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:
parent
7029255b91
commit
8156d95ac9
14 changed files with 660 additions and 122 deletions
|
@ -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 />} />
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
5
packages/console/src/pages/Organizations/Guide/const.ts
Normal file
5
packages/console/src/pages/Organizations/Guide/const.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export const steps = Object.freeze({
|
||||
createPermissions: 'create-permissions',
|
||||
createRoles: 'create-roles',
|
||||
createOrganization: 'create-organization',
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -47,7 +47,7 @@ function Organizations({ tab }: Props) {
|
|||
size="large"
|
||||
title="organizations.create_organization"
|
||||
onClick={() => {
|
||||
navigate(createPathname);
|
||||
navigate('/organization-guide');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue