From fc1699631cf6b15c777ca3c0bca2a03bc3a5e407 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 7 Jun 2024 15:00:06 +0800 Subject: [PATCH] feat(console): implement organization jit ui --- .../MultiOptionInput/index.module.scss | 100 ++++++++++ .../src/components/MultiOptionInput/index.tsx | 175 ++++++++++++++++++ .../Experience/DomainsInput/index.tsx | 1 + .../Settings/index.module.scss | 10 + .../OrganizationDetails/Settings/index.tsx | 108 ++++++++++- .../src/pages/OrganizationDetails/index.tsx | 26 +-- .../src/pages/OrganizationDetails/types.ts | 3 +- .../TenantMembers/InviteEmailsInput/index.tsx | 1 + .../organization/index.email-domains.ts | 2 +- .../admin-console/organization-details.ts | 13 ++ 10 files changed, 419 insertions(+), 20 deletions(-) create mode 100644 packages/console/src/components/MultiOptionInput/index.module.scss create mode 100644 packages/console/src/components/MultiOptionInput/index.tsx create mode 100644 packages/console/src/pages/OrganizationDetails/Settings/index.module.scss diff --git a/packages/console/src/components/MultiOptionInput/index.module.scss b/packages/console/src/components/MultiOptionInput/index.module.scss new file mode 100644 index 000000000..933e08bc0 --- /dev/null +++ b/packages/console/src/components/MultiOptionInput/index.module.scss @@ -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; +} diff --git a/packages/console/src/components/MultiOptionInput/index.tsx b/packages/console/src/components/MultiOptionInput/index.tsx new file mode 100644 index 000000000..e003d5a28 --- /dev/null +++ b/packages/console/src/components/MultiOptionInput/index.tsx @@ -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 | Promise; + +type Props = { + 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({ + className, + values, + getId: getIdInput, + onError, + onClearError, + renderValue, + onChange, + error, + placeholder, + validateInput, +}: Props) { + const ref = useRef(null); + const [focusedValueId, setFocusedValueId] = useState>(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 ( + <> +
{ + ref.current?.focus(); + })} + onClick={() => { + ref.current?.focus(); + }} + > + {placeholder && values.length === 0 && !currentValue && ( +
{placeholder}
+ )} +
+ {values.map((option) => ( + { + ref.current?.focus(); + }} + > + {renderValue(option)} + { + handleDelete(option); + }} + onKeyDown={onKeyDownHandler(() => { + handleDelete(option); + })} + > + + + + ))} + { + 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); + }} + /> +
+
+ {Boolean(error) && typeof error === 'string' && ( +
{error}
+ )} + + ); +} + +export default MultiOptionInput; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.tsx index f72b22d45..e4e9e29f7 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.tsx @@ -27,6 +27,7 @@ type Props = { readonly placeholder?: AdminConsoleKey; }; +// TODO: @Charles refactor me, use `` instead. function DomainsInput({ className, values, onChange: rawOnChange, error, placeholder }: Props) { const inputRef = useRef(null); const [focusedValueId, setFocusedValueId] = useState>(null); diff --git a/packages/console/src/pages/OrganizationDetails/Settings/index.module.scss b/packages/console/src/pages/OrganizationDetails/Settings/index.module.scss new file mode 100644 index 000000000..ca8c775c0 --- /dev/null +++ b/packages/console/src/pages/OrganizationDetails/Settings/index.module.scss @@ -0,0 +1,10 @@ +@use '@/scss/underscore' as _; + +.jitContent { + margin-top: _.unit(3); + + .emailDomains { + margin-top: _.unit(2); + margin-left: _.unit(6); + } +} diff --git a/packages/console/src/pages/OrganizationDetails/Settings/index.tsx b/packages/console/src/pages/OrganizationDetails/Settings/index.tsx index 29471ce01..77e253fff 100644 --- a/packages/console/src/pages/OrganizationDetails/Settings/index.tsx +++ b/packages/console/src/pages/OrganizationDetails/Settings/index.tsx @@ -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 & { customData: string }>; +import * as styles from './index.module.scss'; + +type FormData = Partial & { customData: string }> & { + isJitEnabled: boolean; + jitEmailDomains: string[]; +}; const isJsonObject = (value: string) => { const parsed = trySafe(() => 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 => ({ +const assembleData = ({ + isJitEnabled, + jitEmailDomains, + customData, + ...data +}: FormData): Partial => ({ ...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(); + const { isDeleting, data, emailDomains, onUpdated } = + useOutletContext(); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { register, @@ -43,8 +60,13 @@ function Settings() { control, handleSubmit, formState: { isDirty, isSubmitting, errors }, + setError, + clearErrors, } = useForm({ - 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(); - 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() { /> + {isDevFeaturesEnabled && ( + + + ( +
+ { + field.onChange(value === 'true'); + }} + > + + + + {field.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'); + }} + /> + )} + /> + )} +
+ )} + /> +
+
+ )} ); diff --git a/packages/console/src/pages/OrganizationDetails/index.tsx b/packages/console/src/pages/OrganizationDetails/index.tsx index 22d3d58e5..57e93cf7d 100644 --- a/packages/console/src/pages/OrganizationDetails/index.tsx +++ b/packages/console/src/pages/OrganizationDetails/index.tsx @@ -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( - id && `api/organizations/${id}` + const organization = useSWR(id && `api/organizations/${id}`); + const emailDomains = useSWR( + 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 ( {isLoading && } {error && } - {data && ( + {id && organization.data && emailDomains.data && ( <> } - 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: , title: 'application_details.check_guide', @@ -104,19 +107,20 @@ function OrganizationDetails() { {t('organization_details.delete_confirmation')} - + {t('general.settings_nav')} - + {t('organizations.members')} mutate(data), + onUpdated: async (data) => organization.mutate(data), } satisfies OrganizationDetailsOutletContext } /> diff --git a/packages/console/src/pages/OrganizationDetails/types.ts b/packages/console/src/pages/OrganizationDetails/types.ts index 6eb9254cb..f9f8c2340 100644 --- a/packages/console/src/pages/OrganizationDetails/types.ts +++ b/packages/console/src/pages/OrganizationDetails/types.ts @@ -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. diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/index.tsx b/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/index.tsx index 0cae0b3f6..5108d0fc6 100644 --- a/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/index.tsx +++ b/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/index.tsx @@ -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 `` instead. function InviteEmailsInput({ formName = 'emails', className, diff --git a/packages/core/src/routes/organization/index.email-domains.ts b/packages/core/src/routes/organization/index.email-domains.ts index b9a5fd560..e60bf0b14 100644 --- a/packages/core/src/routes/organization/index.email-domains.ts +++ b/packages/core/src/routes/organization/index.email-domains.ts @@ -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) => { diff --git a/packages/phrases/src/locales/en/translation/admin-console/organization-details.ts b/packages/phrases/src/locales/en/translation/admin-console/organization-details.ts index 65018c531..a3e10cd12 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/organization-details.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/organization-details.ts @@ -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);