feat: add multi-role assignment support for users (#7037)

#### What type of PR is this?

/area ui
/kind feature
/milestone 2.20.x

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

支持为用户分配多个角色。

<img width="634" alt="image" src="https://github.com/user-attachments/assets/caa40327-518a-4bef-afc3-75a020018d3d">
<img width="764" alt="image" src="https://github.com/user-attachments/assets/8b4b807e-6c72-45d9-9368-75e70bb12dcc">

TODO:

- [x] Console / UC 侧边栏显示多个角色
- [x] 支持在管理端查看用户聚合的角色模板列表,或者在分配时显示所选角色其下的角色模板列表,能够让管理员清楚的知道用户具体权限。

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

Fixes #

#### Special notes for your reviewer:

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

```release-note
支持为用户分配多个角色。
```
pull/7075/head
Ryan Wang 2024-11-24 23:48:21 +08:00 committed by GitHub
parent 964bc28052
commit 391aac62d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 403 additions and 90 deletions

View File

@ -9,11 +9,13 @@ import { coreMenuGroups } from "@console/router/constant";
import {
Dialog,
IconAccountCircleLine,
IconArrowDownLine,
IconLogoutCircleRLine,
IconMore,
IconSearch,
IconUserSettings,
IconShieldUser,
VAvatar,
VDropdown,
VTag,
} from "@halo-dev/components";
import { useEventListener } from "@vueuse/core";
@ -152,10 +154,10 @@ onMounted(() => {
>
{{ currentUser?.spec.displayName }}
</div>
<div v-if="currentRoles?.[0]" class="flex">
<VTag>
<div v-if="currentRoles?.length" class="flex mt-1">
<VTag v-if="currentRoles.length === 1">
<template #leftIcon>
<IconUserSettings />
<IconShieldUser />
</template>
{{
currentRoles[0].metadata.annotations?.[
@ -163,6 +165,41 @@ onMounted(() => {
] || currentRoles[0].metadata.name
}}
</VTag>
<VDropdown v-else>
<div class="flex gap-1">
<VTag>
<template #leftIcon>
<IconShieldUser />
</template>
{{ $t("core.sidebar.profile.aggregate_role") }}
</VTag>
<IconArrowDownLine />
</div>
<template #popper>
<div class="p-1">
<h2
class="text-gray-600 text-sm font-semibold border-b border-gray-100 pb-1.5"
>
{{ $t("core.sidebar.profile.aggregate_role") }}
</h2>
<div class="flex gap-2 flex-wrap mt-2">
<VTag
v-for="role in currentRoles"
:key="role.metadata.name"
>
<template #leftIcon>
<IconShieldUser />
</template>
{{
role.metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || role.metadata.name
}}
</VTag>
</div>
</div>
</template>
</VDropdown>
</div>
</div>
@ -299,6 +336,7 @@ onMounted(() => {
.profile-placeholder {
height: 70px;
flex: none;
.current-profile {
height: 70px;

View File

@ -183,8 +183,8 @@ const handleUpdateRole = async () => {
<div>
<dl class="divide-y divide-gray-100">
<div
v-for="(group, groupIndex) in roleTemplateGroups"
:key="groupIndex"
v-for="(group, index) in roleTemplateGroups"
:key="index"
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">
@ -224,7 +224,7 @@ const handleUpdateRole = async () => {
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<ul class="space-y-2">
<li v-for="(role, index) in group.roles" :key="index">
<li v-for="role in group.roles" :key="role.metadata.name">
<label
class="inline-flex w-72 cursor-pointer flex-row items-center gap-4 rounded-base border p-5 hover:border-primary"
>

View File

@ -101,7 +101,7 @@ const tabbarItems = computed(() => {
}));
});
const handleDelete = async (userToDelete: User) => {
const handleDelete = async (user: User) => {
Dialog.warning({
title: t("core.user.operations.delete.title"),
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
@ -111,7 +111,7 @@ const handleDelete = async (userToDelete: User) => {
onConfirm: async () => {
try {
await coreApiClient.user.deleteUser({
name: userToDelete.metadata.name,
name: user.metadata.name,
});
Toast.success(t("core.common.toast.delete_success"));
router.push({ name: "Users" });
@ -189,11 +189,17 @@ function onGrantPermissionModalClose() {
<VDropdownItem @click="passwordChangeModal = true">
{{ $t("core.user.detail.actions.change_password.title") }}
</VDropdownItem>
<VDropdownItem @click="grantPermissionModal = true">
<VDropdownItem
v-if="currentUser?.metadata.name !== user?.user.metadata.name"
@click="grantPermissionModal = true"
>
{{ $t("core.user.detail.actions.grant_permission.title") }}
</VDropdownItem>
<VDropdownItem
v-if="user?.user"
v-if="
user &&
currentUser?.metadata.name !== user?.user.metadata.name
"
type="danger"
@click="handleDelete(user.user)"
>

View File

@ -11,7 +11,7 @@ import {
IconAddCircle,
IconLockPasswordLine,
IconRefreshLine,
IconUserFollow,
IconShieldUser,
IconUserSettings,
Toast,
VAvatar,
@ -288,7 +288,7 @@ function onGrantPermissionModalClose() {
type="default"
>
<template #icon>
<IconUserFollow class="h-full w-full" />
<IconShieldUser class="h-full w-full" />
</template>
{{ $t("core.user.actions.roles") }}
</VButton>
@ -469,19 +469,21 @@ function onGrantPermissionModalClose() {
<template #end>
<VEntityField>
<template #description>
<div
v-for="(role, roleIndex) in user.roles"
:key="roleIndex"
class="flex items-center"
>
<VTag>
<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>
</div>
</VSpace>
</template>
</VEntityField>
<VEntityField v-if="user.user.metadata.deletionTimestamp">

View File

@ -1,9 +1,17 @@
<script lang="ts" setup>
import SubmitButton from "@/components/button/SubmitButton.vue";
import { rbacAnnotations } from "@/constants/annotations";
import { SUPER_ROLE_NAME } from "@/constants/constants";
import { roleLabels } from "@/constants/labels";
import type { User } from "@halo-dev/api-client";
import { consoleApiClient } from "@halo-dev/api-client";
import { VButton, VModal, VSpace } from "@halo-dev/components";
import { ref } from "vue";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
import { useMutation, useQuery } from "@tanstack/vue-query";
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import RolesView from "./RolesView.vue";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -19,51 +27,137 @@ const emit = defineEmits<{
}>();
const modal = ref<InstanceType<typeof VModal> | null>(null);
const selectedRole = ref("");
const isSubmitting = ref(false);
const handleGrantPermission = async () => {
try {
isSubmitting.value = true;
await consoleApiClient.user.grantPermission({
const selectedRoleNames = ref<string[]>([]);
onMounted(() => {
if (!props.user) {
return;
}
selectedRoleNames.value = JSON.parse(
props.user.metadata.annotations?.[rbacAnnotations.ROLE_NAMES] || "[]"
);
});
const { mutate, isLoading } = useMutation({
mutationKey: ["core:user:grant-permissions"],
mutationFn: async ({ roles }: { roles: string[] }) => {
return await consoleApiClient.user.grantPermission({
name: props.user?.metadata.name as string,
grantRequest: {
roles: [selectedRole.value],
roles: roles,
},
});
},
onSuccess() {
Toast.success(t("core.common.toast.operation_success"));
modal.value?.close();
} catch (error) {
console.error("Failed to grant permission to user", error);
} finally {
isSubmitting.value = false;
},
});
function onSubmit(data: { roles: string[] }) {
mutate({ roles: data.roles });
}
const { data: allRoles } = useQuery({
queryKey: ["core:roles"],
queryFn: async () => {
const { data } = await coreApiClient.role.listRole({
page: 0,
size: 0,
labelSelector: [`!${roleLabels.TEMPLATE}`],
});
return data;
},
});
const { data: allRoleTemplates } = useQuery({
queryKey: ["core:role-templates"],
queryFn: async () => {
const { data } = await coreApiClient.role.listRole({
page: 0,
size: 0,
labelSelector: [`${roleLabels.TEMPLATE}=true`, "!halo.run/hidden"],
});
return data.items;
},
});
const currentRoleTemplates = computed(() => {
if (!selectedRoleNames.value.length) {
return [];
}
};
const selectedRoles = allRoles.value?.items.filter((role) =>
selectedRoleNames.value.includes(role.metadata.name)
);
let allDependsRoleTemplates: string[] = [];
selectedRoles?.forEach((role) => {
allDependsRoleTemplates = allDependsRoleTemplates.concat(
JSON.parse(
role.metadata.annotations?.[rbacAnnotations.DEPENDENCIES] || "[]"
)
);
});
return allRoleTemplates.value?.filter((item) => {
return allDependsRoleTemplates.includes(item.metadata.name);
});
});
</script>
<template>
<VModal
ref="modal"
:title="$t('core.user.grant_permission_modal.title')"
:width="500"
:width="600"
:centered="false"
@close="emit('close')"
>
<FormKit
id="grant-permission-form"
name="grant-permission-form"
:config="{ validationVisibility: 'submit' }"
type="form"
@submit="handleGrantPermission"
>
<div>
<FormKit
v-model="selectedRole"
:label="$t('core.user.grant_permission_modal.fields.role.label')"
type="roleSelect"
></FormKit>
</FormKit>
id="grant-permission-form"
name="grant-permission-form"
:config="{ validationVisibility: 'submit' }"
type="form"
@submit="onSubmit"
>
<!-- @vue-ignore -->
<FormKit
v-model="selectedRoleNames"
multiple
name="roles"
:label="$t('core.user.grant_permission_modal.fields.role.label')"
type="roleSelect"
:placeholder="
$t('core.user.grant_permission_modal.fields.role.placeholder')
"
></FormKit>
</FormKit>
<div v-if="selectedRoleNames.length">
<div
v-if="selectedRoleNames.includes(SUPER_ROLE_NAME)"
class="text-sm text-gray-600 mt-4"
>
{{ $t("core.user.grant_permission_modal.roles_preview.all") }}
</div>
<div v-else-if="currentRoleTemplates?.length" class="space-y-3 mt-4">
<span class="text-sm text-gray-600">
{{ $t("core.user.grant_permission_modal.roles_preview.includes") }}
</span>
<RolesView :role-templates="currentRoleTemplates" />
</div>
</div>
</div>
<template #footer>
<VSpace>
<SubmitButton
:loading="isSubmitting"
:loading="isLoading"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('grant-permission-form')"

View File

@ -0,0 +1,107 @@
<script setup lang="ts">
import { useRoleTemplateSelection } from "@/composables/use-role";
import { rbacAnnotations } from "@/constants/annotations";
import { pluginLabels } from "@/constants/labels";
import type { Role } from "@halo-dev/api-client";
import { toRefs } from "vue";
const props = withDefaults(
defineProps<{
roleTemplates?: Role[];
}>(),
{
roleTemplates: () => [],
}
);
const { roleTemplates } = toRefs(props);
const { roleTemplateGroups } = useRoleTemplateSelection(roleTemplates);
</script>
<template>
<dl
class="divide-y divide-gray-100 border border-gray-100 rounded-base overflow-hidden"
>
<div
v-for="(group, index) in roleTemplateGroups"
:key="index"
class="bg-white px-4 py-5 hover:bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">
<div>
{{ $t(`core.rbac.${group.module}`, group.module as string) }}
</div>
<div
v-if="
group.roles.length &&
group.roles[0].metadata.labels?.[pluginLabels.NAME]
"
class="mt-3 text-xs text-gray-500"
>
<i18n-t keypath="core.role.common.text.provided_by_plugin" tag="div">
<template #plugin>
<RouterLink
:to="{
name: 'PluginDetail',
params: {
name: group.roles[0].metadata.labels?.[pluginLabels.NAME],
},
}"
class="hover:text-blue-600"
>
{{ group.roles[0].metadata.labels?.[pluginLabels.NAME] }}
</RouterLink>
</template>
</i18n-t>
</div>
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<ul class="space-y-2">
<li v-for="role in group.roles" :key="role.metadata.name">
<label
class="inline-flex w-72 cursor-pointer flex-row items-center gap-4 rounded-base border p-5 hover:border-primary"
>
<input type="checkbox" disabled checked />
<div class="flex flex-1 flex-col gap-y-3">
<span class="font-medium text-gray-900">
{{
$t(
`core.rbac.${
role.metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
]
}`,
role.metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] as string
)
}}
</span>
<span
v-if="
role.metadata.annotations?.[rbacAnnotations.DEPENDENCIES]
"
class="text-xs text-gray-400"
>
{{
$t("core.role.common.text.dependent_on", {
roles: JSON.parse(
role.metadata.annotations?.[
rbacAnnotations.DEPENDENCIES
]
)
.map((item: string) =>
$t(`core.rbac.${item}`, item as string)
)
.join(""),
})
}}
</span>
</div>
</label>
</li>
</ul>
</dd>
</div>
</dl>
</template>

View File

@ -4,9 +4,9 @@ import { formatDatetime } from "@/utils/date";
import type { DetailedUser } from "@halo-dev/api-client";
import {
IconInformation,
IconUserSettings,
VDescription,
VDescriptionItem,
VSpace,
VTag,
} from "@halo-dev/components";
import RiVerifiedBadgeLine from "~icons/ri/verified-badge-line";
@ -55,24 +55,23 @@ withDefaults(defineProps<{ user?: DetailedUser }>(), {
:label="$t('core.user.detail.fields.roles')"
class="!px-2"
>
<VTag
v-for="(role, index) in user?.roles"
:key="index"
@click="
$router.push({
name: 'RoleDetail',
params: { name: role.metadata.name },
})
"
>
<template #leftIcon>
<IconUserSettings />
</template>
{{
role.metadata.annotations?.[rbacAnnotations.DISPLAY_NAME] ||
role.metadata.name
}}
</VTag>
<VSpace>
<VTag
v-for="role in user?.roles"
:key="role.metadata.name"
@click="
$router.push({
name: 'RoleDetail',
params: { name: role.metadata.name },
})
"
>
{{
role.metadata.annotations?.[rbacAnnotations.DISPLAY_NAME] ||
role.metadata.name
}}
</VTag>
</VSpace>
</VDescriptionItem>
<VDescriptionItem
:label="$t('core.user.detail.fields.bio')"

View File

@ -1,4 +1,5 @@
import { rbacAnnotations } from "@/constants/annotations";
import { SUPER_ROLE_NAME } from "@/constants/constants";
import { useRoleStore } from "@/stores/role";
import { useUserStore } from "@/stores/user";
import { hasPermission } from "@/utils/permission";
@ -24,6 +25,10 @@ export function setupPermissionGuard(router: Router) {
}
function isConsoleAccessDisallowed(currentRoles?: Role[]): boolean {
if (currentRoles?.some((role) => role.metadata.name === SUPER_ROLE_NAME)) {
return false;
}
return (
currentRoles?.some(
(role) =>

View File

@ -1,6 +1,5 @@
import { rbacAnnotations } from "@/constants/annotations";
import { roleLabels } from "@/constants/labels";
import { i18n } from "@/locales";
import type { FormKitNode, FormKitTypeDefinition } from "@formkit/core";
import { coreApiClient } from "@halo-dev/api-client";
import { select } from "./select";
@ -14,12 +13,6 @@ function optionsHandler(node: FormKitNode) {
});
const options = [
{
label: i18n.global.t(
"core.user.grant_permission_modal.fields.role.placeholder"
),
value: "",
},
...data.items.map((role) => {
return {
label:

View File

@ -33,6 +33,8 @@ core:
tooltip: Profile
visit_homepage:
title: Visit homepage
profile:
aggregate_role: Aggregate Role
uc_sidebar:
menu:
items:
@ -42,6 +44,8 @@ core:
operations:
console:
tooltip: Console
profile:
aggregate_role: Aggregate Role
dashboard:
title: Dashboard
actions:
@ -1059,6 +1063,9 @@ core:
role:
label: Role
placeholder: Please select a role
roles_preview:
all: The currently selected role contains all permissions
includes: "The currently selected role contains the following permissions:"
detail:
title: User detail
tabs:

View File

@ -31,6 +31,8 @@ core:
tooltip: 个人中心
visit_homepage:
title: 访问首页
profile:
aggregate_role: 聚合角色
uc_sidebar:
menu:
items:
@ -40,6 +42,8 @@ core:
operations:
console:
tooltip: 管理控制台
profile:
aggregate_role: 聚合角色
dashboard:
title: 仪表板
actions:
@ -989,6 +993,9 @@ core:
role:
label: 角色
placeholder: 请选择角色
roles_preview:
all: 当前所选角色包含所有权限
includes: 当前所选角色包含的权限:
detail:
title: 用户详情
tabs:

View File

@ -31,6 +31,8 @@ core:
tooltip: 個人中心
visit_homepage:
title: 訪問首頁
profile:
aggregate_role: 聚合角色
uc_sidebar:
menu:
items:
@ -40,6 +42,8 @@ core:
operations:
console:
tooltip: 管理控制台
profile:
aggregate_role: 聚合角色
dashboard:
title: 儀表板
actions:
@ -966,6 +970,9 @@ core:
role:
label: 角色
placeholder: 請選擇角色
roles_preview:
all: 目前所選角色包含所有權限
includes: 目前選定角色所包含的權限:
detail:
title: 用戶詳情
tabs:

View File

@ -2,15 +2,18 @@
import { RoutesMenu } from "@/components/menu/RoutesMenu";
import { useRouteMenuGenerator } from "@/composables/use-route-menu-generator";
import { rbacAnnotations } from "@/constants/annotations";
import { SUPER_ROLE_NAME } from "@/constants/constants";
import { useUserStore } from "@/stores/user";
import { coreMenuGroups } from "@console/router/constant";
import {
Dialog,
IconArrowDownLine,
IconLogoutCircleRLine,
IconMore,
IconSettings3Line,
IconUserSettings,
IconShieldUser,
VAvatar,
VDropdown,
VTag,
} from "@halo-dev/components";
import {
@ -86,6 +89,12 @@ onMounted(() => {
});
const disallowAccessConsole = computed(() => {
if (
currentRoles?.value?.some((role) => role.metadata.name === SUPER_ROLE_NAME)
) {
return false;
}
const hasDisallowAccessConsoleRole = currentRoles?.value?.some((role) => {
return (
role.metadata.annotations?.[rbacAnnotations.DISALLOW_ACCESS_CONSOLE] ===
@ -121,7 +130,7 @@ const disallowAccessConsole = computed(() => {
<VAvatar
:src="currentUser?.spec.avatar"
:alt="currentUser?.spec.displayName"
size="md"
size="sm"
circle
></VAvatar>
</div>
@ -129,10 +138,10 @@ const disallowAccessConsole = computed(() => {
<div class="flex text-sm font-medium">
{{ currentUser?.spec.displayName }}
</div>
<div v-if="currentRoles?.[0]" class="flex">
<VTag>
<div v-if="currentRoles?.length" class="flex mt-1">
<VTag v-if="currentRoles.length === 1">
<template #leftIcon>
<IconUserSettings />
<IconShieldUser />
</template>
{{
currentRoles[0].metadata.annotations?.[
@ -140,6 +149,41 @@ const disallowAccessConsole = computed(() => {
] || currentRoles[0].metadata.name
}}
</VTag>
<VDropdown v-else>
<div class="flex gap-1">
<VTag>
<template #leftIcon>
<IconShieldUser />
</template>
{{ $t("core.uc_sidebar.profile.aggregate_role") }}
</VTag>
<IconArrowDownLine />
</div>
<template #popper>
<div class="p-1">
<h2
class="text-gray-600 text-sm font-semibold border-b border-gray-100 pb-1.5"
>
{{ $t("core.uc_sidebar.profile.aggregate_role") }}
</h2>
<div class="flex gap-2 flex-wrap mt-2">
<VTag
v-for="role in currentRoles"
:key="role.metadata.name"
>
<template #leftIcon>
<IconShieldUser />
</template>
{{
role.metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || role.metadata.name
}}
</VTag>
</div>
</div>
</template>
</VDropdown>
</div>
</div>
<div class="flex items-center gap-1">
@ -276,6 +320,7 @@ const disallowAccessConsole = computed(() => {
.profile-placeholder {
height: 70px;
flex: none;
.current-profile {
height: 70px;

View File

@ -5,11 +5,12 @@ import type { DetailedUser, ListedAuthProvider } from "@halo-dev/api-client";
import { consoleApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconUserSettings,
IconShieldUser,
VAlert,
VButton,
VDescription,
VDescriptionItem,
VSpace,
VTag,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
@ -133,15 +134,17 @@ const emailVerifyModal = ref(false);
:label="$t('core.uc_profile.detail.fields.roles')"
class="!px-2"
>
<VTag v-for="role in user?.roles" :key="role.metadata.name">
<template #leftIcon>
<IconUserSettings />
</template>
{{
role.metadata.annotations?.[rbacAnnotations.DISPLAY_NAME] ||
role.metadata.name
}}
</VTag>
<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>
</VDescriptionItem>
<VDescriptionItem
:label="$t('core.uc_profile.detail.fields.bio')"