mirror of
https://github.com/logto-io/logto.git
synced 2025-03-17 22:31:28 -05:00
feat(core,console): organization jit roles
This commit is contained in:
parent
847a7c413a
commit
3ea37c5275
16 changed files with 329 additions and 130 deletions
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -27,6 +30,7 @@ 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) => {
|
||||
|
@ -34,10 +38,14 @@ const isJsonObject = (value: string) => {
|
|||
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),
|
||||
});
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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) => {
|
||||
|
@ -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);
|
||||
})
|
||||
|
@ -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')}
|
||||
|
@ -201,8 +231,8 @@ function Settings() {
|
|||
</InlineNotification>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</FormCard>
|
||||
</FormCard>
|
||||
)}
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
|
||||
</DetailsForm>
|
||||
);
|
||||
|
|
|
@ -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';
|
||||
|
@ -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);
|
||||
|
@ -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} />}
|
||||
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
|
||||
if (organizationIds.length > 0) {
|
||||
await organizationQueries.relations.users.insert(
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
type OrganizationEmailDomain,
|
||||
OrganizationEmailDomains,
|
||||
type CreateOrganizationEmailDomain,
|
||||
type OrganizationJitEmailDomain,
|
||||
OrganizationJitEmailDomains,
|
||||
type CreateOrganizationJitEmailDomain,
|
||||
} from '@logto/schemas';
|
||||
import { type CommonQueryMethods, sql } from '@silverhand/slonik';
|
||||
|
||||
|
@ -10,15 +10,15 @@ import { DeletionError } from '#src/errors/SlonikError/index.js';
|
|||
import { type GetEntitiesOptions } from '#src/utils/RelationQueries.js';
|
||||
import { type OmitAutoSetFields, conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';
|
||||
|
||||
const { table, fields } = convertToIdentifiers(OrganizationEmailDomains);
|
||||
const { table, fields } = convertToIdentifiers(OrganizationJitEmailDomains);
|
||||
|
||||
export class EmailDomainQueries {
|
||||
readonly #insert: (
|
||||
data: OmitAutoSetFields<CreateOrganizationEmailDomain>
|
||||
) => Promise<Readonly<OrganizationEmailDomain>>;
|
||||
data: OmitAutoSetFields<CreateOrganizationJitEmailDomain>
|
||||
) => Promise<Readonly<OrganizationJitEmailDomain>>;
|
||||
|
||||
constructor(protected pool: CommonQueryMethods) {
|
||||
this.#insert = buildInsertIntoWithPool(this.pool)(OrganizationEmailDomains, {
|
||||
this.#insert = buildInsertIntoWithPool(this.pool)(OrganizationJitEmailDomains, {
|
||||
returning: true,
|
||||
});
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ export class EmailDomainQueries {
|
|||
async getEntities(
|
||||
organizationId: string,
|
||||
options: GetEntitiesOptions
|
||||
): Promise<[number, readonly OrganizationEmailDomain[]]> {
|
||||
): Promise<[number, readonly OrganizationJitEmailDomain[]]> {
|
||||
const { limit, offset } = options;
|
||||
const mainSql = sql`
|
||||
from ${table}
|
||||
|
@ -38,7 +38,7 @@ export class EmailDomainQueries {
|
|||
select count(*)
|
||||
${mainSql}
|
||||
`),
|
||||
this.pool.any<OrganizationEmailDomain>(sql`
|
||||
this.pool.any<OrganizationJitEmailDomain>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
${mainSql}
|
||||
${conditionalSql(limit, (limit) => sql`limit ${limit}`)}
|
||||
|
@ -50,7 +50,7 @@ export class EmailDomainQueries {
|
|||
}
|
||||
|
||||
async getOrganizationIdsByDomain(emailDomain: string): Promise<readonly string[]> {
|
||||
const rows = await this.pool.any<Pick<OrganizationEmailDomain, 'organizationId'>>(sql`
|
||||
const rows = await this.pool.any<Pick<OrganizationJitEmailDomain, 'organizationId'>>(sql`
|
||||
select ${fields.organizationId}
|
||||
from ${table}
|
||||
where ${fields.emailDomain} = ${emailDomain}
|
||||
|
@ -58,7 +58,7 @@ export class EmailDomainQueries {
|
|||
return rows.map((row) => row.organizationId);
|
||||
}
|
||||
|
||||
async insert(organizationId: string, emailDomain: string): Promise<OrganizationEmailDomain> {
|
||||
async insert(organizationId: string, emailDomain: string): Promise<OrganizationJitEmailDomain> {
|
||||
return this.#insert({
|
||||
organizationId,
|
||||
emailDomain,
|
||||
|
@ -73,7 +73,7 @@ export class EmailDomainQueries {
|
|||
`);
|
||||
|
||||
if (rowCount < 1) {
|
||||
throw new DeletionError(OrganizationEmailDomains.table);
|
||||
throw new DeletionError(OrganizationJitEmailDomains.table);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ import {
|
|||
Scopes,
|
||||
Resources,
|
||||
Users,
|
||||
OrganizationJitRoles,
|
||||
} from '@logto/schemas';
|
||||
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
|
||||
|
||||
|
@ -290,8 +291,16 @@ export default class OrganizationQueries extends SchemaQueries<
|
|||
),
|
||||
};
|
||||
|
||||
/** Queries for email domains that will be automatically provisioned. */
|
||||
emailDomains = new EmailDomainQueries(this.pool);
|
||||
jit = {
|
||||
/** Queries for email domains that are used for just-in-time provisioning. */
|
||||
emailDomains: new EmailDomainQueries(this.pool),
|
||||
roles: new TwoRelationsQueries(
|
||||
this.pool,
|
||||
OrganizationJitRoles.table,
|
||||
Organizations,
|
||||
OrganizationRoles
|
||||
),
|
||||
};
|
||||
|
||||
constructor(pool: CommonQueryMethods) {
|
||||
super(pool, Organizations);
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/organizations/{id}/email-domains": {
|
||||
"/api/organizations/{id}/jit/email-domains": {
|
||||
"get": {
|
||||
"summary": "Get organization email domains",
|
||||
"summary": "Get organization JIT email domains",
|
||||
"description": "Get email domains for just-in-time provisioning of users in the organization.",
|
||||
"responses": {
|
||||
"200": {
|
||||
|
@ -16,7 +16,7 @@
|
|||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Add organization email domain",
|
||||
"summary": "Add organization JIT email domain",
|
||||
"description": "Add a new email domain for just-in-time provisioning of users in the organization.",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
|
@ -41,7 +41,7 @@
|
|||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Replace organization email domains",
|
||||
"summary": "Replace organization JIT email domains",
|
||||
"description": "Replace all just-in-time provisioning email domains for the organization with the given data.",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
|
@ -63,9 +63,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/organizations/{id}/email-domains/{emailDomain}": {
|
||||
"/api/organizations/{id}/jit/email-domains/{emailDomain}": {
|
||||
"delete": {
|
||||
"summary": "Remove organization email domain",
|
||||
"summary": "Remove organization JIT email domain",
|
||||
"description": "Remove an email domain for just-in-time provisioning of users in the organization.",
|
||||
"parameters": [
|
||||
{
|
|
@ -1,4 +1,4 @@
|
|||
import { OrganizationEmailDomains } from '@logto/schemas';
|
||||
import { OrganizationJitEmailDomains } from '@logto/schemas';
|
||||
import type Router from 'koa-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
@ -12,21 +12,21 @@ export default function emailDomainRoutes(
|
|||
organizations: OrganizationQueries
|
||||
) {
|
||||
const params = Object.freeze({ id: z.string().min(1) });
|
||||
const pathname = '/:id/email-domains';
|
||||
const pathname = '/:id/jit/email-domains';
|
||||
|
||||
router.get(
|
||||
pathname,
|
||||
koaPagination(),
|
||||
koaGuard({
|
||||
params: z.object(params),
|
||||
response: OrganizationEmailDomains.guard.array(),
|
||||
response: OrganizationJitEmailDomains.guard.array(),
|
||||
status: [200],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id } = ctx.guard.params;
|
||||
const { limit, offset } = ctx.pagination;
|
||||
|
||||
const [count, rows] = await organizations.emailDomains.getEntities(id, { limit, offset });
|
||||
const [count, rows] = await organizations.jit.emailDomains.getEntities(id, { limit, offset });
|
||||
ctx.pagination.totalCount = count;
|
||||
ctx.body = rows;
|
||||
return next();
|
||||
|
@ -38,14 +38,14 @@ export default function emailDomainRoutes(
|
|||
koaGuard({
|
||||
params: z.object(params),
|
||||
body: z.object({ emailDomain: z.string().min(1) }),
|
||||
response: OrganizationEmailDomains.guard,
|
||||
response: OrganizationJitEmailDomains.guard,
|
||||
status: [201],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id } = ctx.guard.params;
|
||||
const { emailDomain } = ctx.guard.body;
|
||||
|
||||
ctx.body = await organizations.emailDomains.insert(id, emailDomain);
|
||||
ctx.body = await organizations.jit.emailDomains.insert(id, emailDomain);
|
||||
ctx.status = 201;
|
||||
return next();
|
||||
}
|
||||
|
@ -62,7 +62,7 @@ export default function emailDomainRoutes(
|
|||
const { id } = ctx.guard.params;
|
||||
const { emailDomains } = ctx.guard.body;
|
||||
|
||||
await organizations.emailDomains.replace(id, emailDomains);
|
||||
await organizations.jit.emailDomains.replace(id, emailDomains);
|
||||
ctx.status = 204;
|
||||
return next();
|
||||
}
|
||||
|
@ -77,7 +77,7 @@ export default function emailDomainRoutes(
|
|||
async (ctx, next) => {
|
||||
const { id, emailDomain } = ctx.guard.params;
|
||||
|
||||
await organizations.emailDomains.delete(id, emailDomain);
|
||||
await organizations.jit.emailDomains.delete(id, emailDomain);
|
||||
ctx.status = 204;
|
||||
return next();
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"tags": [
|
||||
{
|
||||
"name": "Organizations"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/organizations/{id}/jit/roles": {
|
||||
"get": {
|
||||
"summary": "Get organization JIT roles",
|
||||
"description": "Get organization roles that will be assigned to users during just-in-time provisioning.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A list of organization roles."
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Add organization JIT role",
|
||||
"description": "Add a new organization role that will be assigned to users during just-in-time provisioning.",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"organizationRoleId": {
|
||||
"description": "The organization role ID to add."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "The organization role was added successfully."
|
||||
},
|
||||
"422": {
|
||||
"description": "The organization role is already in use."
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Replace organization JIT roles",
|
||||
"description": "Replace all organization roles that will be assigned to users during just-in-time provisioning with the given data.",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"organizationRoleIds": {
|
||||
"description": "An array of organization role IDs to replace existing organization roles."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "The organization roles were replaced successfully."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/organizations/{id}/jit/roles/{organizationRoleId}": {
|
||||
"delete": {
|
||||
"summary": "Remove organization JIT role",
|
||||
"description": "Remove an organization role that will be assigned to users during just-in-time provisioning.",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "The organization role was removed successfully."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@ import { parseSearchOptions } from '#src/utils/search.js';
|
|||
|
||||
import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
|
||||
|
||||
import emailDomainRoutes from './index.email-domains.js';
|
||||
import emailDomainRoutes from './index.jit.email-domains.js';
|
||||
import userRoleRelationRoutes from './index.user-role-relations.js';
|
||||
import organizationInvitationRoutes from './invitations.js';
|
||||
import organizationRoleRoutes from './roles.js';
|
||||
|
@ -138,7 +138,10 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
|
|||
);
|
||||
|
||||
userRoleRelationRoutes(router, organizations);
|
||||
|
||||
// MARK: Just-in-time provisioning
|
||||
emailDomainRoutes(router, organizations);
|
||||
router.addRelationRoutes(organizations.jit.roles, 'jit/roles');
|
||||
|
||||
// MARK: Mount sub-routes
|
||||
organizationRoleRoutes(...args);
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
type UserWithOrganizationRoles,
|
||||
type OrganizationWithFeatured,
|
||||
type OrganizationScope,
|
||||
type OrganizationEmailDomain,
|
||||
type OrganizationJitEmailDomain,
|
||||
type CreateOrganization,
|
||||
} from '@logto/schemas';
|
||||
|
||||
|
@ -82,7 +82,7 @@ export class OrganizationApi extends ApiFactory<Organization, Omit<CreateOrganiz
|
|||
id: string,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
): Promise<OrganizationEmailDomain[]> {
|
||||
): Promise<OrganizationJitEmailDomain[]> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (page) {
|
||||
|
@ -94,19 +94,19 @@ export class OrganizationApi extends ApiFactory<Organization, Omit<CreateOrganiz
|
|||
}
|
||||
|
||||
return authedAdminApi
|
||||
.get(`${this.path}/${id}/email-domains`, { searchParams })
|
||||
.json<OrganizationEmailDomain[]>();
|
||||
.get(`${this.path}/${id}/jit/email-domains`, { searchParams })
|
||||
.json<OrganizationJitEmailDomain[]>();
|
||||
}
|
||||
|
||||
async addEmailDomain(id: string, emailDomain: string): Promise<void> {
|
||||
await authedAdminApi.post(`${this.path}/${id}/email-domains`, { json: { emailDomain } });
|
||||
await authedAdminApi.post(`${this.path}/${id}/jit/email-domains`, { json: { emailDomain } });
|
||||
}
|
||||
|
||||
async deleteEmailDomain(id: string, emailDomain: string): Promise<void> {
|
||||
await authedAdminApi.delete(`${this.path}/${id}/email-domains/${emailDomain}`);
|
||||
await authedAdminApi.delete(`${this.path}/${id}/jit/email-domains/${emailDomain}`);
|
||||
}
|
||||
|
||||
async replaceEmailDomains(id: string, emailDomains: string[]): Promise<void> {
|
||||
await authedAdminApi.put(`${this.path}/${id}/email-domains`, { json: { emailDomains } });
|
||||
await authedAdminApi.put(`${this.path}/${id}/jit/email-domains`, { json: { emailDomains } });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,15 +30,16 @@ const organization_details = {
|
|||
membership_policies_description:
|
||||
'Define how users can join this organization and what requirements they must meet for access.',
|
||||
jit: {
|
||||
title: 'Enable just-in-time provisioning',
|
||||
description:
|
||||
'Enable automatic membership assignment based on verified email domains and default roles assignment.',
|
||||
membership_description:
|
||||
'Automatically assign users into this organization when they sign up or are added through the Management API, provided their verified email addresses match the specified domains.',
|
||||
is_enabled_title: 'Enable just-in-time provisioning',
|
||||
email_domain_provisioning: 'Email domain provisioning',
|
||||
'Users can automatically join the organization and receive role assignments if their email matches specific domains, either during sign-up or when added via the Management API.',
|
||||
email_domains: 'JIT provisioning email domains',
|
||||
email_domains_placeholder: 'Enter email domains for just-in-time provisioning',
|
||||
invalid_domain: 'Invalid domain',
|
||||
domain_already_added: 'Domain already added',
|
||||
organization_roles: 'Default organization roles',
|
||||
organization_roles_description:
|
||||
'Assign roles to users upon joining the organization through just-in-time provisioning.',
|
||||
},
|
||||
mfa: {
|
||||
title: 'Multi-factor authentication (MFA)',
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import { sql } from '@silverhand/slonik';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
import { applyTableRls, dropTableRls } from './utils/1704934999-tables.js';
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await pool.query(sql`
|
||||
alter table organization_email_domains rename to organization_jit_email_domains;
|
||||
alter table organization_jit_email_domains
|
||||
rename constraint organization_email_domains_organization_id_fkey to organization_jit_email_domains_organization_id_fkey;
|
||||
alter table organization_jit_email_domains
|
||||
rename constraint organization_email_domains_pkey to organization_jit_email_domains_pkey;
|
||||
alter table organization_jit_email_domains
|
||||
rename constraint organization_email_domains_tenant_id_fkey to organization_jit_email_domains_tenant_id_fkey;
|
||||
alter policy organization_email_domains_modification
|
||||
on organization_jit_email_domains rename to organization_jit_email_domains_modification;
|
||||
alter policy organization_email_domains_tenant_id
|
||||
on organization_jit_email_domains rename to organization_jit_email_domains_tenant_id;
|
||||
create table organization_jit_roles (
|
||||
tenant_id varchar(21) not null
|
||||
references tenants (id) on update cascade on delete cascade,
|
||||
/** The ID of the organization. */
|
||||
organization_id varchar(21) not null
|
||||
references organizations (id) on update cascade on delete cascade,
|
||||
/** The organization role ID that will be automatically provisioned. */
|
||||
organization_role_id varchar(21) not null
|
||||
references organization_roles (id) on update cascade on delete cascade,
|
||||
primary key (tenant_id, organization_id, organization_role_id)
|
||||
);
|
||||
`);
|
||||
await applyTableRls(pool, 'organization_jit_roles');
|
||||
},
|
||||
down: async (pool) => {
|
||||
await dropTableRls(pool, 'organization_jit_roles');
|
||||
await pool.query(sql`
|
||||
drop table organization_jit_roles
|
||||
`);
|
||||
await pool.query(sql`
|
||||
alter table organization_jit_email_domains rename to organization_email_domains;
|
||||
alter table organization_email_domains
|
||||
rename constraint organization_jit_email_domains_organization_id_fkey to organization_email_domains_organization_id_fkey;
|
||||
alter table organization_email_domains
|
||||
rename constraint organization_jit_email_domains_pkey to organization_email_domains_pkey;
|
||||
alter table organization_email_domains
|
||||
rename constraint organization_jit_email_domains_tenant_id_fkey to organization_email_domains_tenant_id_fkey;
|
||||
alter policy organization_jit_email_domains_modification
|
||||
on organization_email_domains rename to organization_email_domains_modification;
|
||||
alter policy organization_jit_email_domains_tenant_id
|
||||
on organization_email_domains rename to organization_email_domains_tenant_id;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -1,7 +1,7 @@
|
|||
/* init_order = 2 */
|
||||
|
||||
/** The email domains that will be automatically provisioned for an organization. */
|
||||
create table organization_email_domains (
|
||||
/** The email domains that will automatically assign users into an organization when they sign up or are added through the Management API. */
|
||||
create table organization_jit_email_domains (
|
||||
tenant_id varchar(21) not null
|
||||
references tenants (id) on update cascade on delete cascade,
|
||||
/** The ID of the organization. */
|
14
packages/schemas/tables/organization_jit_roles.sql
Normal file
14
packages/schemas/tables/organization_jit_roles.sql
Normal file
|
@ -0,0 +1,14 @@
|
|||
/* init_order = 2 */
|
||||
|
||||
/** The organization roles that will be automatically provisioned to users when they join an organization through JIT. */
|
||||
create table organization_jit_roles (
|
||||
tenant_id varchar(21) not null
|
||||
references tenants (id) on update cascade on delete cascade,
|
||||
/** The ID of the organization. */
|
||||
organization_id varchar(21) not null
|
||||
references organizations (id) on update cascade on delete cascade,
|
||||
/** The organization role ID that will be automatically provisioned. */
|
||||
organization_role_id varchar(21) not null
|
||||
references organization_roles (id) on update cascade on delete cascade,
|
||||
primary key (tenant_id, organization_id, organization_role_id)
|
||||
);
|
Loading…
Add table
Reference in a new issue