Skip to content

Commit

Permalink
feat(core,console): organization jit roles
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun committed Jun 14, 2024
1 parent 847a7c4 commit 3ea37c5
Show file tree
Hide file tree
Showing 16 changed files with 329 additions and 130 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,6 @@
margin-top: _.unit(3);
}

.membershipDescription {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin-top: _.unit(1.5);
}

.emailDomains {
margin-top: _.unit(1);
}

.warning {
margin-top: _.unit(3);
}
160 changes: 95 additions & 65 deletions packages/console/src/pages/OrganizationDetails/Settings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type SignInExperience, type Organization } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
Expand All @@ -9,11 +10,13 @@ import useSWR from 'swr';
import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard';
import MultiOptionInput from '@/components/MultiOptionInput';
import OrganizationRolesSelect from '@/components/OrganizationRolesSelect';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { isDevFeaturesEnabled } from '@/consts/env';
import CodeEditor from '@/ds-components/CodeEditor';
import FormField from '@/ds-components/FormField';
import InlineNotification from '@/ds-components/InlineNotification';
import { type Option } from '@/ds-components/Select/MultiSelect';
import Switch from '@/ds-components/Switch';
import TextInput from '@/ds-components/TextInput';
import useApi, { type RequestError } from '@/hooks/use-api';
Expand All @@ -27,17 +30,22 @@ import * as styles from './index.module.scss';
type FormData = Partial<Omit<Organization, 'customData'> & { customData: string }> & {
isJitEnabled: boolean;
jitEmailDomains: string[];
jitRoles: Array<Option<string>>;
};

const isJsonObject = (value: string) => {
const parsed = trySafe<unknown>(() => JSON.parse(value));
return Boolean(parsed && typeof parsed === 'object');
};

const normalizeData = (data: Organization, emailDomains: string[]): FormData => ({
const normalizeData = (
data: Organization,
jit: { emailDomains: string[]; roles: Array<Option<string>> }
): FormData => ({
...data,
isJitEnabled: emailDomains.length > 0,
jitEmailDomains: emailDomains,
isJitEnabled: jit.emailDomains.length > 0 || jit.roles.length > 0,
jitEmailDomains: jit.emailDomains,
jitRoles: jit.roles,
customData: JSON.stringify(data.customData, undefined, 2),
});

Expand All @@ -53,8 +61,7 @@ const assembleData = ({
});

function Settings() {
const { isDeleting, data, emailDomains, onUpdated } =
useOutletContext<OrganizationDetailsOutletContext>();
const { isDeleting, data, jit, onUpdated } = useOutletContext<OrganizationDetailsOutletContext>();
const { data: signInExperience } = useSWR<SignInExperience, RequestError>('api/sign-in-exp');
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
Expand All @@ -67,13 +74,14 @@ function Settings() {
clearErrors,
watch,
} = useForm<FormData>({
defaultValues: normalizeData(
data,
emailDomains.map(({ emailDomain }) => emailDomain)
),
defaultValues: normalizeData(data, {
emailDomains: jit.emailDomains.map(({ emailDomain }) => emailDomain),
roles: jit.roles.map(({ id, name }) => ({ value: id, title: name })),
}),
});
const [isJitEnabled, isMfaRequired] = watch(['isJitEnabled', 'isMfaRequired']);
const api = useApi();
const [keyword, setKeyword] = useState('');

const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
Expand All @@ -82,17 +90,23 @@ function Settings() {
}

const emailDomains = data.isJitEnabled ? data.jitEmailDomains : [];
const roles = data.isJitEnabled ? data.jitRoles : [];
const updatedData = await api
.patch(`api/organizations/${data.id}`, {
json: assembleData(data),
})
.json<Organization>();

await api.put(`api/organizations/${data.id}/email-domains`, {
json: { emailDomains },
});
await Promise.all([
api.put(`api/organizations/${data.id}/jit/email-domains`, {
json: { emailDomains },
}),
api.put(`api/organizations/${data.id}/jit/roles`, {
json: { organizationRoleIds: roles.map(({ value }) => value) },
}),
]);

reset(normalizeData(updatedData, emailDomains));
reset(normalizeData(updatedData, { emailDomains, roles }));
toast.success(t('general.saved'));
onUpdated(updatedData);
})
Expand Down Expand Up @@ -139,57 +153,73 @@ function Settings() {
/>
</FormField>
</FormCard>
<FormCard
title="organization_details.membership_policies"
description="organization_details.membership_policies_description"
>
<FormField title="organization_details.jit.is_enabled_title">
<div className={styles.jitContent}>
<Switch
label={t('organization_details.jit.description')}
{...register('isJitEnabled')}
/>
</div>
</FormField>
{isJitEnabled && (
<FormField title="organization_details.jit.email_domain_provisioning">
<p className={styles.membershipDescription}>
{t('organization_details.jit.membership_description')}
</p>
<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');
}}
/>
)}
/>
{isDevFeaturesEnabled && (
<FormCard
title="organization_details.membership_policies"
description="organization_details.membership_policies_description"
>
<FormField title="organization_details.jit.title">
<div className={styles.jitContent}>
<Switch
label={t('organization_details.jit.description')}
{...register('isJitEnabled')}
/>
</div>
</FormField>
)}
{isDevFeaturesEnabled && (
{isJitEnabled && (
<FormField title="organization_details.jit.email_domains">
<Controller
name="jitEmailDomains"
control={control}
render={({ field: { onChange, value } }) => (
<MultiOptionInput
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');
}}
/>
)}
/>
</FormField>
)}
{isJitEnabled && (
<FormField
title="organization_details.jit.organization_roles"
description="organization_details.jit.organization_roles_description"
descriptionPosition="top"
>
<Controller
name="jitRoles"
control={control}
render={({ field: { onChange, value } }) => (
<OrganizationRolesSelect
keyword={keyword}
setKeyword={setKeyword}
value={value}
onChange={onChange}
/>
)}
/>
</FormField>
)}
<FormField title="organization_details.mfa.title" tip={t('organization_details.mfa.tip')}>
<Switch
label={t('organization_details.mfa.description')}
Expand All @@ -201,8 +231,8 @@ function Settings() {
</InlineNotification>
)}
</FormField>
)}
</FormCard>
</FormCard>
)}
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
</DetailsForm>
);
Expand Down
26 changes: 19 additions & 7 deletions packages/console/src/pages/OrganizationDetails/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { type OrganizationEmailDomain, type Organization } from '@logto/schemas';
import {
type OrganizationJitEmailDomain,
type Organization,
type OrganizationRole,
} from '@logto/schemas';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet, useParams } from 'react-router-dom';
Expand Down Expand Up @@ -31,8 +35,11 @@ function OrganizationDetails() {
const { navigate } = useTenantPathname();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const organization = useSWR<Organization, RequestError>(id && `api/organizations/${id}`);
const emailDomains = useSWR<OrganizationEmailDomain[], RequestError>(
id && `api/organizations/${id}/email-domains`
const jitEmailDomains = useSWR<OrganizationJitEmailDomain[], RequestError>(
id && `api/organizations/${id}/jit/email-domains`
);
const jitRoles = useSWR<OrganizationRole[], RequestError>(
id && `api/organizations/${id}/jit/roles`
);
const [isDeleting, setIsDeleting] = useState(false);
const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false);
Expand All @@ -54,15 +61,17 @@ function OrganizationDetails() {
}, [api, id, isDeleting, navigate]);

