0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(console): assign roles to user (#2896)

This commit is contained in:
Xiao Yijun 2023-01-11 15:42:16 +08:00 committed by GitHub
parent d3b9d46b6b
commit 64d2fa5a63
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 559 additions and 8 deletions

View file

@ -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<typeof DangerousRaw>;
subtitle?: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
children: ReactNode;
footer?: ReactNode;
onClose?: () => void;

View file

@ -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);
}
}

View file

@ -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 (
<div
className={styles.item}
role="button"
tabIndex={0}
onKeyDown={onKeyDownHandler(() => {
onSelect();
})}
onClick={() => {
onSelect();
}}
>
<Checkbox
checked={isSelected}
disabled={false}
onChange={() => {
onSelect();
}}
/>
<div className={styles.name}>{name}</div>
<div className={styles.count}>
({t('user_details.roles.assigned_user_count', { value: usersCount })})
</div>
</div>
);
};
export default SourceRoleItem;

View file

@ -0,0 +1,7 @@
.search {
width: 100%;
}
.icon {
color: var(--color-text-secondary);
}

View file

@ -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<HTMLInputElement>) => {
debounce(() => {
setPageIndex(1);
setKeyword(event.target.value);
}, searchDelay);
};
return (
<div className={transferLayout.box}>
<div className={transferLayout.boxTopBar}>
<TextInput
className={styles.search}
icon={<Search className={styles.icon} />}
placeholder={t('general.search_placeholder')}
onChange={handleSearchInput}
/>
</div>
<div className={transferLayout.boxContent}>
{dataSource.map((role) => {
const isSelected = isRoleSelected(role);
return (
<SourceRoleItem
key={role.id}
role={role}
isSelected={isSelected}
onSelect={() => {
onChange(
isSelected
? selectedRoles.filter(({ id }) => id !== role.id)
: [role, ...selectedRoles]
);
}}
/>
);
})}
</div>
<Pagination
mode="pico"
pageIndex={pageIndex}
totalCount={totalCount}
pageSize={pageSize}
className={transferLayout.boxPagination}
onChange={(page) => {
setPageIndex(page);
}}
/>
</div>
);
};
export default SourceRolesBox;

View file

@ -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);
}
}

View file

@ -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 (
<div className={styles.item}>
<div className={styles.info}>
<div className={styles.name}>{name}</div>
<div className={styles.count}>
({t('user_details.roles.assigned_user_count', { value: usersCount })})
</div>
</div>
<IconButton size="small" iconClassName={styles.icon} onClick={onDelete}>
<Close />
</IconButton>
</div>
);
};
export default TargetRoleItem;

View file

@ -0,0 +1,7 @@
.icon {
color: var(--color-text-secondary);
}
.added {
font: var(--font-label-large);
}

View file

@ -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 (
<div className={transferLayout.box}>
<div className={transferLayout.boxTopBar}>
<span className={styles.added}>
{`${selectedRoles.length} `}
{t('general.added')}
</span>
</div>
<div className={transferLayout.boxContent}>
{selectedRoles.map((role) => (
<TargetRoleItem
key={role.id}
role={role}
onDelete={() => {
onChange(selectedRoles.filter(({ id }) => id !== role.id));
}}
/>
))}
</div>
</div>
);
};
export default TargetRolesBox;

View file

@ -0,0 +1,3 @@
.rolesTransfer {
height: 360px;
}

View file

@ -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) => (
<div className={classNames(transferLayout.container, styles.rolesTransfer)}>
<SourceRolesBox userId={userId} selectedRoles={value} onChange={onChange} />
<div className={transferLayout.verticalBar} />
<TargetRolesBox selectedRoles={value} onChange={onChange} />
</div>
);
export default UserRolesTransfer;

View file

@ -0,0 +1,28 @@
import { useEffect, useRef } from 'react';
const useDebounce = () => {
const timerRef = useRef<NodeJS.Timeout>();
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;

View file

@ -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<RoleResponse[]>([]);
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 (
<ReactModal
isOpen
shouldCloseOnEsc
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
onClose();
}}
>
<ModalLayout
title={
<DangerousRaw>{t('user_details.roles.assign_title', { name: userName })}</DangerousRaw>
}
subtitle={
<DangerousRaw>{t('user_details.roles.assign_subtitle', { name: userName })}</DangerousRaw>
}
size="large"
footer={
<Button
isLoading={isSubmitting}
disabled={roles.length === 0}
htmlType="submit"
title="user_details.roles.confirm_assign"
size="large"
type="primary"
onClick={handleAssign}
/>
}
onClose={onClose}
>
<UserRolesTransfer
userId={user.id}
value={roles}
onChange={(value) => {
setRoles(value);
}}
/>
</ModalLayout>
</ReactModal>
);
};
export default AssignRolesModal;

View file

@ -17,12 +17,12 @@ import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import type { UserDetailsOutletContext } from '../types';
import AssignRolesModal from './components/AssignRolesModal';
import * as styles from './index.module.scss';
const UserRoles = () => {
const {
user: { id: userId },
} = useOutletContext<UserDetailsOutletContext>();
const { user } = useOutletContext<UserDetailsOutletContext>();
const { id: userId } = user;
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -30,6 +30,7 @@ const UserRoles = () => {
const isLoading = !roles && !error;
const [isAssignRolesModalOpen, setIsAssignRolesModalOpen] = useState(false);
const [roleToBeDeleted, setRoleToBeDeleted] = useState<Role>();
const [isDeleting, setIsDeleting] = useState(false);
@ -99,7 +100,7 @@ const UserRoles = () => {
size="large"
icon={<Plus />}
onClick={() => {
// TODO @xiaoyijun assign roles to user
setIsAssignRolesModalOpen(true);
}}
/>
</div>
@ -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')}
</ConfirmModal>
)}
{isAssignRolesModalOpen && (
<AssignRolesModal
user={user}
onClose={(success) => {
if (success) {
void mutate();
}
setIsAssignRolesModalOpen(false);
}}
/>
)}
</>
);
};

View file

@ -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;
}

View file

@ -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
},
};

View file

@ -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)',
},
};

View file

@ -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
},
};

View file

@ -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
},
};

View file

@ -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
},
};

View file

@ -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
},
};

View file

@ -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
},
};

View file

@ -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
},
};