0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

feat(console): implement organization jit ui

This commit is contained in:
Gao Sun 2024-06-07 15:00:06 +08:00
parent afb1091603
commit fc1699631c
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
10 changed files with 419 additions and 20 deletions

View file

@ -0,0 +1,100 @@
@use '@/scss/underscore' as _;
.input {
display: flex;
align-items: flex-start;
justify-content: space-between;
min-height: 102px;
padding: _.unit(1.5) _.unit(3);
background: var(--color-layer-1);
border: 1px solid var(--color-border);
border-radius: 8px;
outline: 3px solid transparent;
transition-property: outline, border;
transition-timing-function: ease-in-out;
transition-duration: 0.2s;
font: var(--font-body-2);
cursor: text;
position: relative;
.placeholder {
position: absolute;
inset: 0;
padding: _.unit(1.5) _.unit(3);
color: var(--color-placeholder);
}
.wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: _.unit(2);
width: 100%;
.tag {
cursor: auto;
display: flex;
align-items: center;
gap: _.unit(1);
position: relative;
&.focused::after {
content: '';
position: absolute;
inset: 0;
background: var(--color-overlay-default-focused);
}
}
.close {
width: 16px;
height: 16px;
}
.delete {
width: 20px;
height: 20px;
margin-right: _.unit(-0.5);
}
input {
color: var(--color-text);
font: var(--font-body-2);
background: transparent;
flex: 1;
padding: 0;
appearance: none;
&::placeholder {
color: var(--color-placeholder);
}
}
}
&:focus-within {
border-color: var(--color-primary);
outline-color: var(--color-focused-variant);
}
&.error {
border-color: var(--color-error);
&:focus-within {
outline-color: var(--color-danger-focused);
}
}
}
canvas {
display: none;
}
.errorMessage {
font: var(--font-body-2);
color: var(--color-error);
margin-top: _.unit(1);
white-space: pre-wrap;
}

View file

