mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
Merge pull request #5998 from logto-io/gao-console-jit
feat(console): implement organization jit ui
This commit is contained in:
commit
8306cc4263
10 changed files with 419 additions and 20 deletions
|
@ -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;
|
||||
}
|
175
packages/console/src/components/MultiOptionInput/index.tsx
Normal file
175
packages/console/src/components/MultiOptionInput/index.tsx
Normal 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;
|
|
@ -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);
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.jitContent {
|
||||
margin-top: _.unit(3);
|
||||
|
||||
.emailDomains {
|
||||
margin-top: _.unit(2);
|
||||
margin-left: _.unit(6);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Reference in a new issue