feat: add support for disabling/enabling user accounts (#7273)

#### What type of PR is this?

/kind feature
/area ui
/milestone 2.20.x

#### What this PR does / why we need it:

Add support for disabling/enabling user accounts

<img width="1207" alt="image" src="https://github.com/user-attachments/assets/a298e6f7-21a1-4b1c-86c3-1064a136e28c" />

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/7250

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
支持在管理控制台禁用指定用户
```
pull/7284/head v2.20.16
Ryan Wang 2025-03-10 23:15:02 +08:00 committed by GitHub
parent ca8bc52079
commit 4ad97cd58e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 850 additions and 191 deletions

View File

@ -7556,6 +7556,70 @@
]
}
},
"/apis/console.api.security.halo.run/v1alpha1/users/{username}/disable": {
"post": {
"description": "Disable user by username",
"operationId": "DisableUser",
"parameters": [
{
"description": "Username",
"in": "path",
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
},
"description": "The user has been disabled."
}
},
"tags": [
"UserV1alpha1Console"
]
}
},
"/apis/console.api.security.halo.run/v1alpha1/users/{username}/enable": {
"post": {
"description": "Enable user by username",
"operationId": "EnableUser",
"parameters": [
{
"description": "Username",
"in": "path",
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
},
"description": "The user has been enabled."
}
},
"tags": [
"UserV1alpha1Console"
]
}
},
"/apis/content.halo.run/v1alpha1/categories": {
"get": {
"description": "List Category",

View File

@ -3374,6 +3374,70 @@
"NotifierV1alpha1Console"
]
}
},
"/apis/console.api.security.halo.run/v1alpha1/users/{username}/disable": {
"post": {
"description": "Disable user by username",
"operationId": "DisableUser",
"parameters": [
{
"description": "Username",
"in": "path",
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
},
"description": "The user has been disabled."
}
},
"tags": [
"UserV1alpha1Console"
]
}
},
"/apis/console.api.security.halo.run/v1alpha1/users/{username}/enable": {
"post": {
"description": "Enable user by username",
"operationId": "EnableUser",
"parameters": [
{
"description": "Username",
"in": "path",
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
},
"description": "The user has been enabled."
}
},
"tags": [
"UserV1alpha1Console"
]
}
}
},
"components": {

View File

@ -26,4 +26,9 @@ public interface UserService {
Flux<User> listByEmail(String email);
String encryptPassword(String rawPassword);
Mono<User> disable(String username);
Mono<User> enable(String username);
}

View File

@ -11,4 +11,6 @@ public interface DeviceService {
Mono<Void> changeSessionId(ServerWebExchange exchange);
Mono<Void> revoke(String principalName, String deviceId);
Mono<Void> revoke(String username);
}

View File

@ -0,0 +1,109 @@
package run.halo.app.core.endpoint.console;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import java.util.Objects;
import org.springdoc.core.fn.builders.parameter.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.core.user.service.UserService;
import run.halo.app.extension.GroupVersion;
/**
* User endpoint for console.
*
* @author johnniang
*/
@Component
class ConsoleUserEndpoint implements CustomEndpoint {
private final UserService userService;
ConsoleUserEndpoint(UserService userService) {
this.userService = userService;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
var tag = "UserV1alpha1Console";
return RouterFunctions.nest(RequestPredicates.path("/users"), SpringdocRouteBuilder.route()
.POST("/{username}/disable", this::handleDisableUser, ops -> {
ops.operationId("DisableUser")
.tag(tag)
.description("Disable user by username")
.parameter(Builder.parameterBuilder()
.name("username")
.in(ParameterIn.PATH)
.description("Username")
.required(true)
.implementation(String.class)
)
.response(responseBuilder()
.implementation(User.class)
.description("The user has been disabled.")
);
})
.POST("/{username}/enable", this::handleEnableUser, ops -> {
ops.operationId("EnableUser")
.tag(tag)
.description("Enable user by username")
.parameter(Builder.parameterBuilder()
.name("username")
.in(ParameterIn.PATH)
.description("Username")
.required(true)
.implementation(String.class)
)
.response(responseBuilder()
.implementation(User.class)
.description("The user has been enabled.")
);
})
.build());
}
private Mono<ServerResponse> handleEnableUser(ServerRequest request) {
return userService.enable(request.pathVariable("username"))
.switchIfEmpty(Mono.error(
() -> new ServerWebInputException("The user was not found or has been enabled."))
)
.flatMap(user -> ServerResponse.ok().bodyValue(user));
}
private Mono<ServerResponse> handleDisableUser(ServerRequest request) {
var username = request.pathVariable("username");
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Authentication::getName)
.switchIfEmpty(Mono.error(
() -> new ServerWebInputException("The current user is not authenticated."))
)
.filter(currentUsername -> !Objects.equals(currentUsername, username))
.switchIfEmpty(Mono.error(() -> new ServerWebInputException(
"The user is the current user, can't disable it."
)))
.then(Mono.defer(() -> userService.disable(username)))
.switchIfEmpty(Mono.error(
() -> new ServerWebInputException("The user was not found or has been disabled."))
)
.flatMap(user -> ServerResponse.ok().bodyValue(user));
}
@Override
public GroupVersion groupVersion() {
return new GroupVersion("console.api.security.halo.run", "v1alpha1");
}
}

View File

@ -15,6 +15,8 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.ReactiveTransactionManager;
import org.springframework.transaction.reactive.TransactionalOperator;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
@ -45,6 +47,7 @@ import run.halo.app.infra.exception.EmailVerificationFailed;
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
import run.halo.app.infra.exception.UserNotFoundException;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.security.device.DeviceService;
@Service
@RequiredArgsConstructor
@ -66,6 +69,10 @@ public class UserServiceImpl implements UserService {
private final ExtensionGetter extensionGetter;
private final DeviceService deviceService;
private final ReactiveTransactionManager transactionManager;
private Clock clock = Clock.systemUTC();
void setClock(Clock clock) {
@ -276,6 +283,25 @@ public class UserServiceImpl implements UserService {
return passwordEncoder.encode(rawPassword);
}
@Override
public Mono<User> disable(String username) {
var tx = TransactionalOperator.create(transactionManager);
return client.fetch(User.class, username)
.filter(user -> !Boolean.TRUE.equals(user.getSpec().getDisabled()))
.flatMap(user -> deviceService.revoke(username).thenReturn(user))
.doOnNext(user -> user.getSpec().setDisabled(true))
.flatMap(client::update)
.as(tx::transactional);
}
@Override
public Mono<User> enable(String username) {
return client.fetch(User.class, username)
.filter(user -> Boolean.TRUE.equals(user.getSpec().getDisabled()))
.doOnNext(user -> user.getSpec().setDisabled(false))
.flatMap(client::update);
}
void publishPasswordChangedEvent(String username) {
eventPublisher.publishEvent(new PasswordChangedEvent(this, username));
}

View File

@ -9,6 +9,7 @@ import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute
import com.fasterxml.jackson.core.type.TypeReference;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@ -177,6 +178,13 @@ public class SchemeInitializer implements ApplicationListener<ApplicationContext
})
)
.orElseGet(Set::of))));
indexSpecs.add(new IndexSpec()
.setName("spec.disabled")
.setIndexFunc(simpleAttribute(User.class, user ->
Objects.requireNonNullElse(user.getSpec().getDisabled(), Boolean.FALSE)
.toString())
)
);
});
schemeManager.register(ReverseProxy.class);
schemeManager.register(Setting.class);

View File

@ -10,6 +10,7 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.MessageSource;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.CredentialsContainer;
@ -65,7 +66,10 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
.switchIfEmpty(Mono.defer(
() -> {
URI location = URI.create("/login?error&method=local");
var location = URI.create("/login?error&method=local");
if (exception instanceof DisabledException) {
location = URI.create("/login?error=account-disabled&method=local");
}
if (exception instanceof BadCredentialsException) {
location = URI.create("/login?error=invalid-credential&method=local");
}

View File

@ -1,5 +1,6 @@
package run.halo.app.security.device;
import static run.halo.app.extension.ExtensionUtil.defaultSort;
import static run.halo.app.infra.utils.IpAddressUtils.getClientIp;
import static run.halo.app.security.authentication.rememberme.PersistentTokenBasedRememberMeServices.REMEMBER_ME_SERIES_REQUEST_NAME;
@ -24,8 +25,10 @@ import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.core.extension.Device;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.security.authentication.rememberme.PersistentRememberMeTokenRepository;
@Slf4j
@ -132,6 +135,20 @@ public class DeviceServiceImpl implements DeviceService {
.flatMap(revoked -> sessionRepository.deleteById(revoked.getSpec().getSessionId()));
}
@Override
public Mono<Void> revoke(String username) {
var listOptions = ListOptions.builder()
.andQuery(QueryFactory.equal("spec.principalName", username))
.build();
return client.listAll(Device.class, listOptions, defaultSort())
.flatMap(this::removeRememberMeToken)
.flatMap(device -> sessionRepository.deleteById(device.getSpec().getSessionId())
.thenReturn(device)
)
.flatMap(client::delete)
.then();
}
private Mono<Device> removeRememberMeToken(Device device) {
var seriesId = device.getSpec().getRememberMeSeriesId();
if (StringUtils.isBlank(seriesId)) {

View File

@ -13,6 +13,9 @@
<strong th:if="${error == 'rate-limit-exceeded'}">
<span th:text="#{form.error.rateLimitExceeded}"></span>
</strong>
<strong th:if="${error == 'account-disabled'}">
<span th:text="#{form.error.accountDisabled}"></span>
</strong>
</div>
<div class="alert" role="alert" th:if="${param.logout.size() > 0}">
<strong th:text="#{form.messages.logoutSuccess}"></strong>

View File

@ -4,6 +4,7 @@ form.messages.oauth2Bind=当前登录未绑定账号,请尝试通过其他方
form.messages.passwordReset=密码重置成功,请立即登录。
form.error.invalidCredential=无效的凭证。
form.error.rateLimitExceeded=请求过于频繁,请稍后再试。
form.error.accountDisabled=账号已被禁用。
form.rememberMe.label=保持登录会话
form.submit=登录

View File

@ -4,6 +4,7 @@ form.messages.oauth2Bind=The current login is not bound to an account. Please tr
form.messages.passwordReset=Password reset successfully, please login now.
form.error.invalidCredential=Invalid credentials.
form.error.rateLimitExceeded=Too many requests, please try again later.
form.error.accountDisabled=Account has been disabled.
form.rememberMe.label=Remember me
form.submit=Login

View File

@ -4,6 +4,7 @@ form.messages.oauth2Bind=El inicio de sesión actual no está vinculado a una cu
form.messages.passwordReset=¡Felicidades! La contraseña se ha restablecido con éxito.
form.error.invalidCredential=Credenciales inválidas.
form.error.rateLimitExceeded=Demasiadas solicitudes, por favor intente nuevamente más tarde.
form.error.accountDisabled=La cuenta ha sido deshabilitada.
form.rememberMe.label=Mantener sesión iniciada
form.submit=Iniciar sesión

View File

@ -4,6 +4,7 @@ form.messages.oauth2Bind=當前登入未綁定至帳戶。請嘗試通過其他
form.messages.passwordReset=密碼重置成功。請立即登入。
form.error.invalidCredential=無效的憑證。
form.error.rateLimitExceeded=請求過於頻繁,請稍後再試。
form.error.accountDisabled=帳戶已被停用。
form.form.rememberMe.label=保持登入會話
form.form.submit=登入

View File

@ -10,11 +10,13 @@ import {
Toast,
VButton,
VDropdown,
VDropdownDivider,
VDropdownItem,
VTabbar,
VTag,
} from "@halo-dev/components";
import type { UserTab } from "@halo-dev/console-shared";
import { useQuery } from "@tanstack/vue-query";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import {
computed,
@ -30,8 +32,10 @@ import { useRoute, useRouter } from "vue-router";
import GrantPermissionModal from "./components/GrantPermissionModal.vue";
import UserEditingModal from "./components/UserEditingModal.vue";
import UserPasswordChangeModal from "./components/UserPasswordChangeModal.vue";
import { useUserEnableDisable } from "./composables/use-user";
import DetailTab from "./tabs/Detail.vue";
const queryClient = useQueryClient();
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const { currentUser } = useUserStore();
@ -134,6 +138,8 @@ function onGrantPermissionModalClose() {
grantPermissionModal.value = false;
refetch();
}
const { handleEnableOrDisableUser } = useUserEnableDisable();
</script>
<template>
<UserEditingModal
@ -162,9 +168,14 @@ function onGrantPermissionModalClose() {
<UserAvatar :name="user?.user.metadata.name" />
</div>
<div class="block">
<div class="flex items-center gap-2">
<h1 class="truncate text-lg font-bold text-gray-900">
{{ user?.user.spec.displayName }}
</h1>
<VTag v-if="user?.user.spec.disabled">
{{ $t("core.user.fields.disabled") }}
</VTag>
</div>
<span v-if="!isLoading" class="text-sm text-gray-600">
@{{ user?.user.metadata.name }}
</span>
@ -195,6 +206,33 @@ function onGrantPermissionModalClose() {
>
{{ $t("core.user.detail.actions.grant_permission.title") }}
</VDropdownItem>
<VDropdownDivider
v-if="currentUser?.metadata.name !== user?.user.metadata.name"
/>
<VDropdownItem
v-if="
!!user &&
currentUser?.metadata.name !== user?.user.metadata.name
"
type="danger"
@click="
handleEnableOrDisableUser({
name: user.user.metadata.name,
operation: user.user.spec.disabled ? 'enable' : 'disable',
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['user-detail'],
});
},
})
"
>
{{
user.user.spec.disabled
? $t("core.user.operations.enable.title")
: $t("core.user.operations.disable.title")
}}
</VDropdownItem>
<VDropdownItem
v-if="
user &&

View File

@ -2,7 +2,6 @@
import { useFetchRole } from "@/composables/use-role";
import { rbacAnnotations } from "@/constants/annotations";
import { useUserStore } from "@/stores/user";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import type { ListedUser, User } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
@ -14,40 +13,28 @@ import {
IconShieldUser,
IconUserSettings,
Toast,
VAvatar,
VButton,
VCard,
VDropdownItem,
VEmpty,
VEntity,
VEntityField,
VLoading,
VPageHeader,
VPagination,
VSpace,
VStatusDot,
VTag,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import GrantPermissionModal from "./components/GrantPermissionModal.vue";
import UserCreationModal from "./components/UserCreationModal.vue";
import UserEditingModal from "./components/UserEditingModal.vue";
import UserPasswordChangeModal from "./components/UserPasswordChangeModal.vue";
import UserListItem from "./components/UserListItem.vue";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const checkedAll = ref(false);
const editingModal = ref<boolean>(false);
const creationModal = ref<boolean>(false);
const passwordChangeModal = ref<boolean>(false);
const grantPermissionModal = ref<boolean>(false);
const selectedUserNames = ref<string[]>([]);
const selectedUser = ref<User>();
const keyword = useRouteQuery<string>("keyword", "");
const userStore = useUserStore();
@ -122,34 +109,8 @@ const {
return hasDeletingData ? 1000 : false;
},
onSuccess() {
selectedUser.value = undefined;
},
});
const handleDelete = async (user: User) => {
Dialog.warning({
title: t("core.user.operations.delete.title"),
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await coreApiClient.user.deleteUser({
name: user.metadata.name,
});
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete user", e);
} finally {
await refetch();
}
},
});
};
const handleDeleteInBatch = async () => {
Dialog.warning({
title: t("core.user.operations.delete_in_batch.title"),
@ -184,10 +145,7 @@ watch(selectedUserNames, (newValue) => {
});
const checkSelection = (user: User) => {
return (
user.metadata.name === selectedUser.value?.metadata.name ||
selectedUserNames.value.includes(user.metadata.name)
);
return selectedUserNames.value.includes(user.metadata.name);
};
const handleCheckAllChange = (e: Event) => {
@ -209,21 +167,6 @@ const handleCheckAllChange = (e: Event) => {
}
};
const handleOpenCreateModal = (user: User) => {
selectedUser.value = user;
editingModal.value = true;
};
const handleOpenPasswordChangeModal = (user: User) => {
selectedUser.value = user;
passwordChangeModal.value = true;
};
const handleOpenGrantPermissionModal = (user: User) => {
selectedUser.value = user;
grantPermissionModal.value = true;
};
// Route query action
const routeQueryAction = useRouteQuery<string | undefined>("action");
@ -237,44 +180,10 @@ function onCreationModalClose() {
creationModal.value = false;
routeQueryAction.value = undefined;
}
function onEditingModalClose() {
editingModal.value = false;
selectedUser.value = undefined;
}
function onPasswordChangeModalClose() {
passwordChangeModal.value = false;
refetch();
}
function onGrantPermissionModalClose() {
grantPermissionModal.value = false;
selectedUser.value = undefined;
refetch();
}
</script>
<template>
<UserEditingModal
v-if="editingModal && selectedUser"
:user="selectedUser"
@close="onEditingModalClose"
/>
<UserCreationModal v-if="creationModal" @close="onCreationModalClose" />
<UserPasswordChangeModal
v-if="passwordChangeModal"
:user="selectedUser"
@close="onPasswordChangeModalClose"
/>
<GrantPermissionModal
v-if="grantPermissionModal"
:user="selectedUser"
@close="onGrantPermissionModalClose"
/>
<VPageHeader :title="$t('core.user.title')">
<template #icon>
<IconUserSettings class="mr-2 self-center" />
@ -413,7 +322,7 @@ function onGrantPermissionModalClose() {
<VButton
v-permission="['system:users:manage']"
type="secondary"
@click="editingModal = true"
@click="creationModal = true"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
@ -431,7 +340,7 @@ function onGrantPermissionModalClose() {
role="list"
>
<li v-for="(user, index) in users" :key="index">
<VEntity :is-selected="checkSelection(user.user)">
<UserListItem :user="user" :is-selected="checkSelection(user.user)">
<template
v-if="currentUserHasPermission(['system:users:manage'])"
#checkbox
@ -439,7 +348,7 @@ function onGrantPermissionModalClose() {
<input
v-model="selectedUserNames"
:value="user.user.metadata.name"
name="post-checkbox"
name="user-checkbox"
type="checkbox"
:disabled="
user.user.metadata.name ===
@ -447,95 +356,7 @@ function onGrantPermissionModalClose() {
"
/>
</template>
<template #start>
<VEntityField>
<template #description>
<VAvatar
:alt="user.user.spec.displayName"
:src="user.user.spec.avatar"
size="md"
></VAvatar>
</template>
</VEntityField>
<VEntityField
:title="user.user.spec.displayName"
:description="user.user.metadata.name"
:route="{
name: 'UserDetail',
params: { name: user.user.metadata.name },
}"
/>
</template>
<template #end>
<VEntityField>
<template #description>
<VSpace>
<VTag
v-for="role in user.roles"
:key="role.metadata.name"
>
<template #leftIcon>
<IconShieldUser />
</template>
{{
role.metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || role.metadata.name
}}
</VTag>
</VSpace>
</template>
</VEntityField>
<VEntityField v-if="user.user.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(user.user.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:users:manage'])"
#dropdownItems
>
<VDropdownItem @click="handleOpenCreateModal(user.user)">
{{ $t("core.user.operations.update_profile.title") }}
</VDropdownItem>
<VDropdownItem
@click="handleOpenPasswordChangeModal(user.user)"
>
{{ $t("core.user.operations.change_password.title") }}
</VDropdownItem>
<VDropdownItem
v-if="
userStore.currentUser?.metadata.name !==
user.user.metadata.name
"
@click="handleOpenGrantPermissionModal(user.user)"
>
{{ $t("core.user.operations.grant_permission.title") }}
</VDropdownItem>
<VDropdownItem
v-if="
userStore.currentUser?.metadata.name !==
user.user.metadata.name
"
type="danger"
@click="handleDelete(user.user)"
>
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template>
</VEntity>
</UserListItem>
</li>
</ul>
</Transition>

View File

@ -0,0 +1,224 @@
<script setup lang="ts">
import { rbacAnnotations } from "@/constants/annotations";
import { useUserStore } from "@/stores/user";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import { coreApiClient, type ListedUser } from "@halo-dev/api-client";
import {
Dialog,
IconShieldUser,
Toast,
VAvatar,
VDropdownDivider,
VDropdownItem,
VEntity,
VEntityField,
VSpace,
VStatusDot,
VTag,
} from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import { storeToRefs } from "pinia";
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { useUserEnableDisable } from "../composables/use-user";
import GrantPermissionModal from "./GrantPermissionModal.vue";
import UserEditingModal from "./UserEditingModal.vue";
import UserPasswordChangeModal from "./UserPasswordChangeModal.vue";
const props = withDefaults(
defineProps<{
user: ListedUser;
isSelected?: boolean;
}>(),
{ isSelected: false }
);
const queryClient = useQueryClient();
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const { currentUser } = storeToRefs(useUserStore());
const handleDelete = async () => {
Dialog.warning({
title: t("core.user.operations.delete.title"),
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await coreApiClient.user.deleteUser({
name: props.user.user.metadata.name,
});
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete user", e);
} finally {
queryClient.invalidateQueries({
queryKey: ["users"],
});
}
},
});
};
const grantPermissionModal = ref(false);
function onGrantPermissionModalClose() {
grantPermissionModal.value = false;
queryClient.invalidateQueries({
queryKey: ["users"],
});
}
const passwordChangeModal = ref<boolean>(false);
function onPasswordChangeModalClose() {
passwordChangeModal.value = false;
queryClient.invalidateQueries({
queryKey: ["users"],
});
}
const editingModal = ref<boolean>(false);
function onEditingModalClose() {
editingModal.value = false;
queryClient.invalidateQueries({
queryKey: ["users"],
});
}
const { handleEnableOrDisableUser } = useUserEnableDisable();
</script>
<template>
<UserEditingModal
v-if="editingModal"
:user="user.user"
@close="onEditingModalClose"
/>
<GrantPermissionModal
v-if="grantPermissionModal"
:user="user.user"
@close="onGrantPermissionModalClose"
/>
<UserPasswordChangeModal
v-if="passwordChangeModal"
:user="user.user"
@close="onPasswordChangeModalClose"
/>
<VEntity :is-selected="isSelected">
<template #checkbox>
<slot name="checkbox" />
</template>
<template #start>
<VEntityField>
<template #description>
<VAvatar
:alt="user.user.spec.displayName"
:src="user.user.spec.avatar"
size="md"
></VAvatar>
</template>
</VEntityField>
<VEntityField
:title="user.user.spec.displayName"
:description="user.user.metadata.name"
:route="{
name: 'UserDetail',
params: { name: user.user.metadata.name },
}"
>
<template v-if="user.user.spec.disabled" #extra>
<VTag>
{{ $t("core.user.fields.disabled") }}
</VTag>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField>
<template #description>
<VSpace>
<VTag v-for="role in user.roles" :key="role.metadata.name">
<template #leftIcon>
<IconShieldUser />
</template>
{{
role.metadata.annotations?.[rbacAnnotations.DISPLAY_NAME] ||
role.metadata.name
}}
</VTag>
</VSpace>
</template>
</VEntityField>
<VEntityField v-if="user.user.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(user.user.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:users:manage'])"
#dropdownItems
>
<VDropdownItem @click="editingModal = true">
{{ $t("core.user.operations.update_profile.title") }}
</VDropdownItem>
<VDropdownItem @click="passwordChangeModal = true">
{{ $t("core.user.operations.change_password.title") }}
</VDropdownItem>
<VDropdownItem
v-if="currentUser?.metadata.name !== user.user.metadata.name"
@click="grantPermissionModal = true"
>
{{ $t("core.user.operations.grant_permission.title") }}
</VDropdownItem>
<VDropdownDivider
v-if="currentUser?.metadata.name !== user.user.metadata.name"
/>
<VDropdownItem
v-if="currentUser?.metadata.name !== user.user.metadata.name"
type="danger"
@click="
handleEnableOrDisableUser({
name: user.user.metadata.name,
operation: user.user.spec.disabled ? 'enable' : 'disable',
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['users'],
});
},
})
"
>
{{
user.user.spec.disabled
? $t("core.user.operations.enable.title")
: $t("core.user.operations.disable.title")
}}
</VDropdownItem>
<VDropdownItem
v-if="currentUser?.metadata.name !== user.user.metadata.name"
type="danger"
@click="handleDelete"
>
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template>
</VEntity>
</template>

View File

@ -1,7 +1,9 @@
import type { User } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import { Dialog, Toast } from "@halo-dev/components";
import type { Ref } from "vue";
import { onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
interface useUserFetchReturn {
users: Ref<User[]>;
@ -43,3 +45,62 @@ export function useUserFetch(options?: {
handleFetchUsers,
};
}
export function useUserEnableDisable() {
const { t } = useI18n();
const handleEnableOrDisableUser = ({
name,
operation,
onSuccess,
}: {
name: string;
operation: "enable" | "disable";
onSuccess?: () => void;
}) => {
const operations = {
enable: {
title: t("core.user.operations.enable.title"),
description: t("core.user.operations.enable.description"),
value: false,
},
disable: {
title: t("core.user.operations.disable.title"),
description: t("core.user.operations.disable.description"),
value: true,
},
};
const operationConfig = operations[operation];
Dialog.warning({
title: operationConfig.title,
description: operationConfig.description,
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
if (operation == "enable") {
await consoleApiClient.user.enableUser({
username: name,
});
} else {
await consoleApiClient.user.disableUser({
username: name,
});
}
Toast.success(t("core.common.toast.operation_success"));
onSuccess?.();
} catch (e) {
console.error("Failed to enable or disable user", e);
}
},
});
};
return {
handleEnableOrDisableUser,
};
}

View File

@ -212,6 +212,88 @@ export const UserV1alpha1ConsoleApiAxiosParamCreator = function (configuration?:
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Disable user by username
* @param {string} username Username
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
disableUser: async (username: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'username' is not null or undefined
assertParamExists('disableUser', 'username', username)
const localVarPath = `/apis/console.api.security.halo.run/v1alpha1/users/{username}/disable`
.replace(`{${"username"}}`, encodeURIComponent(String(username)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication basicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
// authentication bearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Enable user by username
* @param {string} username Username
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
enableUser: async (username: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'username' is not null or undefined
assertParamExists('enableUser', 'username', username)
const localVarPath = `/apis/console.api.security.halo.run/v1alpha1/users/{username}/enable`
.replace(`{${"username"}}`, encodeURIComponent(String(username)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication basicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
// authentication bearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -699,6 +781,30 @@ export const UserV1alpha1ConsoleApiFp = function(configuration?: Configuration)
const localVarOperationServerBasePath = operationServerMap['UserV1alpha1ConsoleApi.deleteUserAvatar']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Disable user by username
* @param {string} username Username
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async disableUser(username: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<User>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.disableUser(username, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['UserV1alpha1ConsoleApi.disableUser']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Enable user by username
* @param {string} username Username
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async enableUser(username: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<User>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.enableUser(username, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['UserV1alpha1ConsoleApi.enableUser']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Get current user detail
* @param {*} [options] Override http request option.
@ -860,6 +966,24 @@ export const UserV1alpha1ConsoleApiFactory = function (configuration?: Configura
deleteUserAvatar(requestParameters: UserV1alpha1ConsoleApiDeleteUserAvatarRequest, options?: RawAxiosRequestConfig): AxiosPromise<User> {
return localVarFp.deleteUserAvatar(requestParameters.name, options).then((request) => request(axios, basePath));
},
/**
* Disable user by username
* @param {UserV1alpha1ConsoleApiDisableUserRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
disableUser(requestParameters: UserV1alpha1ConsoleApiDisableUserRequest, options?: RawAxiosRequestConfig): AxiosPromise<User> {
return localVarFp.disableUser(requestParameters.username, options).then((request) => request(axios, basePath));
},
/**
* Enable user by username
* @param {UserV1alpha1ConsoleApiEnableUserRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
enableUser(requestParameters: UserV1alpha1ConsoleApiEnableUserRequest, options?: RawAxiosRequestConfig): AxiosPromise<User> {
return localVarFp.enableUser(requestParameters.username, options).then((request) => request(axios, basePath));
},
/**
* Get current user detail
* @param {*} [options] Override http request option.
@ -1006,6 +1130,34 @@ export interface UserV1alpha1ConsoleApiDeleteUserAvatarRequest {
readonly name: string
}
/**
* Request parameters for disableUser operation in UserV1alpha1ConsoleApi.
* @export
* @interface UserV1alpha1ConsoleApiDisableUserRequest
*/
export interface UserV1alpha1ConsoleApiDisableUserRequest {
/**
* Username
* @type {string}
* @memberof UserV1alpha1ConsoleApiDisableUser
*/
readonly username: string
}
/**
* Request parameters for enableUser operation in UserV1alpha1ConsoleApi.
* @export
* @interface UserV1alpha1ConsoleApiEnableUserRequest
*/
export interface UserV1alpha1ConsoleApiEnableUserRequest {
/**
* Username
* @type {string}
* @memberof UserV1alpha1ConsoleApiEnableUser
*/
readonly username: string
}
/**
* Request parameters for getPermissions operation in UserV1alpha1ConsoleApi.
* @export
@ -1225,6 +1377,28 @@ export class UserV1alpha1ConsoleApi extends BaseAPI {
return UserV1alpha1ConsoleApiFp(this.configuration).deleteUserAvatar(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
}
/**
* Disable user by username
* @param {UserV1alpha1ConsoleApiDisableUserRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UserV1alpha1ConsoleApi
*/
public disableUser(requestParameters: UserV1alpha1ConsoleApiDisableUserRequest, options?: RawAxiosRequestConfig) {
return UserV1alpha1ConsoleApiFp(this.configuration).disableUser(requestParameters.username, options).then((request) => request(this.axios, this.basePath));
}
/**
* Enable user by username
* @param {UserV1alpha1ConsoleApiEnableUserRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UserV1alpha1ConsoleApi
*/
public enableUser(requestParameters: UserV1alpha1ConsoleApiEnableUserRequest, options?: RawAxiosRequestConfig) {
return UserV1alpha1ConsoleApiFp(this.configuration).enableUser(requestParameters.username, options).then((request) => request(this.axios, this.basePath));
}
/**
* Get current user detail
* @param {*} [options] Override http request option.

View File

@ -242,6 +242,13 @@ core:
actions:
extension-point-settings: Extension settings
user:
operations:
enable:
title: Enable
description: Are you sure you want to enable this user?
disable:
title: Disable
description: Are you sure you want to disable this user? This user will not be able to log in after being disabled
grant_permission_modal:
roles_preview:
all: The currently selected role contains all permissions
@ -257,6 +264,8 @@ core:
tooltip: Verified
email_not_verified:
tooltip: Not verified
fields:
disabled: Disabled
role:
editing_modal:
fields:

View File

@ -1018,6 +1018,14 @@ core:
title: Change password
grant_permission:
title: Grant permission
enable:
title: Enable
description: Are you sure you want to enable this user?
disable:
title: Disable
description: >-
Are you sure you want to disable this user? This user will not be able
to log in after being disabled
filters:
role:
label: Role
@ -1091,6 +1099,8 @@ core:
tooltip: Verified
email_not_verified:
tooltip: Not verified
fields:
disabled: Disabled
role:
title: Roles
common:

View File

@ -951,6 +951,12 @@ core:
title: 修改密码
grant_permission:
title: 分配角色
enable:
title: 取消禁用
description: 确定取消禁用该用户吗?
disable:
title: 禁用
description: 确定禁用该用户吗?禁用之后该用户将无法登录系统
filters:
role:
label: 角色
@ -1021,6 +1027,8 @@ core:
tooltip: 已验证
email_not_verified:
tooltip: 未验证
fields:
disabled: 已禁用
role:
title: 角色
common:

View File

@ -936,6 +936,12 @@ core:
title: 修改密碼
grant_permission:
title: 分配角色
enable:
title: 取消停用
description: 確定要取消停用該用戶嗎?
disable:
title: 停用
description: 確定要停用該用戶嗎?停用後該用戶將無法登入系統
filters:
role:
label: 角色
@ -1006,6 +1012,8 @@ core:
tooltip: 已驗證
email_not_verified:
tooltip: 未驗證
fields:
disabled: 已停用
role:
title: 角色
common: