0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

refactor(console): organization permissions and roles are split into two isolated steps (#4901)

This commit is contained in:
Charles Zhao 2023-11-17 11:27:28 +08:00 committed by GitHub
parent c0ac2c5354
commit d2bfe18688
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 177 additions and 111 deletions

View file

@ -11,7 +11,7 @@ import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import useTheme from '@/hooks/use-theme';
import { steps } from '../const';
import { steps, totalStepCount } from '../const';
import * as parentStyles from '../index.module.scss';
import FlexBox from './components/FlexBox';
@ -114,12 +114,12 @@ function Introduction({ isReadonly }: Props) {
</div>
</OverlayScrollbar>
{!isReadonly && (
<ActionBar step={1} totalSteps={3}>
<ActionBar step={1} totalSteps={totalStepCount}>
<Button
title="general.next"
type="primary"
onClick={() => {
navigate(`../${steps.permissionsAndRoles}`);
navigate(`../${steps.permissions}`);
}}
/>
</ActionBar>

View file

@ -18,7 +18,7 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
import useTheme from '@/hooks/use-theme';
import { trySubmitSafe } from '@/utils/form';
import { steps } from '../const';
import { steps, totalStepCount } from '../const';
import * as styles from '../index.module.scss';
type OrganizationForm = {
@ -52,7 +52,7 @@ function OrganizationInfo() {
const onNavigateBack = () => {
reset();
navigate(`../${steps.permissionsAndRoles}`);
navigate(`../${steps.roles}`);
};
return (
@ -108,7 +108,7 @@ function OrganizationInfo() {
</Card>
</div>
</OverlayScrollbar>
<ActionBar step={3} totalSteps={3}>
<ActionBar step={4} totalSteps={totalStepCount}>
<Button isLoading={isSubmitting} title="general.done" type="primary" onClick={onSubmit} />
<Button title="general.back" onClick={onNavigateBack} />
</ActionBar>

View file

@ -0,0 +1,143 @@
import { Theme, type OrganizationScope } from '@logto/schemas';
import classNames from 'classnames';
import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import PermissionFeatureDark from '@/assets/icons/permission-feature-dark.svg';
import PermissionFeature from '@/assets/icons/permission-feature.svg';
import ActionBar from '@/components/ActionBar';
import Button from '@/ds-components/Button';
import Card from '@/ds-components/Card';
import FormField from '@/ds-components/FormField';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
import TextInput from '@/ds-components/TextInput';
import useApi, { type RequestError } from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import useTheme from '@/hooks/use-theme';
import { trySubmitSafe } from '@/utils/form';
import { organizationScopesPath } from '../../PermissionModal';
import DynamicFormFields from '../DynamicFormFields';
import { steps, totalStepCount } from '../const';
import * as styles from '../index.module.scss';
type Form = {
/* Organization permissions, a.k.a organization scopes */
permissions: Array<Omit<OrganizationScope, 'id' | 'tenantId'>>;
};
const defaultValue = { name: '', description: '' };
function OrganizationPermissions() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.organizations' });
const theme = useTheme();
const PermissionIcon = theme === Theme.Light ? PermissionFeature : PermissionFeatureDark;
const { navigate } = useTenantPathname();
const api = useApi();
const { data, error } = useSWR<OrganizationScope[], RequestError>('api/organization-scopes');
const {
control,
register,
handleSubmit,
reset,
formState: { errors, isSubmitting, isDirty },
} = useForm<Form>({
defaultValues: {
permissions: [defaultValue],
},
});
useEffect(() => {
if (data?.length) {
reset({
permissions: data.map(({ name, description }) => ({ name, description })),
});
}
}, [data, reset]);
const permissionFields = useFieldArray({ control, name: 'permissions' });
const onSubmit = handleSubmit(
trySubmitSafe(async ({ permissions }) => {
// If the form is pristine then skip the submit and go directly to the next step
if (!isDirty) {
navigate(`../${steps.roles}`);
return;
}
// If there's pre-saved permissions, remove them first
if (data?.length) {
await Promise.all(
data.map(async ({ id }) => api.delete(`${organizationScopesPath}/${id}`))
);
}
// Create new permissions
if (permissions.length > 0) {
await Promise.all(
permissions
.filter(({ name }) => name)
.map(async ({ name, description }) => {
await api.post(organizationScopesPath, { json: { name, description } });
})
);
}
navigate(`../${steps.roles}`);
})
);
const onNavigateBack = () => {
reset();
navigate(`../${steps.introduction}`);
};
return (
<>
<OverlayScrollbar className={styles.stepContainer}>
<div className={classNames(styles.content)}>
<Card className={styles.card}>
<PermissionIcon className={styles.icon} />
<div className={styles.title}>{t('guide.step_1')}</div>
<form>
<DynamicFormFields
isLoading={!data && !error}
title="organizations.guide.organization_permissions"
fields={permissionFields.fields}
render={(index) => (
<div className={styles.fieldGroup}>
<FormField title="organizations.guide.permission_name">
<TextInput
{...register(`permissions.${index}.name`)}
error={Boolean(errors.permissions?.[index]?.name)}
placeholder="read:appointment"
/>
</FormField>
<FormField title="general.description">
<TextInput
{...register(`permissions.${index}.description`)}
placeholder={t('create_permission_placeholder')}
/>
</FormField>
</div>
)}
onAdd={() => {
permissionFields.append(defaultValue);
}}
onRemove={permissionFields.remove}
/>
</form>
</Card>
</div>
</OverlayScrollbar>
<ActionBar step={2} totalSteps={totalStepCount}>
<Button isLoading={isSubmitting} title="general.next" type="primary" onClick={onSubmit} />
<Button title="general.back" onClick={onNavigateBack} />
</ActionBar>
</>
);
}
export default OrganizationPermissions;

View file

@ -1,17 +1,10 @@
import {
type OrganizationRoleWithScopes,
Theme,
type OrganizationRole,
type OrganizationScope,
} from '@logto/schemas';
import { type OrganizationRoleWithScopes, Theme, type OrganizationRole } from '@logto/schemas';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import PermissionFeatureDark from '@/assets/icons/permission-feature-dark.svg';
import PermissionFeature from '@/assets/icons/permission-feature.svg';
import RbacFeatureDark from '@/assets/icons/rbac-feature-dark.svg';
import RbacFeature from '@/assets/icons/rbac-feature.svg';
import ActionBar from '@/components/ActionBar';
@ -27,37 +20,24 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
import useTheme from '@/hooks/use-theme';
import { trySubmitSafe } from '@/utils/form';
import { organizationScopesPath } from '../../PermissionModal';
import { organizationRolePath } from '../../RoleModal';
import DynamicFormFields from '../DynamicFormFields';
import { steps } from '../const';
import { steps, totalStepCount } from '../const';
import * as styles from '../index.module.scss';
type Form = {
/* Organization permissions, a.k.a organization scopes */
permissions: Array<Omit<OrganizationScope, 'id' | 'tenantId'>>;
roles: Array<Omit<OrganizationRole, 'tenantId' | 'id'> & { scopes: Array<Option<string>> }>;
};
const icons = {
[Theme.Light]: { PermissionIcon: PermissionFeature, RbacIcon: RbacFeature },
[Theme.Dark]: { PermissionIcon: PermissionFeatureDark, RbacIcon: RbacFeatureDark },
};
const defaultValue = { name: '', description: '', scopes: [] };
const defaultPermission = { name: '', description: '' };
const defaultRoles = { name: '', description: '', scopes: [] };
function PermissionsAndRoles() {
function OrganizationRoles() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.organizations' });
const theme = useTheme();
const { PermissionIcon, RbacIcon } = icons[theme];
const RbacIcon = theme === Theme.Light ? RbacFeature : RbacFeatureDark;
const { navigate } = useTenantPathname();
const api = useApi();
const { data: permissionsData, error: permissionsError } = useSWR<
OrganizationScope[],
RequestError
>('api/organization-scopes');
const { data: rolesData, error: rolesError } = useSWR<OrganizationRoleWithScopes[], RequestError>(
const { data, error } = useSWR<OrganizationRoleWithScopes[], RequestError>(
'api/organization-roles'
);
const [keyword, setKeyword] = useState('');
@ -70,65 +50,35 @@ function PermissionsAndRoles() {
formState: { errors, isSubmitting, isDirty },
} = useForm<Form>({
defaultValues: {
permissions: [defaultPermission],
roles: [defaultRoles],
roles: [defaultValue],
},
});
useEffect(() => {
if (permissionsData?.length) {
if (data?.length) {
reset({
permissions: permissionsData.map(({ name, description }) => ({ name, description })),
});
}
}, [permissionsData, reset]);
useEffect(() => {
if (rolesData?.length) {
reset({
roles: rolesData.map(({ name, description, scopes }) => ({
roles: data.map(({ name, description, scopes }) => ({
name,
description,
scopes: scopes.map(({ id, name }) => ({ value: id, title: name })),
})),
});
}
}, [rolesData, reset]);
}, [data, reset]);
const permissionFields = useFieldArray({ control, name: 'permissions' });
const roleFields = useFieldArray({ control, name: 'roles' });
const onSubmit = handleSubmit(
trySubmitSafe(async ({ permissions, roles }) => {
// If user has pre-saved data but with no changes made this time,
// skip form submit and go directly to the next step.
if ((Boolean(permissionsData?.length) || Boolean(rolesData?.length)) && !isDirty) {
trySubmitSafe(async ({ roles }) => {
// If the form is pristine then skip the submit and go directly to the next step
if (!isDirty) {
navigate(`../${steps.organizationInfo}`);
return;
}
// If there's pre-saved permissions, remove them first
if (permissionsData?.length) {
await Promise.all(
permissionsData.map(async ({ id }) => api.delete(`${organizationScopesPath}/${id}`))
);
}
// Create new permissions
if (permissions.length > 0) {
await Promise.all(
permissions
.filter(({ name }) => name)
.map(async ({ name, description }) => {
await api.post(organizationScopesPath, { json: { name, description } });
})
);
}
// Remove pre-saved roles
if (rolesData?.length) {
await Promise.all(
rolesData.map(async ({ id }) => api.delete(`${organizationRolePath}/${id}`))
);
if (data?.length) {
await Promise.all(data.map(async ({ id }) => api.delete(`${organizationRolePath}/${id}`)));
}
// Create new roles
if (roles.length > 0) {
@ -156,45 +106,13 @@ function PermissionsAndRoles() {
const onNavigateBack = () => {
reset();
setKeyword('');
navigate(`../${steps.introduction}`);
navigate(`../${steps.permissions}`);
};
return (
<>
<OverlayScrollbar className={styles.stepContainer}>
<div className={classNames(styles.content)}>
<Card className={styles.card}>
<PermissionIcon className={styles.icon} />
<div className={styles.title}>{t('guide.step_1')}</div>
<form>
<DynamicFormFields
isLoading={!permissionsData && !permissionsError}
title="organizations.guide.organization_permissions"
fields={permissionFields.fields}
render={(index) => (
<div className={styles.fieldGroup}>
<FormField title="organizations.guide.permission_name">
<TextInput
{...register(`permissions.${index}.name`)}
error={Boolean(errors.permissions?.[index]?.name)}
placeholder="read:appointment"
/>
</FormField>
<FormField title="general.description">
<TextInput
{...register(`permissions.${index}.description`)}
placeholder={t('create_permission_placeholder')}
/>
</FormField>
</div>
)}
onAdd={() => {
permissionFields.append(defaultPermission);
}}
onRemove={permissionFields.remove}
/>
</form>
</Card>
<Card className={styles.card}>
<RbacIcon className={styles.icon} />
<div className={styles.section}>
@ -203,7 +121,7 @@ function PermissionsAndRoles() {
</div>
<form>
<DynamicFormFields
isLoading={!rolesData && !rolesError}
isLoading={!data && !error}
title="organizations.guide.organization_roles"
fields={roleFields.fields}
render={(index) => (
@ -238,7 +156,7 @@ function PermissionsAndRoles() {
</div>
)}
onAdd={() => {
roleFields.append(defaultRoles);
roleFields.append(defaultValue);
}}
onRemove={roleFields.remove}
/>
@ -246,7 +164,7 @@ function PermissionsAndRoles() {
</Card>
</div>
</OverlayScrollbar>
<ActionBar step={2} totalSteps={3}>
<ActionBar step={3} totalSteps={totalStepCount}>
<Button isLoading={isSubmitting} title="general.next" type="primary" onClick={onSubmit} />
<Button title="general.back" onClick={onNavigateBack} />
</ActionBar>
@ -254,4 +172,4 @@ function PermissionsAndRoles() {
);
}
export default PermissionsAndRoles;
export default OrganizationRoles;

View file

@ -1,5 +1,8 @@
export const steps = Object.freeze({
introduction: 'introduction',
permissionsAndRoles: 'permissions-and-roles',
permissions: 'permissions',
roles: 'roles',
organizationInfo: 'organization-info',
});
export const totalStepCount = Object.keys(steps).length;

View file

@ -8,7 +8,8 @@ import * as modalStyles from '@/scss/modal.module.scss';
import Introduction from './Introduction';
import OrganizationInfo from './OrganizationInfo';
import PermissionsAndRoles from './PermissionsAndRoles';
import OrganizationPermissions from './OrganizationPermissions';
import OrganizationRoles from './OrganizationRoles';
import { steps } from './const';
import * as styles from './index.module.scss';
@ -30,7 +31,8 @@ function Guide() {
<Routes>
<Route index element={<Navigate replace to={steps.introduction} />} />
<Route path={steps.introduction} element={<Introduction />} />
<Route path={steps.permissionsAndRoles} element={<PermissionsAndRoles />} />
<Route path={steps.permissions} element={<OrganizationPermissions />} />
<Route path={steps.roles} element={<OrganizationRoles />} />
<Route path={steps.organizationInfo} element={<OrganizationInfo />} />
</Routes>
</div>