@ -0,0 +1,175 @@
import { isKeyInObject, type Nullable } from '@silverhand/essentials';
import classNames from 'classnames';
import { type ReactNode, useRef, useState, useCallback } from 'react';
import Close from '@/assets/icons/close.svg';
import IconButton from '@/ds-components/IconButton';
import Tag from '@/ds-components/Tag';
import { onKeyDownHandler } from '@/utils/a11y';
import * as styles from './index.module.scss';
type CanBePromise<T> = T | Promise<T>;
type Props<T> = {
readonly className?: string;
readonly values: T[];
readonly getId?: (value: T) => string;
readonly onError?: (error: string) => void;
readonly onClearError?: () => void;
readonly onChange: (values: T[]) => void;
readonly renderValue: (value: T) => ReactNode;
/** Give a text input, return the parsed value or an error message if it cannot be parsed. */
readonly validateInput: (text: string) => CanBePromise<{ value: T } | string>;
readonly error?: string | boolean;
readonly placeholder?: string;
};
function MultiOptionInput<T>({
className,
values,
getId: getIdInput,
onError,
onClearError,
renderValue,
onChange,
error,
placeholder,
validateInput,
}: Props<T>) {
const ref = useRef<HTMLInputElement>(null);
const [focusedValueId, setFocusedValueId] = useState<Nullable<string>>(null);
const [currentValue, setCurrentValue] = useState('');
const getId = useCallback(
(value: T): string => {
if (getIdInput) {
return getIdInput(value);
}
if (isKeyInObject(value, 'id')) {
return String(value.id);
}
return String(value);
},
[getIdInput]
);
const handleChange = (values: T[]) => {
onClearError?.();
onChange(values);
};
const handleAdd = async (text: string) => {
const result = await validateInput(text);
if (typeof result === 'string') {
onError?.(result);
return;
}
handleChange([...values, result.value]);
setCurrentValue('');
ref.current?.focus();
};
const handleDelete = (option: T) => {
onChange(values.filter((value) => getId(value) !== getId(option)));
};
return (
<>
<div
className={classNames(styles.input, Boolean(error) && styles.error, className)}
role="button"
tabIndex={0}
onKeyDown={onKeyDownHandler(() => {
ref.current?.focus();
})}
onClick={() => {
ref.current?.focus();
}}
>
{placeholder && values.length === 0 && !currentValue && (
<div className={styles.placeholder}>{placeholder}</div>
)}
<div className={styles.wrapper}>
{values.map((option) => (
<Tag
key={getId(option)}
variant="cell"
className={classNames(styles.tag, getId(option) === focusedValueId && styles.focused)}
onClick={() => {
ref.current?.focus();
}}
>
{renderValue(option)}
<IconButton
className={styles.delete}
size="small"
onClick={() => {
handleDelete(option);
}}
onKeyDown={onKeyDownHandler(() => {
handleDelete(option);
})}
>
<Close className={styles.close} />
</IconButton>
</Tag>
))}
<input
ref={ref}
value={currentValue}
onKeyDown={async (event) => {
switch (event.key) {
case 'Backspace': {
if (currentValue === '') {
if (focusedValueId) {
onChange(values.filter((value) => getId(value) !== focusedValueId));
setFocusedValueId(null);
} else {
const lastValue = values.at(-1);
setFocusedValueId(lastValue ? getId(lastValue) : null);
}
ref.current?.focus();
}
break;
}
case ' ':
case 'Enter': {
// Do not react to "Enter"
event.preventDefault();
// Focusing on input
if (currentValue !== '' && document.activeElement === ref.current) {
await handleAdd(currentValue);
}
break;
}
default:
}
}}
onChange={({ currentTarget: { value } }) => {
setCurrentValue(value);
setFocusedValueId(null);
}}
onFocus={() => {
ref.current?.focus();
}}
onBlur={async () => {
if (currentValue !== '') {
await handleAdd(currentValue);
}
setFocusedValueId(null);
}}
/>
</div>
</div>
{Boolean(error) && typeof error === 'string' && (
<div className={styles.errorMessage}>{error}</div>
)}
</>
);
}
export default MultiOptionInput;

View file

