diff --git a/packages/console/src/components/ModalLayout/index.tsx b/packages/console/src/components/ModalLayout/index.tsx index 82ee19dd3..9000f9d66 100644 --- a/packages/console/src/components/ModalLayout/index.tsx +++ b/packages/console/src/components/ModalLayout/index.tsx @@ -1,17 +1,18 @@ import type { AdminConsoleKey } from '@logto/phrases'; import classNames from 'classnames'; -import type { ReactNode } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import Close from '@/assets/images/close.svg'; import Card from '../Card'; import CardTitle from '../CardTitle'; +import type DangerousRaw from '../DangerousRaw'; import IconButton from '../IconButton'; import * as styles from './index.module.scss'; type Props = { - title: AdminConsoleKey; - subtitle?: AdminConsoleKey; + title: AdminConsoleKey | ReactElement; + subtitle?: AdminConsoleKey | ReactElement; children: ReactNode; footer?: ReactNode; onClose?: () => void; diff --git a/packages/console/src/components/UserRolesTransfer/components/SourceRoleItem/index.module.scss b/packages/console/src/components/UserRolesTransfer/components/SourceRoleItem/index.module.scss new file mode 100644 index 000000000..a0c31f278 --- /dev/null +++ b/packages/console/src/components/UserRolesTransfer/components/SourceRoleItem/index.module.scss @@ -0,0 +1,24 @@ +@use '@/scss/underscore' as _; + +.item { + display: flex; + align-items: center; + font: var(--font-body-medium); + padding: _.unit(2.5) _.unit(4); + user-select: none; + cursor: pointer; + + &:hover { + background-color: var(--color-hover); + } + + .name { + @include _.text-ellipsis; + } + + .count { + flex-shrink: 0; + margin-left: _.unit(2); + color: var(--color-text-secondary); + } +} diff --git a/packages/console/src/components/UserRolesTransfer/components/SourceRoleItem/index.tsx b/packages/console/src/components/UserRolesTransfer/components/SourceRoleItem/index.tsx new file mode 100644 index 000000000..d5b79366b --- /dev/null +++ b/packages/console/src/components/UserRolesTransfer/components/SourceRoleItem/index.tsx @@ -0,0 +1,46 @@ +import type { RoleResponse } from '@logto/schemas'; +import { useTranslation } from 'react-i18next'; + +import Checkbox from '@/components/Checkbox'; +import { onKeyDownHandler } from '@/utilities/a11y'; + +import * as styles from './index.module.scss'; + +type Props = { + role: RoleResponse; + isSelected: boolean; + onSelect: () => void; +}; + +const SourceRoleItem = ({ role, isSelected, onSelect }: Props) => { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { name, usersCount } = role; + + return ( +
{ + onSelect(); + })} + onClick={() => { + onSelect(); + }} + > + { + onSelect(); + }} + /> +
{name}
+
+ ({t('user_details.roles.assigned_user_count', { value: usersCount })}) +
+
+ ); +}; + +export default SourceRoleItem; diff --git a/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.module.scss b/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.module.scss new file mode 100644 index 000000000..b6084458b --- /dev/null +++ b/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.module.scss @@ -0,0 +1,7 @@ +.search { + width: 100%; +} + +.icon { + color: var(--color-text-secondary); +} diff --git a/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.tsx b/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.tsx new file mode 100644 index 000000000..49ff2e904 --- /dev/null +++ b/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.tsx @@ -0,0 +1,100 @@ +import type { RoleResponse } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; +import type { ChangeEvent } from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import useSWR from 'swr'; + +import Search from '@/assets/images/search.svg'; +import Pagination from '@/components/Pagination'; +import TextInput from '@/components/TextInput'; +import useDebounce from '@/hooks/use-debounce'; +import * as transferLayout from '@/scss/transfer.module.scss'; +import { buildUrl } from '@/utilities/url'; + +import SourceRoleItem from '../SourceRoleItem'; +import * as styles from './index.module.scss'; + +type Props = { + userId: string; + selectedRoles: RoleResponse[]; + onChange: (value: RoleResponse[]) => void; +}; + +const pageSize = 20; +const searchDelay = 500; + +const SourceRolesBox = ({ userId, selectedRoles, onChange }: Props) => { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + const [pageIndex, setPageIndex] = useState(1); + const [keyword, setKeyword] = useState(''); + + const debounce = useDebounce(); + + const url = buildUrl('/api/roles', { + excludeUserId: userId, + page: String(pageIndex), + page_size: String(pageSize), + ...conditional(keyword && { search: `%${keyword}%` }), + }); + + const { data } = useSWR<[RoleResponse[], number]>(url); + + const [dataSource = [], totalCount] = data ?? []; + + const isRoleSelected = (role: RoleResponse) => + selectedRoles.findIndex(({ id }) => role.id === id) >= 0; + + const handleSearchInput = (event: ChangeEvent) => { + debounce(() => { + setPageIndex(1); + setKeyword(event.target.value); + }, searchDelay); + }; + + return ( +
+
+ } + placeholder={t('general.search_placeholder')} + onChange={handleSearchInput} + /> +
+
+ {dataSource.map((role) => { + const isSelected = isRoleSelected(role); + + return ( + { + onChange( + isSelected + ? selectedRoles.filter(({ id }) => id !== role.id) + : [role, ...selectedRoles] + ); + }} + /> + ); + })} +
+ { + setPageIndex(page); + }} + /> +
+ ); +}; + +export default SourceRolesBox; diff --git a/packages/console/src/components/UserRolesTransfer/components/TargetRoleItem/index.module.scss b/packages/console/src/components/UserRolesTransfer/components/TargetRoleItem/index.module.scss new file mode 100644 index 000000000..6af6cdc26 --- /dev/null +++ b/packages/console/src/components/UserRolesTransfer/components/TargetRoleItem/index.module.scss @@ -0,0 +1,33 @@ +@use '@/scss/underscore' as _; + +.item { + display: flex; + align-items: center; + padding: _.unit(2.5) _.unit(4); + font: var(--font-body-medium); + user-select: none; + + &:hover { + background-color: var(--color-hover); + } + + .info { + flex: 1 1 0; + display: flex; + align-items: center; + + .name { + @include _.text-ellipsis; + } + + .count { + flex-shrink: 0; + margin-left: _.unit(2); + color: var(--color-text-secondary); + } + } + + .icon { + color: var(--color-text-secondary); + } +} diff --git a/packages/console/src/components/UserRolesTransfer/components/TargetRoleItem/index.tsx b/packages/console/src/components/UserRolesTransfer/components/TargetRoleItem/index.tsx new file mode 100644 index 000000000..c3949dad3 --- /dev/null +++ b/packages/console/src/components/UserRolesTransfer/components/TargetRoleItem/index.tsx @@ -0,0 +1,33 @@ +import type { RoleResponse } from '@logto/schemas'; +import { useTranslation } from 'react-i18next'; + +import Close from '@/assets/images/close.svg'; +import IconButton from '@/components/IconButton'; + +import * as styles from './index.module.scss'; + +type Props = { + role: RoleResponse; + onDelete: () => void; +}; + +const TargetRoleItem = ({ role, onDelete }: Props) => { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { name, usersCount } = role; + + return ( +
+
+
{name}
+
+ ({t('user_details.roles.assigned_user_count', { value: usersCount })}) +
+
+ + + +
+ ); +}; + +export default TargetRoleItem; diff --git a/packages/console/src/components/UserRolesTransfer/components/TargetRolesBox/index.module.scss b/packages/console/src/components/UserRolesTransfer/components/TargetRolesBox/index.module.scss new file mode 100644 index 000000000..62c864651 --- /dev/null +++ b/packages/console/src/components/UserRolesTransfer/components/TargetRolesBox/index.module.scss @@ -0,0 +1,7 @@ +.icon { + color: var(--color-text-secondary); +} + +.added { + font: var(--font-label-large); +} diff --git a/packages/console/src/components/UserRolesTransfer/components/TargetRolesBox/index.tsx b/packages/console/src/components/UserRolesTransfer/components/TargetRolesBox/index.tsx new file mode 100644 index 000000000..5e1dd61f0 --- /dev/null +++ b/packages/console/src/components/UserRolesTransfer/components/TargetRolesBox/index.tsx @@ -0,0 +1,40 @@ +import type { RoleResponse } from '@logto/schemas'; +import { useTranslation } from 'react-i18next'; + +import * as transferLayout from '@/scss/transfer.module.scss'; + +import TargetRoleItem from '../TargetRoleItem'; +import * as styles from './index.module.scss'; + +type Props = { + selectedRoles: RoleResponse[]; + onChange: (value: RoleResponse[]) => void; +}; + +const TargetRolesBox = ({ selectedRoles, onChange }: Props) => { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + return ( +
+
+ + {`${selectedRoles.length} `} + {t('general.added')} + +
+
+ {selectedRoles.map((role) => ( + { + onChange(selectedRoles.filter(({ id }) => id !== role.id)); + }} + /> + ))} +
+
+ ); +}; + +export default TargetRolesBox; diff --git a/packages/console/src/components/UserRolesTransfer/index.module.scss b/packages/console/src/components/UserRolesTransfer/index.module.scss new file mode 100644 index 000000000..33692371a --- /dev/null +++ b/packages/console/src/components/UserRolesTransfer/index.module.scss @@ -0,0 +1,3 @@ +.rolesTransfer { + height: 360px; +} diff --git a/packages/console/src/components/UserRolesTransfer/index.tsx b/packages/console/src/components/UserRolesTransfer/index.tsx new file mode 100644 index 000000000..918042570 --- /dev/null +++ b/packages/console/src/components/UserRolesTransfer/index.tsx @@ -0,0 +1,24 @@ +import type { RoleResponse } from '@logto/schemas'; +import classNames from 'classnames'; + +import * as transferLayout from '@/scss/transfer.module.scss'; + +import SourceRolesBox from './components/SourceRolesBox'; +import TargetRolesBox from './components/TargetRolesBox'; +import * as styles from './index.module.scss'; + +type Props = { + userId: string; + value: RoleResponse[]; + onChange: (value: RoleResponse[]) => void; +}; + +const UserRolesTransfer = ({ userId, value, onChange }: Props) => ( +
+ +
+ +
+); + +export default UserRolesTransfer; diff --git a/packages/console/src/hooks/use-debounce.ts b/packages/console/src/hooks/use-debounce.ts new file mode 100644 index 000000000..65e2de363 --- /dev/null +++ b/packages/console/src/hooks/use-debounce.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from 'react'; + +const useDebounce = () => { + const timerRef = useRef(); + + const clearTimer = () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + + useEffect(() => { + return () => { + clearTimer(); + }; + }, []); + + return (callback: () => void, wait: number) => { + clearTimer(); + // eslint-disable-next-line @silverhand/fp/no-mutation + timerRef.current = setTimeout(() => { + callback(); + clearTimer(); + }, wait); + }; +}; + +export default useDebounce; diff --git a/packages/console/src/pages/UserDetails/UserRoles/components/AssignRolesModal/index.tsx b/packages/console/src/pages/UserDetails/UserRoles/components/AssignRolesModal/index.tsx new file mode 100644 index 000000000..686cb7d0e --- /dev/null +++ b/packages/console/src/pages/UserDetails/UserRoles/components/AssignRolesModal/index.tsx @@ -0,0 +1,90 @@ +import type { RoleResponse, User } from '@logto/schemas'; +import { useState } from 'react'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import ReactModal from 'react-modal'; + +import Button from '@/components/Button'; +import DangerousRaw from '@/components/DangerousRaw'; +import ModalLayout from '@/components/ModalLayout'; +import UserRolesTransfer from '@/components/UserRolesTransfer'; +import useApi from '@/hooks/use-api'; +import * as modalStyles from '@/scss/modal.module.scss'; + +type Props = { + user: User; + onClose: (success?: boolean) => void; +}; + +const AssignRolesModal = ({ user, onClose }: Props) => { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + const userName = user.name ?? t('users.unnamed'); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [roles, setRoles] = useState([]); + + const api = useApi(); + + const handleAssign = async () => { + if (isSubmitting || roles.length === 0) { + return; + } + + setIsSubmitting(true); + + try { + await api.post(`/api/users/${user.id}/roles`, { + json: { roleIds: roles.map(({ id }) => id) }, + }); + toast.success(t('user_details.roles.role_assigned')); + onClose(true); + } finally { + setIsSubmitting(false); + } + }; + + return ( + { + onClose(); + }} + > + {t('user_details.roles.assign_title', { name: userName })} + } + subtitle={ + {t('user_details.roles.assign_subtitle', { name: userName })} + } + size="large" + footer={ +
@@ -110,7 +111,7 @@ const UserRoles = () => { title="user_details.roles.assign_button" type="outline" onClick={() => { - // TODO @xiaoyijun assign roles to user + setIsAssignRolesModalOpen(true); }} /> ), @@ -131,6 +132,17 @@ const UserRoles = () => { {t('user_details.roles.delete_description')} )} + {isAssignRolesModalOpen && ( + { + if (success) { + void mutate(); + } + setIsAssignRolesModalOpen(false); + }} + /> + )} ); }; diff --git a/packages/console/src/scss/transfer.module.scss b/packages/console/src/scss/transfer.module.scss new file mode 100644 index 000000000..d2679c765 --- /dev/null +++ b/packages/console/src/scss/transfer.module.scss @@ -0,0 +1,39 @@ +@use '@/scss/underscore' as _; + +.container { + border: 1px solid var(--color-border); + border-radius: 6px; + display: flex; + align-items: stretch; + overflow: hidden; +} + +.box { + flex: 1 1 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.boxTopBar { + height: 52px; + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + padding: 0 _.unit(4); +} + +.boxContent { + flex: 1 1 0; + overflow-y: auto; +} + +.boxPagination { + height: 40px; + padding-right: _.unit(4); + border-top: 1px solid var(--color-border); +} + +.verticalBar { + @include _.vertical-bar; +} diff --git a/packages/phrases/src/locales/de/translation/admin-console/user-details.ts b/packages/phrases/src/locales/de/translation/admin-console/user-details.ts index efd946b54..47d92a041 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/user-details.ts @@ -48,6 +48,14 @@ const user_details = { assign_button: 'Assign Roles', // UNTRANSLATED delete_description: 'TBD', // UNTRANSLATED deleted: 'The role {{name}} has been successfully deleted from the user.', // UNTRANSLATED + assign_title: 'Assign roles to {{name}}', // UNTRANSLATED + assign_subtitle: 'Authorize {{name}} one or more roles', // UNTRANSLATED + assign_role_field: 'Assign roles', // UNTRANSLATED + role_search_placeholder: 'Search by role name', // UNTRANSLATED + added_text: '{{value, number}} added', // UNTRANSLATED + assigned_user_count: '{{value, number}} users', // UNTRANSLATED + confirm_assign: 'Assign roles', // UNTRANSLATED + role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED }, }; diff --git a/packages/phrases/src/locales/en/translation/admin-console/user-details.ts b/packages/phrases/src/locales/en/translation/admin-console/user-details.ts index 3904467b7..df58e7d97 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/user-details.ts @@ -46,6 +46,14 @@ const user_details = { assign_button: 'Assign Roles', delete_description: 'TBD', // UNTRANSLATED deleted: 'The role {{name}} has been successfully deleted from the user.', // UNTRANSLATED + assign_title: 'Assign roles to {{name}}', + assign_subtitle: 'Authorize {{name}} one or more roles', + assign_role_field: 'Assign roles', + role_search_placeholder: 'Search by role name', + added_text: '{{value, number}} added', + assigned_user_count: '{{value, number}} users', + confirm_assign: 'Assign roles', + role_assigned: 'Successfully assigned role(s)', }, }; diff --git a/packages/phrases/src/locales/fr/translation/admin-console/user-details.ts b/packages/phrases/src/locales/fr/translation/admin-console/user-details.ts index 9f048761d..4ab8b9ac4 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/user-details.ts @@ -48,6 +48,14 @@ const user_details = { assign_button: 'Assign Roles', // UNTRANSLATED delete_description: 'TBD', // UNTRANSLATED deleted: 'The role {{name}} has been successfully deleted from the user.', // UNTRANSLATED + assign_title: 'Assign roles to {{name}}', // UNTRANSLATED + assign_subtitle: 'Authorize {{name}} one or more roles', // UNTRANSLATED + assign_role_field: 'Assign roles', // UNTRANSLATED + role_search_placeholder: 'Search by role name', // UNTRANSLATED + added_text: '{{value, number}} added', // UNTRANSLATED + assigned_user_count: '{{value, number}} users', // UNTRANSLATED + confirm_assign: 'Assign roles', // UNTRANSLATED + role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED }, }; diff --git a/packages/phrases/src/locales/ko/translation/admin-console/user-details.ts b/packages/phrases/src/locales/ko/translation/admin-console/user-details.ts index 1c40719ac..b767ec012 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/user-details.ts @@ -45,6 +45,14 @@ const user_details = { assign_button: 'Assign Roles', // UNTRANSLATED delete_description: 'TBD', // UNTRANSLATED deleted: 'The role {{name}} has been successfully deleted from the user.', // UNTRANSLATED + assign_title: 'Assign roles to {{name}}', // UNTRANSLATED + assign_subtitle: 'Authorize {{name}} one or more roles', // UNTRANSLATED + assign_role_field: 'Assign roles', // UNTRANSLATED + role_search_placeholder: 'Search by role name', // UNTRANSLATED + added_text: '{{value, number}} added', // UNTRANSLATED + assigned_user_count: '{{value, number}} users', // UNTRANSLATED + confirm_assign: 'Assign roles', // UNTRANSLATED + role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED }, }; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/user-details.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/user-details.ts index f9b795435..0ac454cc4 100644 --- a/packages/phrases/src/locales/pt-br/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/user-details.ts @@ -46,6 +46,14 @@ const user_details = { assign_button: 'Assign Roles', // UNTRANSLATED delete_description: 'TBD', // UNTRANSLATED deleted: 'The role {{name}} has been successfully deleted from the user.', // UNTRANSLATED + assign_title: 'Assign roles to {{name}}', // UNTRANSLATED + assign_subtitle: 'Authorize {{name}} one or more roles', // UNTRANSLATED + assign_role_field: 'Assign roles', // UNTRANSLATED + role_search_placeholder: 'Search by role name', // UNTRANSLATED + added_text: '{{value, number}} added', // UNTRANSLATED + assigned_user_count: '{{value, number}} users', // UNTRANSLATED + confirm_assign: 'Assign roles', // UNTRANSLATED + role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED }, }; diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/user-details.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/user-details.ts index 1b6e6b02a..da68bc743 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/user-details.ts @@ -48,6 +48,14 @@ const user_details = { assign_button: 'Assign Roles', // UNTRANSLATED delete_description: 'TBD', // UNTRANSLATED deleted: 'The role {{name}} has been successfully deleted from the user.', // UNTRANSLATED + assign_title: 'Assign roles to {{name}}', // UNTRANSLATED + assign_subtitle: 'Authorize {{name}} one or more roles', // UNTRANSLATED + assign_role_field: 'Assign roles', // UNTRANSLATED + role_search_placeholder: 'Search by role name', // UNTRANSLATED + added_text: '{{value, number}} added', // UNTRANSLATED + assigned_user_count: '{{value, number}} users', // UNTRANSLATED + confirm_assign: 'Assign roles', // UNTRANSLATED + role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED }, }; diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/user-details.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/user-details.ts index 683a82470..6994d7cf4 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/user-details.ts @@ -46,6 +46,14 @@ const user_details = { assign_button: 'Assign Roles', // UNTRANSLATED delete_description: 'TBD', // UNTRANSLATED deleted: 'The role {{name}} has been successfully deleted from the user.', // UNTRANSLATED + assign_title: 'Assign roles to {{name}}', // UNTRANSLATED + assign_subtitle: 'Authorize {{name}} one or more roles', // UNTRANSLATED + assign_role_field: 'Assign roles', // UNTRANSLATED + role_search_placeholder: 'Search by role name', // UNTRANSLATED + added_text: '{{value, number}} added', // UNTRANSLATED + assigned_user_count: '{{value, number}} users', // UNTRANSLATED + confirm_assign: 'Assign roles', // UNTRANSLATED + role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED }, }; diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/user-details.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/user-details.ts index 47195c9f0..7d853b86b 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/user-details.ts @@ -44,6 +44,14 @@ const user_details = { assign_button: 'Assign Roles', // UNTRANSLATED delete_description: 'TBD', // UNTRANSLATED deleted: 'The role {{name}} has been successfully deleted from the user.', // UNTRANSLATED + assign_title: 'Assign roles to {{name}}', // UNTRANSLATED + assign_subtitle: 'Authorize {{name}} one or more roles', // UNTRANSLATED + assign_role_field: 'Assign roles', // UNTRANSLATED + role_search_placeholder: 'Search by role name', // UNTRANSLATED + added_text: '{{value, number}} added', // UNTRANSLATED + assigned_user_count: '{{value, number}} users', // UNTRANSLATED + confirm_assign: 'Assign roles', // UNTRANSLATED + role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED }, };