0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat: organization logo

This commit is contained in:
Gao Sun 2024-07-07 21:40:26 +08:00
parent 1b7418c29d
commit 3a839f6d60
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
28 changed files with 389 additions and 41 deletions

View file

@ -0,0 +1,33 @@
---
"@logto/experience": minor
"@logto/console": minor
"@logto/core": minor
"@logto/integration-tests": patch
"@logto/phrases": patch
"@logto/schemas": patch
---
support organization logo and sign-in experience override
Now it's able to set light and dark logos for organizations. You can upload the logos in the organization settings page.
Also, it's possible to override the sign-in experience logo from an organization. Simply add the `organization_id` parameter to the authentication request. In most Logto SDKs, it can be done by using the `extraParams` field in the `signIn` method.
For example, in the JavaScript SDK:
```ts
import LogtoClient from '@logto/client';
const logtoClient = new LogtoClient(/* your configuration */);
logtoClient.signIn({
redirectUri: 'https://your-app.com/callback',
extraParams: {
organization_id: '<organization-id>'
},
});
```
The value `<organization-id>` can be found in the organization settings page.
If you could not find the `extraParams` field in the SDK you are using, please let us know.

View file

@ -0,0 +1,5 @@
---
"@logto/demo-app": minor
---
support extra token params in dev panel

View file

