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:
parent
c0ac2c5354
commit
d2bfe18688
6 changed files with 177 additions and 111 deletions
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue