0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): add organization creation guide (#4780)

This commit is contained in:
Charles Zhao 2023-10-30 12:10:12 +08:00 committed by GitHub
parent 7029255b91
commit 8156d95ac9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 660 additions and 122 deletions

View file

@ -155,13 +155,15 @@ function ConsoleContent() {
</Route>
</Route>
{isDevFeaturesEnabled && (
<Route path="organizations">
<Route index element={<Organizations />} />
<Route path="create" element={<Organizations />} />
<Route path="settings" element={<Organizations tab="settings" />} />
<Route path=":id/*" element={<OrganizationDetails />} />
<Route path="guide" element={<OrganizationGuide />} />
</Route>
<>
<Route path="organizations">
<Route index element={<Organizations />} />
<Route path="create" element={<Organizations />} />
<Route path="settings" element={<Organizations tab="settings" />} />
<Route path=":id/*" element={<OrganizationDetails />} />
</Route>
<Route path="organization-guide/*" element={<OrganizationGuide />} />
</>
)}
<Route path="profile">
<Route index element={<Profile />} />

View file

@ -0,0 +1,112 @@
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import OrganizationFeatureDark from '@/assets/icons/organization-feature-dark.svg';
import OrganizationFeature from '@/assets/icons/organization-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 TextLink from '@/ds-components/TextLink';
import useApi from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import useTheme from '@/hooks/use-theme';
import { trySubmitSafe } from '@/utils/form';
import { steps } from '../const';
import styles from '../index.module.scss';
type OrganizationForm = {
name: string;
};
function CreateOrganization() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.organizations.guide' });
const theme = useTheme();
const Icon = theme === Theme.Light ? OrganizationFeature : OrganizationFeatureDark;
const { navigate } = useTenantPathname();
const api = useApi();
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<OrganizationForm>({
defaultValues: { name: '' },
});
const onSubmit = handleSubmit(
trySubmitSafe(async (json) => {
await api.post(`api/organizations`, { json });
navigate(`/organizations`);
})
);
const onNavigateBack = () => {
reset();
navigate(`../${steps.createRoles}`);
};
return (
<>
<OverlayScrollbar className={styles.stepContainer}>
<div className={classNames(styles.content)}>
<Card className={styles.card}>
<Icon className={styles.icon} />
<div className={styles.section}>
<div className={styles.title}>{t('step_3')}</div>
<div className={styles.description}>{t('step_3_description')}</div>
</div>
<form>
<FormField isRequired title="organizations.guide.organization_name">
<TextInput {...register('name', { required: true })} error={Boolean(errors.name)} />
</FormField>
</form>
</Card>
<Card className={styles.card}>
<div className={styles.section}>
<div className={styles.title}>{t('more_next_steps')}</div>
<div className={styles.subtitle}>{t('add_members')}</div>
{/* eslint-disable-next-line no-warning-comments */}
{/* TODO: @charles Documentation links will be updated later */}
<ul>
<li>
<TextLink
target="blank"
rel="noopener"
href="https://docs.logto.io/docs/tutorials/"
>
{t('add_members_action')}
</TextLink>
</li>
</ul>
<div className={styles.subtitle}>{t('add_enterprise_connector')}</div>
<ul>
<li>
<TextLink
target="blank"
rel="noopener"
href="https://docs.logto.io/docs/tutorials/"
>
{t('add_enterprise_connector_action')}
</TextLink>
</li>
</ul>
</div>
</Card>
</div>
</OverlayScrollbar>
<ActionBar step={3} totalSteps={3}>
<Button isLoading={isSubmitting} title="general.done" type="primary" onClick={onSubmit} />
<Button title="general.back" onClick={onNavigateBack} />
</ActionBar>
</>
);
}
export default CreateOrganization;

View file

@ -0,0 +1,142 @@
import { type OrganizationScope, Theme } 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 OrganizationFeatureDark from '@/assets/icons/organization-feature-dark.svg';
import OrganizationFeature from '@/assets/icons/organization-feature.svg';
import PermissionFeatureDark from '@/assets/icons/permission-feature-dark.svg';
import PermissionFeature from '@/assets/icons/permission-feature.svg';
import workflowImage from '@/assets/images/organization-workflow.webp';
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 } from '../const';
import * as styles from '../index.module.scss';
const icons = {
[Theme.Light]: { OrganizationIcon: OrganizationFeature, PermissionIcon: PermissionFeature },
[Theme.Dark]: {
OrganizationIcon: OrganizationFeatureDark,
PermissionIcon: PermissionFeatureDark,
},
};
type PermissionForm = {
permissions: Array<Omit<OrganizationScope, 'id' | 'tenantId'>>;
};
const defaultPermission = { name: '', description: '' };
function CreatePermissions() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.organizations.guide' });
const theme = useTheme();
const { OrganizationIcon, PermissionIcon } = icons[theme];
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<PermissionForm>({
defaultValues: {
permissions: [defaultPermission],
},
});
useEffect(() => {
if (data?.length) {
reset({ permissions: data.map(({ name, description }) => ({ name, description })) });
}
}, [data, reset]);
const { fields, append, remove } = useFieldArray({ control, name: 'permissions' });
const onSubmit = () => {
if (!isDirty) {
navigate(`../${steps.createRoles}`);
return;
}
void handleSubmit(
trySubmitSafe(async ({ permissions }) => {
if (data?.length) {
await Promise.all(
data.map(async ({ id }) => api.delete(`${organizationScopesPath}/${id}`))
);
}
await Promise.all(
permissions.map(async ({ name, description }) => {
await api.post(organizationScopesPath, { json: { name, description } });
})
);
navigate(`../${steps.createRoles}`);
})
)();
};
return (
<>
<OverlayScrollbar className={styles.stepContainer}>
<div className={classNames(styles.content)}>
<Card className={styles.card}>
<OrganizationIcon className={styles.icon} />
<div className={styles.title}>{t('brief_title')}</div>
<img className={styles.image} src={workflowImage} alt="Organization workflow" />
<div className={styles.description}>{t('brief_introduction')}</div>
</Card>
<Card className={styles.card}>
<PermissionIcon className={styles.icon} />
<div className={styles.title}>{t('step_1')}</div>
<form>
<DynamicFormFields
isLoading={!data && !error}
title="organizations.guide.organization_permissions"
fields={fields}
render={(index) => (
<div className={styles.fieldGroup}>
<FormField isRequired title="organizations.guide.permission_name">
<TextInput
{...register(`permissions.${index}.name`, { required: true })}
error={Boolean(errors.permissions?.[index]?.name)}
/>
</FormField>
<FormField title="general.description">
<TextInput {...register(`permissions.${index}.description`)} />
</FormField>
</div>
)}
onAdd={() => {
append(defaultPermission);
}}
onRemove={remove}
/>
</form>
</Card>
</div>
</OverlayScrollbar>
<ActionBar step={1} totalSteps={3}>
<Button isLoading={isSubmitting} title="general.next" type="primary" onClick={onSubmit} />
</ActionBar>
</>
);
}
export default CreatePermissions;