const isLoading =
(!organization.data && !organization.error) || (!emailDomains.data && !emailDomains.error);
const error = organization.error ?? emailDomains.error;
(!organization.data && !organization.error) ||
(!jitEmailDomains.data && !jitEmailDomains.error) ||
(!jitRoles.data && !jitRoles.error);
const error = organization.error ?? jitEmailDomains.error ?? jitRoles.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} />}
{id && organization.data && emailDomains.data && (
{id && organization.data && jitEmailDomains.data && jitRoles.data && (
<>
<DetailsPageHeader
icon={<ThemedIcon for={OrganizationIcon} size={60} />}
Expand Down Expand Up @@ -118,7 +127,10 @@ function OrganizationDetails() {
context={
{
data: organization.data,
emailDomains: emailDomains.data,
jit: {
emailDomains: jitEmailDomains.data,
roles: jitRoles.data,
},
isDeleting,
onUpdated: async (data) => organization.mutate(data),
} satisfies OrganizationDetailsOutletContext
Expand Down
11 changes: 9 additions & 2 deletions packages/console/src/pages/OrganizationDetails/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { type OrganizationEmailDomain, type Organization } from '@logto/schemas';
import {
type OrganizationJitEmailDomain,
type Organization,
type OrganizationRole,
} from '@logto/schemas';

export type OrganizationDetailsOutletContext = {
data: Organization;
emailDomains: OrganizationEmailDomain[];
jit: {
emailDomains: OrganizationJitEmailDomain[];
roles: OrganizationRole[];
};
/**
* Whether the organization is being deleted, this is used to disable the unsaved
* changes alert modal.
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/libraries/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,8 @@ export const createUserLibrary = (queries: Queries) => {
const userEmailDomain = data.primaryEmail?.split('@')[1];
if (userEmailDomain) {
const organizationQueries = new OrganizationQueries(connection);
const organizationIds = await organizationQueries.emailDomains.getOrganizationIdsByDomain(
userEmailDomain
);
const organizationIds =
await organizationQueries.jit.emailDomains.getOrganizationIdsByDomain(userEmailDomain);

Check warning on line 152 in packages/core/src/libraries/user.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/user.ts#L151-L152

Added lines #L151 - L152 were not covered by tests

if (organizationIds.length > 0) {
await organizationQueries.relations.users.insert(
Expand Down
Loading

0 comments on commit 3ea37c5

Please sign in to comment.