@ -27,6 +27,7 @@ type Props = {
readonly placeholder?: AdminConsoleKey;
};
// TODO: @Charles refactor me, use `<MultiOptionInput />` instead.
function DomainsInput({ className, values, onChange: rawOnChange, error, placeholder }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [focusedValueId, setFocusedValueId] = useState<Nullable<string>>(null);

View file

@ -0,0 +1,10 @@
@use '@/scss/underscore' as _;
.jitContent {
margin-top: _.unit(3);
.emailDomains {
margin-top: _.unit(2);
margin-left: _.unit(6);
}
}

View file

@ -7,35 +7,52 @@ import { useOutletContext } from 'react-router-dom';
import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard';
import MultiOptionInput from '@/components/MultiOptionInput';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { isDevFeaturesEnabled } from '@/consts/env';
import CodeEditor from '@/ds-components/CodeEditor';
import FormField from '@/ds-components/FormField';
import RadioGroup, { Radio } from '@/ds-components/RadioGroup';
import TextInput from '@/ds-components/TextInput';
import useApi from '@/hooks/use-api';
import { domainRegExp } from '@/pages/EnterpriseSsoDetails/Experience/DomainsInput/consts';
import { trySubmitSafe } from '@/utils/form';
import { type OrganizationDetailsOutletContext } from '../types';
type FormData = Partial<Omit<Organization, 'customData'> & { customData: string }>;
import * as styles from './index.module.scss';
type FormData = Partial<Omit<Organization, 'customData'> & { customData: string }> & {
isJitEnabled: boolean;
jitEmailDomains: string[];
};
const isJsonObject = (value: string) => {
const parsed = trySafe<unknown>(() => JSON.parse(value));
return Boolean(parsed && typeof parsed === 'object');
};
const normalizeData = (data: Organization): FormData => ({
const normalizeData = (data: Organization, emailDomains: string[]): FormData => ({
...data,
isJitEnabled: emailDomains.length > 0,
jitEmailDomains: emailDomains,
customData: JSON.stringify(data.customData, undefined, 2),
});
const assembleData = (data: FormData): Partial<Organization> => ({
const assembleData = ({
isJitEnabled,
jitEmailDomains,
customData,
...data
}: FormData): Partial<Organization> => ({
...data,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
customData: JSON.parse(data.customData ?? '{}'),
customData: JSON.parse(customData ?? '{}'),
});
function Settings() {
const { isDeleting, data, onUpdated } = useOutletContext<OrganizationDetailsOutletContext>();
const { isDeleting, data, emailDomains, onUpdated } =
useOutletContext<OrganizationDetailsOutletContext>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
register,
@ -43,8 +60,13 @@ function Settings() {
control,
handleSubmit,
formState: { isDirty, isSubmitting, errors },
setError,
clearErrors,
} = useForm<FormData>({
defaultValues: normalizeData(data),
defaultValues: normalizeData(
data,
emailDomains.map(({ emailDomain }) => emailDomain)
),
});
const api = useApi();
@ -54,12 +76,18 @@ function Settings() {
return;
}
const emailDomains = data.isJitEnabled ? data.jitEmailDomains : [];
const updatedData = await api
.patch(`api/organizations/${data.id}`, {
json: assembleData(data),
})
.json<Organization>();
reset(normalizeData(updatedData));
await api.put(`api/organizations/${data.id}/email-domains`, {
json: { emailDomains },
});
reset(normalizeData(updatedData, emailDomains));
toast.success(t('general.saved'));
onUpdated(updatedData);
})
@ -106,6 +134,72 @@ function Settings() {
/>
</FormField>
</FormCard>
{isDevFeaturesEnabled && (
<FormCard
title="organization_details.jit.title"
description="organization_details.jit.description"
>
<FormField title="organization_details.jit.is_enabled_title">
<Controller
name="isJitEnabled"
control={control}
render={({ field }) => (
<div className={styles.jitContent}>
<RadioGroup
name="isJitEnabled"
value={String(field.value)}
onChange={(value) => {
field.onChange(value === 'true');
}}
>
<Radio
value="false"
title="organization_details.jit.is_enabled_false_description"
/>
<Radio
value="true"
title="organization_details.jit.is_enabled_true_description"
/>
</RadioGroup>
{field.value && (
<Controller
name="jitEmailDomains"
control={control}
render={({ field: { onChange, value } }) => (
<MultiOptionInput
className={styles.emailDomains}
values={value}
renderValue={(value) => value}
validateInput={(input) => {
if (!domainRegExp.test(input)) {
return t('organization_details.jit.invalid_domain');
}
if (value.includes(input)) {
return t('organization_details.jit.domain_already_added');
}
return { value: input };
}}
placeholder={t('organization_details.jit.email_domains_placeholder')}
error={errors.jitEmailDomains?.message}
onChange={onChange}
onError={(error) => {
setError('jitEmailDomains', { type: 'custom', message: error });
}}
onClearError={() => {
clearErrors('jitEmailDomains');
}}
/>
)}
/>
)}
</div>
)}
/>
</FormField>
</FormCard>
)}
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
</DetailsForm>
);

View file

@ -1,4 +1,4 @@
import { type Organization } from '@logto/schemas';
import { type OrganizationEmailDomain, type Organization } from '@logto/schemas';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet, useParams } from 'react-router-dom';
@ -30,8 +30,9 @@ function OrganizationDetails() {
const { id } = useParams();
const { navigate } = useTenantPathname();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data, error, mutate } = useSWR<Organization, RequestError>(
id && `api/organizations/${id}`
const organization = useSWR<Organization, RequestError>(id && `api/organizations/${id}`);
const emailDomains = useSWR<OrganizationEmailDomain[], RequestError>(
id && `api/organizations/${id}/email-domains`
);
const [isDeleting, setIsDeleting] = useState(false);
const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false);
@ -52,19 +53,21 @@ function OrganizationDetails() {
}
}, [api, id, isDeleting, navigate]);
const isLoading = !data && !error;
const isLoading =
(!organization.data && !organization.error) || (!emailDomains.data && !emailDomains.error);
const error = organization.error ?? emailDomains.error;
return (
<DetailsPage backLink={pathname} backLinkTitle="organizations.title" className={styles.page}>
<PageMeta titleKey="organization_details.page_title" />
{isLoading && <Skeleton />}
{error && <AppError errorCode={error.body?.code} errorMessage={error.body?.message} />}
{data && (
{id && organization.data && emailDomains.data && (
<>
<DetailsPageHeader
icon={<ThemedIcon for={OrganizationIcon} size={60} />}
title={data.name}
identifier={{ name: t('organization_details.organization_id'), value: data.id }}
title={organization.data.name}
identifier={{ name: t('organization_details.organization_id'), value: id }}
additionalActionButton={{
icon: <File />,
title: 'application_details.check_guide',
@ -104,19 +107,20 @@ function OrganizationDetails() {
{t('organization_details.delete_confirmation')}
</DeleteConfirmModal>
<TabNav>
<TabNavItem href={`${pathname}/${data.id}/${OrganizationDetailsTabs.Settings}`}>
<TabNavItem href={`${pathname}/${id}/${OrganizationDetailsTabs.Settings}`}>
{t('general.settings_nav')}
</TabNavItem>
<TabNavItem href={`${pathname}/${data.id}/${OrganizationDetailsTabs.Members}`}>
<TabNavItem href={`${pathname}/${id}/${OrganizationDetailsTabs.Members}`}>
{t('organizations.members')}
</TabNavItem>
</TabNav>
<Outlet
context={
{
data,
data: organization.data,
emailDomains: emailDomains.data,
isDeleting,
onUpdated: async (data) => mutate(data),
onUpdated: async (data) => organization.mutate(data),
} satisfies OrganizationDetailsOutletContext
}
/>

View file

@ -1,7 +1,8 @@
import { type Organization } from '@logto/schemas';
import { type OrganizationEmailDomain, type Organization } from '@logto/schemas';
export type OrganizationDetailsOutletContext = {
data: Organization;
emailDomains: OrganizationEmailDomain[];
/**
* Whether the organization is being deleted, this is used to disable the unsaved
* changes alert modal.

View file

@ -38,6 +38,7 @@ type Props = {
const fontBody2 =
'400 14px / 20px -apple-system, system-ui, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Helvetica, Arial, sans-serif, Apple Color Emoji';
// TODO: @Charles refactor me, use `<MultiOptionInput />` instead.
function InviteEmailsInput({
formName = 'emails',
className,

View file

@ -55,7 +55,7 @@ export default function emailDomainRoutes(
pathname,
koaGuard({
params: z.object(params),
body: z.object({ emailDomains: z.string().min(1).array().nonempty() }),
body: z.object({ emailDomains: z.string().array() }),
status: [204],
}),
async (ctx, next) => {

View file

@ -26,6 +26,19 @@ const organization_details = {
custom_data_tip:
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
invalid_json_object: 'Invalid JSON object.',
jit: {
title: 'Just-in-time (JIT) provisioning',
description:
'Automatically assign users into this organization when they sign up or are added through the Management API, provided their email addresses match the specified domains.',
is_enabled_title: 'Enable just-in-time provisioning',
is_enabled_true_description:
'New users with verified email domains will automatically join the organization',
is_enabled_false_description:
'Users can join the organization only if they are invited or added via Management API',
email_domains_placeholder: 'Enter email domains for just-in-time provisioning',
invalid_domain: 'Invalid domain',
domain_already_added: 'Domain already added',
},
};
export default Object.freeze(organization_details);