View file

@ -0,0 +1,168 @@
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 RbacFeatureDark from '@/assets/icons/rbac-feature-dark.svg';
import RbacFeature from '@/assets/icons/rbac-feature.svg';
import ActionBar from '@/components/ActionBar';
import OrganizationScopesSelect from '@/components/OrganizationScopesSelect';
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 { type Option } from '@/ds-components/Select/MultiSelect';
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 { organizationRolePath } from '../../RoleModal';
import DynamicFormFields from '../DynamicFormFields';
import { steps } from '../const';
import styles from '../index.module.scss';
type RoleForm = {
roles: Array<Omit<OrganizationRole, 'tenantId' | 'id'> & { scopes: Array<Option<string>> }>;
};
const defaultRoles = { name: '', description: '', scopes: [] };
function CreateRoles() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.organizations.guide' });
const theme = useTheme();
const Icon = theme === Theme.Light ? RbacFeature : RbacFeatureDark;
const { navigate } = useTenantPathname();
const api = useApi();
const { data, error } = useSWR<OrganizationRoleWithScopes[], RequestError>(
'api/organization-roles'
);
const [keyword, setKeyword] = useState('');
const {
control,
register,
handleSubmit,
reset,
formState: { errors, isSubmitting, isDirty },
} = useForm<RoleForm>({
defaultValues: {
roles: [defaultRoles],
},
});
useEffect(() => {
if (data?.length) {
reset({
roles: data.map(({ name, description, scopes }) => ({
name,
description,
scopes: scopes.map(({ id, name }) => ({ value: id, title: name })),
})),
});
}
}, [data, reset]);
const { fields, append, remove } = useFieldArray({ control, name: 'roles' });
const onSubmit = () => {
if (!isDirty) {
navigate(`../${steps.createOrganization}`);
return;
}
void handleSubmit(
trySubmitSafe(async ({ roles }) => {
if (data?.length) {
await Promise.all(
data.map(async ({ id }) => api.delete(`${organizationRolePath}/${id}`))
);
}
await Promise.all(
roles.map(async ({ name, description, scopes }) => {
const { id } = await api
.post(organizationRolePath, { json: { name, description } })
.json<OrganizationRole>();
if (scopes.length > 0) {
await api.put(`${organizationRolePath}/${id}/scopes`, {
json: { organizationScopeIds: scopes.map(({ value }) => value) },
});
}
})
);
navigate(`../${steps.createOrganization}`);
})
)();
};
const onNavigateBack = () => {
reset();
setKeyword('');
navigate(`../${steps.createPermissions}`);
};
return (
<>
<OverlayScrollbar className={styles.stepContainer}>
<div className={classNames(styles.content)}>
<Card className={styles.card}>
<Icon className={styles.icon} />
<div className={styles.section}>
<div className={styles.title}>{t('step_2')}</div>
<div className={styles.description}>{t('step_2_description')}</div>
</div>
<form>
<DynamicFormFields
isLoading={!data && !error}
title="organizations.guide.organization_roles"
fields={fields}
render={(index) => (
<div className={styles.fieldGroup}>
<FormField isRequired title="organizations.guide.role_name">
<TextInput
{...register(`roles.${index}.name`, { required: true })}
error={Boolean(errors.roles?.[index]?.name)}
/>
</FormField>
<FormField title="general.description">
<TextInput {...register(`roles.${index}.description`)} />
</FormField>
<FormField title="organizations.guide.permissions">
<Controller
name={`roles.${index}.scopes`}
control={control}
render={({ field: { onChange, value } }) => (
<OrganizationScopesSelect
keyword={keyword}
setKeyword={setKeyword}
value={value}
onChange={onChange}
/>
)}
/>
</FormField>
</div>
)}
onAdd={() => {
append(defaultRoles);
}}
onRemove={remove}
/>
</form>
</Card>
</div>
</OverlayScrollbar>
<ActionBar step={2} totalSteps={3}>
<Button isLoading={isSubmitting} title="general.next" type="primary" onClick={onSubmit} />
<Button title="general.back" onClick={onNavigateBack} />
</ActionBar>
</>
);
}
export default CreateRoles;

View file

@ -0,0 +1,55 @@
@use '@/scss/underscore' as _;
.formContainer {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
gap: _.unit(3);
}
.title {
font: var(--font-label-2);
}
.item {
width: 100%;
display: flex;
align-items: center;
gap: _.unit(2);
.fieldWrapper {
flex: 1;
background: var(--color-layer-light);
padding: _.unit(4);
border-radius: 12px;
}
}
.skeleton {
width: 100%;
padding: _.unit(1) 0;
.group {
display: flex;
flex-direction: column;
gap: _.unit(3);
width: 100%;
+ .group {
margin-top: _.unit(6);
}
}
.title {
width: 200px;
height: 20px;
@include _.shimmering-animation;
}
.field {
width: 100%;
height: 36px;
@include _.shimmering-animation;
}
}

View file

@ -0,0 +1,83 @@
import { type AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import { type ReactNode } from 'react';
import CirclePlus from '@/assets/icons/circle-plus.svg';
import Minus from '@/assets/icons/minus.svg';
import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT';
import IconButton from '@/ds-components/IconButton';
import * as styles from './index.module.scss';
type Props = {
className?: string;
title?: AdminConsoleKey;
fields: Array<Record<'id', string>>;
isLoading?: boolean;
onAdd: () => void;
onRemove: (index: number) => void;
render: (index: number) => ReactNode;
};
function Skeleton() {
return (
<div className={styles.skeleton}>
{Array.from({ length: 2 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index} className={styles.group}>
<div className={styles.title} />
<div className={styles.field} />
</div>
))}
</div>
);
}
function DynamicFormFields({
className,
title,
fields,
isLoading,
onAdd,
onRemove,
render,
}: Props) {
return (
<div className={classNames(styles.formContainer, className)}>
{title && (
<div className={styles.title}>
<DynamicT forKey={title} />
</div>
)}
{fields.map((field, index) => (
<div key={field.id} className={styles.item}>
{isLoading ? <Skeleton /> : <div className={styles.fieldWrapper}>{render(index)}</div>}
{fields.length > 1 && (
<IconButton
size="small"
onClick={() => {
onRemove(index);
}}
>
<Minus />
</IconButton>
)}
</div>
))}
{!isLoading && (
<Button
size="small"
type="text"
title="general.add_another"
icon={<CirclePlus />}
onClick={() => {
onAdd();
}}
/>
)}
</div>
);
}
export default DynamicFormFields;