@ -30,7 +30,7 @@ const getAuthorizationUri: GetAuthorizationUri = async (
}
}
return `http://mock.social.com/?state=${state}&redirect_uri=${redirectUri}`;
return `http://mock-social/?state=${state}&redirect_uri=${redirectUri}`;
};
const getUserInfo: GetUserInfo = async (data, getSession) => {

View file

@ -9,6 +9,15 @@ import { GlobalRoute } from '@/contexts/TenantsProvider';
import useApi, { useStaticApi, type RequestError } from './use-api';
import useSwrFetcher from './use-swr-fetcher';
/**
* Hook to check if the user assets service (file uploading) is ready.
*
* Caveats: When using it in a form, remember to check `isLoading` first and don't render the form
* until it's settled. Otherwise, the form may be rendered with unexpected behavior, such as
* registering a unexpected validate function. If you really need to render the form while loading,
* you can use the `shouldUnregister` option from `react-hook-form` to unregister the field when
* the component is unmounted.
*/
const useUserAssetsService = () => {
const adminApi = useStaticApi({
prefixUrl: adminTenantEndpoint,
@ -27,6 +36,10 @@ const useUserAssetsService = () => {
);
return {
/**
* Whether the user assets service (file uploading) is ready.
* @see {@link useUserAssetsService} for caveats.
*/
isReady: data?.status === 'ready',
isLoading: !error && !data,
};

View file

@ -0,0 +1,72 @@
import { Theme, themeToLogoKey } from '@logto/schemas';
import { Controller, type UseFormReturn } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import FormCard, { FormCardSkeleton } from '@/components/FormCard';
import FormField from '@/ds-components/FormField';
import TextInput from '@/ds-components/TextInput';
import ImageUploaderField from '@/ds-components/Uploader/ImageUploaderField';
import useUserAssetsService from '@/hooks/use-user-assets-service';
import { uriValidator } from '@/utils/validator';
import * as styles from './index.module.scss';
import { type FormData } from './utils';
type Props = {
readonly form: UseFormReturn<FormData>;
};
function Branding({ form }: Props) {
const { isReady: isUserAssetsServiceReady, isLoading } = useUserAssetsService();
const {
control,
formState: { errors },
register,
} = form;
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
if (isLoading) {
return <FormCardSkeleton />;
}
return (
<FormCard
title="organization_details.branding.title"
description="organization_details.branding.description"
>
<div className={styles.branding}>
{Object.values(Theme).map((theme) => (
<section key={theme}>
<FormField title={`organization_details.branding.${theme}_logo`}>
{isUserAssetsServiceReady ? (
<Controller
control={control}
name={`branding.${themeToLogoKey[theme]}`}
render={({ field: { onChange, value, name } }) => (
<ImageUploaderField
name={name}
value={value ?? ''}
actionDescription={t('organization_details.branding.logo_upload_description')}
onChange={onChange}
/>
)}
/>
) : (
<TextInput
{...register(`branding.${themeToLogoKey[theme]}`, {
validate: (value?: string) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={errors.branding?.[themeToLogoKey[theme]]?.message}
placeholder={t('sign_in_exp.branding.logo_image_url_placeholder')}
/>
)}
</FormField>
</section>
))}
</div>
</FormCard>
);
}
export default Branding;

View file

@ -22,6 +22,12 @@
gap: _.unit(3);
}
.branding {
section + section {
margin-top: _.unit(6);
}
}
.mfaWarning {
margin-top: _.unit(3);
}

View file

@ -20,6 +20,7 @@ import { trySubmitSafe } from '@/utils/form';
import { type OrganizationDetailsOutletContext } from '../types';
import Branding from './Branding';
import JitSettings from './JitSettings';
import * as styles from './index.module.scss';
import { assembleData, isJsonObject, normalizeData, type FormData } from './utils';
@ -136,6 +137,7 @@ function Settings() {
)}
</FormField>
</FormCard>
<Branding form={form} />
<JitSettings form={form} />
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
</DetailsForm>

View file

@ -25,14 +25,24 @@ export const normalizeData = (
customData: JSON.stringify(data.customData, undefined, 2),
});
const assembleBranding = (branding?: Organization['branding']) => {
if (!branding) {
return {};
}
return Object.fromEntries(Object.entries(branding).filter(([, value]) => Boolean(value)));
};
export const assembleData = ({
jitEmailDomains,
jitRoles,
jitSsoConnectorIds,
customData,
branding,
...data
}: FormData): Partial<Organization> => ({
...data,
branding: assembleBranding(branding),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
customData: JSON.parse(customData ?? '{}'),
});

View file

@ -149,7 +149,7 @@ describe('getFullSignInExperience()', () => {
wellConfiguredSsoConnector,
]);
const fullSignInExperience = await getFullSignInExperience('en');
const fullSignInExperience = await getFullSignInExperience({ locale: 'en' });
const connectorFactory = ssoConnectorFactories[wellConfiguredSsoConnector.providerName];
expect(fullSignInExperience).toStrictEqual({
@ -183,7 +183,7 @@ describe('getFullSignInExperience()', () => {
wellConfiguredSsoConnector,
]);
const fullSignInExperience = await getFullSignInExperience('en');
const fullSignInExperience = await getFullSignInExperience({ locale: 'en' });
const connectorFactory = ssoConnectorFactories[wellConfiguredSsoConnector.providerName];
expect(fullSignInExperience).toStrictEqual({
@ -224,7 +224,7 @@ describe('get sso connectors', () => {
singleSignOnEnabled: false,
});
const { ssoConnectors } = await getFullSignInExperience('en');
const { ssoConnectors } = await getFullSignInExperience({ locale: 'en' });
expect(ssoConnectorLibrary.getAvailableSsoConnectors).not.toBeCalled();
@ -239,7 +239,7 @@ describe('get sso connectors', () => {
wellConfiguredSsoConnector,
]);
const { ssoConnectors } = await getFullSignInExperience('jp');
const { ssoConnectors } = await getFullSignInExperience({ locale: 'jp' });
const connectorFactory = ssoConnectorFactories[wellConfiguredSsoConnector.providerName];
@ -270,7 +270,7 @@ describe('get sso connectors', () => {
const connectorFactory = ssoConnectorFactories[wellConfiguredSsoConnector.providerName];
const { ssoConnectors } = await getFullSignInExperience('en');
const { ssoConnectors } = await getFullSignInExperience({ locale: 'en' });
expect(ssoConnectors).toEqual([
{

View file

@ -4,10 +4,12 @@ import type {
ConnectorMetadata,
FullSignInExperience,
LanguageInfo,
SignInExperience,
SsoConnectorMetadata,
} from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { deduplicate } from '@silverhand/essentials';
import { deduplicate, trySafe } from '@silverhand/essentials';
import deepmerge from 'deepmerge';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
@ -37,6 +39,7 @@ export const createSignInExperienceLibrary = (
const {
customPhrases: { findAllCustomLanguageTags },
signInExperiences: { findDefaultSignInExperience, updateDefaultSignInExperience },
organizations,
} = queries;
const validateLanguageInfo = async (languageInfo: LanguageInfo) => {
@ -109,12 +112,38 @@ export const createSignInExperienceLibrary = (
return plan.id === developmentTenantPlanId;
};
const getFullSignInExperience = async (locale: string): Promise<FullSignInExperience> => {
const [signInExperience, logtoConnectors, isDevelopmentTenant] = await Promise.all([
findDefaultSignInExperience(),
getLogtoConnectors(),
getIsDevelopmentTenant(),
]);
/**
* Get the override data for the sign-in experience by reading from organization data. If the
* entity is not found, return `undefined`.
*/
const getOrganizationOverride = async (
organizationId?: string
): Promise<Partial<SignInExperience> | undefined> => {
if (!organizationId) {
return;
}
const organization = await trySafe(organizations.findById(organizationId));
if (!organization?.branding) {
return;
}
return { branding: organization.branding };
};
const getFullSignInExperience = async ({
locale,
organizationId,
}: {
locale: string;
organizationId?: string;
}): Promise<FullSignInExperience> => {
const [signInExperience, logtoConnectors, isDevelopmentTenant, organizationOverride] =
await Promise.all([
findDefaultSignInExperience(),
getLogtoConnectors(),
getIsDevelopmentTenant(),
getOrganizationOverride(organizationId),
]);
// Always return empty array if single-sign-on is disabled
const ssoConnectors = signInExperience.singleSignOnEnabled
@ -167,7 +196,7 @@ export const createSignInExperienceLibrary = (
};
return {
...signInExperience,
...deepmerge(signInExperience, organizationOverride ?? {}),
socialConnectors,
ssoConnectors,
forgotPassword,

View file

@ -101,5 +101,11 @@ export const buildLoginPromptUrl = (params: ExtraParamsObject, appId?: unknown):
return path.join('direct', method ?? '', target ?? '') + getSearchParamString();
}
// Append other valid params as-is
const { first_screen: _, interaction_mode: __, direct_sign_in: ___, ...rest } = params;
for (const [key, value] of Object.entries(rest)) {
searchParams.append(key, value);
}
return firstScreen + getSearchParamString();
};

View file

@ -1,5 +1,5 @@
import { isBuiltInLanguageTag } from '@logto/phrases-experience';
import { adminTenantId, guardFullSignInExperience } from '@logto/schemas';
import { ExtraParamsKey, adminTenantId, guardFullSignInExperience } from '@logto/schemas';
import { conditionalArray } from '@silverhand/essentials';
import { z } from 'zod';
@ -41,9 +41,14 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
router.get(
'/.well-known/sign-in-exp',
koaGuard({ response: guardFullSignInExperience, status: 200 }),
koaGuard({
query: z.object({ [ExtraParamsKey.OrganizationId]: z.string().optional() }),
response: guardFullSignInExperience,
status: 200,
}),
async (ctx, next) => {
ctx.body = await getFullSignInExperience(ctx.locale);
const { [ExtraParamsKey.OrganizationId]: organizationId } = ctx.guard.query;
ctx.body = await getFullSignInExperience({ locale: ctx.locale, organizationId });
return next();
}

View file

@ -16,6 +16,7 @@ import { getLocalData, setLocalData } from './utils';
void initI18n();
const Main = () => {
const config = getLocalData('config');
const params = new URL(window.location.href).searchParams;
const { isAuthenticated, isLoading, getIdTokenClaims, signIn, signOut } = useLogto();
const [user, setUser] = useState<Pick<IdTokenClaims, 'sub' | 'username'>>();
@ -53,10 +54,24 @@ const Main = () => {
if (!isAuthenticated) {
void signIn({
redirectUri: window.location.origin + window.location.pathname,
extraParams: Object.fromEntries(new URLSearchParams(window.location.search).entries()),
extraParams: Object.fromEntries(
new URLSearchParams([
...new URLSearchParams(config.signInExtraParams).entries(),
...new URLSearchParams(window.location.search).entries(),
]).entries()
),
});
}
}, [error, getIdTokenClaims, isAuthenticated, isInCallback, isLoading, signIn, user]);
}, [
config.signInExtraParams,
error,
getIdTokenClaims,
isAuthenticated,
isInCallback,
isLoading,
signIn,
user,
]);
useEffect(() => {
const onThemeChange = (event: MediaQueryListEvent) => {

View file

@ -48,13 +48,27 @@ const DevPanel = () => {
<div className={[styles.card, styles.devPanel].join(' ')}>
<form onSubmit={submitConfig}>
<div className={styles.title}>Logto config</div>
<div className={styles.item}>
<div className={styles.text}>Sign-in extra params</div>
<input
name="signInExtraParams"
defaultValue={config.signInExtraParams}
type="text"
placeholder="foo=bar&baz=qux"
/>
</div>
<div className={styles.item}>
<div className={styles.text}>Prompt</div>
<input name="prompt" defaultValue={config.prompt} type="text" />
<input
name="prompt"
defaultValue={config.prompt}
type="text"
placeholder="login consent"
/>
</div>
<div className={styles.item}>
<div className={styles.text}>Scope</div>
<input name="scope" defaultValue={config.scope} type="text" />
<input name="scope" defaultValue={config.scope} type="text" placeholder="foo bar" />
</div>
<div className={styles.item}>
<div className={styles.text}>Resource (space delimited)</div>

View file

@ -1,25 +1,35 @@
import { Prompt, UserScope } from '@logto/react';
import { z } from 'zod';
type ToZodObject<T> = z.ZodObject<{
[K in keyof T]-?: z.ZodType<T[K]>;
}>;
type LocalLogtoConfig = {
signInExtraParams?: string;
prompt?: string;
scope?: string;
resource?: string;
};
const localLogtoConfigGuard = z.object({
prompt: z.string(),
scope: z.string(),
resource: z.string(),
}) satisfies z.ZodType<LocalLogtoConfig>;
const localLogtoConfigGuard = z
.object({
signInExtraParams: z.string(),
prompt: z.string(),
scope: z.string(),
resource: z.string(),
})
.partial() satisfies ToZodObject<LocalLogtoConfig>;
type LocalUiConfig = {
showDevPanel?: boolean;
};
const localUiConfigGuard = z.object({
showDevPanel: z.boolean(),
}) satisfies z.ZodType<LocalUiConfig>;
const localUiConfigGuard = z
.object({
showDevPanel: z.boolean(),
})
.partial() satisfies ToZodObject<LocalUiConfig>;
type Key = 'config' | 'ui';

View file

@ -23,6 +23,7 @@ export const getSignInExperience = async <T extends SignInExperienceResponse>():
.get('/api/.well-known/sign-in-exp', {
searchParams: buildSearchParameters({
[searchKeys.noCache]: sessionStorage.getItem(searchKeys.noCache),
[searchKeys.organizationId]: sessionStorage.getItem(searchKeys.organizationId),
}),
})
.json<T>();

View file

@ -1,5 +1,11 @@
import { condString } from '@silverhand/essentials';
export const searchKeys = Object.freeze({
noCache: 'no_cache',
/**
* The key for specifying the organization ID that may be used to override the default settings.
*/
organizationId: 'organization_id',
});
export const handleSearchParametersData = () => {
@ -9,9 +15,22 @@ export const handleSearchParametersData = () => {
return;
}
// TODO: will refactor soon
const parameters = new URLSearchParams(search);
if (parameters.get(searchKeys.noCache) !== null) {
sessionStorage.setItem(searchKeys.noCache, 'true');
}
const organizationId = parameters.get(searchKeys.organizationId);
if (organizationId) {
sessionStorage.setItem(searchKeys.organizationId, organizationId);
parameters.delete(searchKeys.organizationId);
}
window.history.replaceState(
{},
'',
window.location.pathname + condString(parameters.size > 0 && `?${parameters.toString()}`)
);
};

View file

@ -30,7 +30,7 @@ export const signUpIdentifiers = {
export const consoleUsername = 'svhd';
export const consolePassword = 'silverhandasd_1';
export const mockSocialAuthPageUrl = 'http://mock.social.com';
export const mockSocialAuthPageUrl = 'http://mock-social';
export const newOidcSsoConnectorPayload = {
providerName: SsoProviderName.OIDC,

View file

@ -212,7 +212,7 @@ describe('admin console user management', () => {
});
const state = 'random_state';
const redirectUri = 'http://mock.social.com/callback/random_string';
const redirectUri = 'http://mock-social/callback/random_string';
const code = 'random_code_from_social';
const socialUserId = 'social_platform_user_id_' + randomString();
const socialUserEmail = `johndoe_${randomString()}@gmail.com`;
@ -229,7 +229,7 @@ describe('admin console user management', () => {
const { id: userId } = await createUserByAdmin();
const { redirectTo } = await getConnectorAuthorizationUri(connectorId, state, redirectUri);
expect(redirectTo).toBe(`http://mock.social.com/?state=${state}&redirect_uri=${redirectUri}`);
expect(redirectTo).toBe(`http://mock-social/?state=${state}&redirect_uri=${redirectUri}`);
const identities = await postUserIdentity(userId, connectorId, {
code,

View file

@ -18,11 +18,11 @@ describe('admin console sign-in experience', () => {
isDarkModeEnabled: true,
},
branding: {
logoUrl: 'https://logto.io/new-logo.png',
darkLogoUrl: 'https://logto.io/new-dark-logo.png',
logoUrl: 'mock://fake-url/logo.png',
darkLogoUrl: 'mock://fake-url/dark-logo.png',
},
termsOfUseUrl: 'https://logto.io/terms',
privacyPolicyUrl: 'https://logto.io/privacy',
termsOfUseUrl: 'mock://fake-url/terms',
privacyPolicyUrl: 'mock://fake-url/privacy',
mfa: {
policy: MfaPolicy.UserControlled,
factors: [],

View file

@ -0,0 +1,62 @@
/**
* @fileoverview Tests for overriding sign-in experience settings via `override` parameter.
*/
import { ConnectorType } from '@logto/connector-kit';
import { SignInIdentifier } from '@logto/schemas';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { demoAppUrl } from '#src/constants.js';
import { clearConnectorsByTypes } from '#src/helpers/connector.js';
import { OrganizationApiTest } from '#src/helpers/organization.js';
import ExpectExperience from '#src/ui-helpers/expect-experience.js';
describe('override', () => {
const organizationApi = new OrganizationApiTest();
afterEach(async () => {
await organizationApi.cleanUp();
});
beforeAll(async () => {
await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email, ConnectorType.Sms]);
await updateSignInExperience({
termsOfUseUrl: null,
privacyPolicyUrl: null,
color: { primaryColor: '#000', darkPrimaryColor: '#fff', isDarkModeEnabled: true },
signUp: { identifiers: [], password: true, verify: false },
signIn: {
methods: [
{
identifier: SignInIdentifier.Username,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
],
},
});
});
it('should show the overridden organization logos', async () => {
const logoUrl = 'mock://fake-url/logo.png';
const darkLogoUrl = 'mock://fake-url/dark-logo.png';
const organization = await organizationApi.create({
name: 'override-organization',
branding: {
logoUrl,
darkLogoUrl,
},
});
const experience = new ExpectExperience(await browser.newPage());
await experience.page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'light' }]);
await experience.navigateTo(demoAppUrl.href + `?organization_id=${organization.id}`);
await experience.toMatchElement(`img[src="${logoUrl}"]`);
await experience.page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
await experience.navigateTo(demoAppUrl.href + `?organization_id=${organization.id}`);
await experience.toMatchElement(`img[src="${darkLogoUrl}"]`);
});
});

View file

@ -38,6 +38,14 @@ 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.',
branding: {
title: 'Branding',
description:
'Customize the branding of the organization. The branding can be used in the sign-in experience or for your own reference.',
light_logo: 'Organization logo',
dark_logo: 'Organization logo (dark)',
logo_upload_description: 'Click or drop an image to upload',
},
jit: {
title: 'Just-in-time provisioning',
description:

View file

@ -23,7 +23,7 @@ const sign_in_exp = {
color: {
title: 'COLOR',
primary_color: 'Brand color',
dark_primary_color: 'Brand color (Dark)',
dark_primary_color: 'Brand color (dark)',
dark_mode: 'Enable dark mode',
dark_mode_description:
'Your app will have an auto-generated dark mode theme based on your brand color and Logto algorithm. You are free to customize.',
@ -36,7 +36,7 @@ const sign_in_exp = {
favicon: 'Favicon',
logo_image_url: 'App logo image URL',
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
dark_logo_image_url: 'App logo image URL (Dark)',
dark_logo_image_url: 'App logo image URL (dark)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'App logo',
dark_logo_image: 'App logo (Dark)',

View file

@ -0,0 +1,18 @@
import { sql } from '@silverhand/slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
alter table organizations add column branding jsonb not null default '{}'::jsonb;
`);
},
down: async (pool) => {
await pool.query(sql`
alter table organizations drop column branding;
`);
},
};
export default alteration;

View file

@ -1,6 +1,7 @@
import { z } from 'zod';
import { type CustomClientMetadata } from '../foundations/index.js';
import { type ToZodObject } from '../utils/zod.js';
import { inSeconds } from './date.js';
@ -35,6 +36,11 @@ export enum ExtraParamsKey {
* - `sso:<connector-id>` (Use the specified SSO connector, e.g. `sso:123456`)
*/
DirectSignIn = 'direct_sign_in',
/**
* Override the default sign-in experience configuration with the settings from the specified
* organization ID.
*/
OrganizationId = 'organization_id',
}
/** @deprecated Use {@link FirstScreen} instead. */
@ -53,7 +59,13 @@ export const extraParamsObjectGuard = z
[ExtraParamsKey.InteractionMode]: z.nativeEnum(InteractionMode),
[ExtraParamsKey.FirstScreen]: z.nativeEnum(FirstScreen),
[ExtraParamsKey.DirectSignIn]: z.string(),
[ExtraParamsKey.OrganizationId]: z.string(),
})
.partial();
.partial() satisfies ToZodObject<ExtraParamsObject>;
export type ExtraParamsObject = z.infer<typeof extraParamsObjectGuard>;
export type ExtraParamsObject = Partial<{
[ExtraParamsKey.InteractionMode]: InteractionMode;
[ExtraParamsKey.FirstScreen]: FirstScreen;
[ExtraParamsKey.DirectSignIn]: string;
[ExtraParamsKey.OrganizationId]: string;
}>;

View file

@ -2,6 +2,7 @@ import { hexColorRegEx } from '@logto/core-kit';
import { languageTagGuard } from '@logto/language-kit';
import { z } from 'zod';
import { Theme } from '../../types/theme.js';
import { type ToZodObject } from '../../utils/zod.js';
export const colorGuard = z.object({
@ -12,6 +13,12 @@ export const colorGuard = z.object({
export type Color = z.infer<typeof colorGuard>;
/** Maps a theme to the key of the logo URL in the {@link Branding} object. */
export const themeToLogoKey = Object.freeze({
[Theme.Light]: 'logoUrl',
[Theme.Dark]: 'darkLogoUrl',
} satisfies Record<Theme, keyof Branding>);
export const brandingGuard = z.object({
logoUrl: z.string().url().optional(),
darkLogoUrl: z.string().url().optional(),

View file

@ -10,6 +10,5 @@ create table application_sign_in_experiences (
terms_of_use_url varchar(2048),
privacy_policy_url varchar(2048),
display_name varchar(256),
primary key (tenant_id, application_id)
);

View file

@ -14,6 +14,8 @@ create table organizations (
custom_data jsonb /* @use JsonObject */ not null default '{}'::jsonb,
/** Whether multi-factor authentication configuration is required for the members of the organization. */
is_mfa_required boolean not null default false,
/** The organization's branding configuration. */
branding jsonb /* @use Branding */ not null default '{}'::jsonb,
/** When the organization was created. */
created_at timestamptz not null default(now()),
primary key (id)