View file

@ -1,36 +0,0 @@
@use '@/scss/underscore' as _;
@use '@/scss/dimensions' as dim;
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: _.unit(6);
.card {
display: flex;
flex-direction: column;
width: 100%;
max-width: dim.$guide-main-content-max-width;
padding: _.unit(12);
gap: _.unit(6);
.image {
width: 100%;
}
.icon {
width: 48px;
height: 48px;
flex-shrink: 0;
}
.title {
font: var(--font-title-1);
}
.subtitle {
font: var(--font-body-2);
}
}
}

View file

@ -1,44 +0,0 @@
import { Theme } from '@logto/schemas';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import OrganizationFeatureDark from '@/assets/icons/organization-feature-dark.svg';
import OrganizationFeature from '@/assets/icons/organization-feature.svg';
import PermissionFeatureDark from '@/assets/icons/permission-feature-dark.svg';
import PermissionFeature from '@/assets/icons/permission-feature.svg';
import workflowImage from '@/assets/images/organization-workflow.webp';
import Card from '@/ds-components/Card';
import useTheme from '@/hooks/use-theme';
import * as styles from './index.module.scss';
const icons = {
[Theme.Light]: { OrganizationIcon: OrganizationFeature, PermissionIcon: PermissionFeature },
[Theme.Dark]: {
OrganizationIcon: OrganizationFeatureDark,
PermissionIcon: PermissionFeatureDark,
},
};
function Step1() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.organizations.guide' });
const theme = useTheme();
const { OrganizationIcon, PermissionIcon } = icons[theme];
return (
<div className={classNames(styles.container)}>
<Card className={styles.card}>
<OrganizationIcon className={styles.icon} />
<div className={styles.title}>{t('brief_title')}</div>
<img className={styles.image} src={workflowImage} alt="Organization workflow" />
<div className={styles.subtitle}>{t('brief_introduction')}</div>
</Card>
<Card className={styles.card}>
<PermissionIcon className={styles.icon} />
<div className={styles.title}>{t('step_1')}</div>
</Card>
</div>
);
}
export default Step1;

View file

@ -0,0 +1,5 @@
export const steps = Object.freeze({
createPermissions: 'create-permissions',
createRoles: 'create-roles',
createOrganization: 'create-organization',
});

View file

@ -1,4 +1,5 @@
@use '@/scss/underscore' as _;
@use '@/scss/dimensions' as dim;
.modalContainer {
height: 100vh;
@ -6,11 +7,77 @@
display: flex;
flex-direction: column;
background: var(--color-base);
}
.content {
flex: 1;
overflow: hidden;
.stepContainer {
flex: 1;
overflow: hidden;
width: 100%;
padding: _.unit(6);
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: _.unit(6);
.card {
display: flex;
flex-direction: column;
width: 100%;
padding: _.unit(6);
max-width: dim.$guide-main-content-max-width;
padding: _.unit(12);
gap: _.unit(6);
.section {
display: flex;
flex-direction: column;
width: 100%;
gap: _.unit(3);
}
.image {
width: 100%;
}
.icon {
width: 48px;
height: 48px;
flex-shrink: 0;
}
.title {
font: var(--font-title-1);
}
.subtitle {
font: var(--font-title-2);
color: var(--color-text-secondary);
}
.description {
font: var(--font-body-2);
}
.fieldGroup {
display: flex;
flex-direction: column;
gap: _.unit(3);
width: 100%;
> div {
margin: 0;
}
}
ul {
margin: 0;
padding-inline-start: 2ch;
> li {
padding-inline-start: _.unit(1);
}
}
}
}

View file

@ -1,34 +1,24 @@
import { useCallback, useState } from 'react';
import { useCallback } from 'react';
import Modal from 'react-modal';
import { Navigate, Route, Routes } from 'react-router-dom';
import ActionBar from '@/components/ActionBar';
import Button from '@/ds-components/Button';
import DsModalHeader from '@/ds-components/ModalHeader';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import * as modalStyles from '@/scss/modal.module.scss';
import Step1 from './Step1';
import CreateOrganization from './CreateOrganization';
import CreatePermissions from './CreatePermissions';
import CreateRoles from './CreateRoles';
import { steps } from './const';
import * as styles from './index.module.scss';
const totalSteps = 3;
function Guide() {
const { navigate } = useTenantPathname();
const [currentStep, setCurrentStep] = useState(1);
const onClose = useCallback(() => {
navigate('/organizations');
}, [navigate]);
const onClickNext = useCallback(() => {
setCurrentStep(Math.min(currentStep + 1, totalSteps));
}, [currentStep]);
const onClickBack = useCallback(() => {
setCurrentStep(Math.max(1, currentStep - 1));
}, [currentStep]);
return (
<Modal shouldCloseOnEsc isOpen className={modalStyles.fullScreen} onRequestClose={onClose}>
<div className={styles.modalContainer}>
@ -37,18 +27,12 @@ function Guide() {
subtitle="organizations.guide.subtitle"
onClose={onClose}
/>
<OverlayScrollbar className={styles.content}>
{currentStep === 1 && <Step1 />}
</OverlayScrollbar>
<ActionBar step={currentStep} totalSteps={totalSteps}>
{currentStep === totalSteps && (
<Button title="general.done" type="primary" onClick={onClose} />
)}
{currentStep < totalSteps && (
<Button title="general.next" type="primary" onClick={onClickNext} />
)}
{currentStep > 1 && <Button title="general.back" onClick={onClickBack} />}
</ActionBar>
<Routes>
<Route index element={<Navigate replace to={steps.createPermissions} />} />
<Route path={steps.createPermissions} element={<CreatePermissions />} />
<Route path={steps.createRoles} element={<CreateRoles />} />
<Route path={steps.createOrganization} element={<CreateOrganization />} />
</Routes>
</div>
</Modal>
);

View file

@ -15,7 +15,7 @@ import useApi from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
const organizationScopesPath = 'api/organization-scopes';
export const organizationScopesPath = 'api/organization-scopes';
type Props = {
isOpen: boolean;

View file

@ -17,7 +17,7 @@ import useApi from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
const organizationRolePath = 'api/organization-roles';
export const organizationRolePath = 'api/organization-roles';
type Props = {
isOpen: boolean;

View file

@ -47,7 +47,7 @@ function Organizations({ tab }: Props) {
size="large"
title="organizations.create_organization"
onClick={() => {
navigate(createPathname);
navigate('/organization-guide');
}}
/>
</div>