diff --git a/.changeset/dull-goats-help.md b/.changeset/dull-goats-help.md new file mode 100644 index 000000000..6f920c82a --- /dev/null +++ b/.changeset/dull-goats-help.md @@ -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: '' + }, +}); +``` + +The value `` 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. diff --git a/.changeset/lazy-geese-bow.md b/.changeset/lazy-geese-bow.md new file mode 100644 index 000000000..a8d41162d --- /dev/null +++ b/.changeset/lazy-geese-bow.md @@ -0,0 +1,5 @@ +--- +"@logto/demo-app": minor +--- + +support extra token params in dev panel diff --git a/packages/connectors/connector-mock-social/src/index.ts b/packages/connectors/connector-mock-social/src/index.ts index 77c24b645..1c0f4ec17 100644 --- a/packages/connectors/connector-mock-social/src/index.ts +++ b/packages/connectors/connector-mock-social/src/index.ts @@ -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) => { diff --git a/packages/console/src/hooks/use-user-assets-service.ts b/packages/console/src/hooks/use-user-assets-service.ts index 4ad92bc47..7962ab77c 100644 --- a/packages/console/src/hooks/use-user-assets-service.ts +++ b/packages/console/src/hooks/use-user-assets-service.ts @@ -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, }; diff --git a/packages/console/src/pages/OrganizationDetails/Settings/Branding.tsx b/packages/console/src/pages/OrganizationDetails/Settings/Branding.tsx new file mode 100644 index 000000000..47cc45908 --- /dev/null +++ b/packages/console/src/pages/OrganizationDetails/Settings/Branding.tsx @@ -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; +}; + +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 ; + } + + return ( + +
+ {Object.values(Theme).map((theme) => ( +
+ + {isUserAssetsServiceReady ? ( + ( + + )} + /> + ) : ( + + !value || uriValidator(value) || t('errors.invalid_uri_format'), + })} + error={errors.branding?.[themeToLogoKey[theme]]?.message} + placeholder={t('sign_in_exp.branding.logo_image_url_placeholder')} + /> + )} + +
+ ))} +
+
+ ); +} + +export default Branding; diff --git a/packages/console/src/pages/OrganizationDetails/Settings/index.module.scss b/packages/console/src/pages/OrganizationDetails/Settings/index.module.scss index e3064f255..15444cf5c 100644 --- a/packages/console/src/pages/OrganizationDetails/Settings/index.module.scss +++ b/packages/console/src/pages/OrganizationDetails/Settings/index.module.scss @@ -22,6 +22,12 @@ gap: _.unit(3); } +.branding { + section + section { + margin-top: _.unit(6); + } +} + .mfaWarning { margin-top: _.unit(3); } diff --git a/packages/console/src/pages/OrganizationDetails/Settings/index.tsx b/packages/console/src/pages/OrganizationDetails/Settings/index.tsx index e324157f4..a3e6b2337 100644 --- a/packages/console/src/pages/OrganizationDetails/Settings/index.tsx +++ b/packages/console/src/pages/OrganizationDetails/Settings/index.tsx @@ -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() { )} + diff --git a/packages/console/src/pages/OrganizationDetails/Settings/utils.ts b/packages/console/src/pages/OrganizationDetails/Settings/utils.ts index 502de3f72..23c50d240 100644 --- a/packages/console/src/pages/OrganizationDetails/Settings/utils.ts +++ b/packages/console/src/pages/OrganizationDetails/Settings/utils.ts @@ -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 => ({ ...data, + branding: assembleBranding(branding), // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment customData: JSON.parse(customData ?? '{}'), }); diff --git a/packages/core/src/libraries/sign-in-experience/index.test.ts b/packages/core/src/libraries/sign-in-experience/index.test.ts index 4c43db6f8..a9b3bceeb 100644 --- a/packages/core/src/libraries/sign-in-experience/index.test.ts +++ b/packages/core/src/libraries/sign-in-experience/index.test.ts @@ -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([ { diff --git a/packages/core/src/libraries/sign-in-experience/index.ts b/packages/core/src/libraries/sign-in-experience/index.ts index 63a2cb08b..a50519d42 100644 --- a/packages/core/src/libraries/sign-in-experience/index.ts +++ b/packages/core/src/libraries/sign-in-experience/index.ts @@ -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 => { - 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 | 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 => { + 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, diff --git a/packages/core/src/oidc/utils.ts b/packages/core/src/oidc/utils.ts index ea28a172f..45e07dbba 100644 --- a/packages/core/src/oidc/utils.ts +++ b/packages/core/src/oidc/utils.ts @@ -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(); }; diff --git a/packages/core/src/routes/well-known.ts b/packages/core/src/routes/well-known.ts index 8578d448f..93e771d46 100644 --- a/packages/core/src/routes/well-known.ts +++ b/packages/core/src/routes/well-known.ts @@ -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( 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(); } diff --git a/packages/demo-app/src/App.tsx b/packages/demo-app/src/App.tsx index be4102bbc..5a83a7fe2 100644 --- a/packages/demo-app/src/App.tsx +++ b/packages/demo-app/src/App.tsx @@ -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>(); @@ -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) => { diff --git a/packages/demo-app/src/DevPanel.tsx b/packages/demo-app/src/DevPanel.tsx index 3ec27ea1a..9d3d9f037 100644 --- a/packages/demo-app/src/DevPanel.tsx +++ b/packages/demo-app/src/DevPanel.tsx @@ -48,13 +48,27 @@ const DevPanel = () => {
Logto config
+
+
Sign-in extra params
+ +
Prompt
- +
Scope
- +
Resource (space delimited)
diff --git a/packages/demo-app/src/utils.ts b/packages/demo-app/src/utils.ts index 23eeb569a..8e9556199 100644 --- a/packages/demo-app/src/utils.ts +++ b/packages/demo-app/src/utils.ts @@ -1,25 +1,35 @@ import { Prompt, UserScope } from '@logto/react'; import { z } from 'zod'; +type ToZodObject = z.ZodObject<{ + [K in keyof T]-?: z.ZodType; +}>; + 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; +const localLogtoConfigGuard = z + .object({ + signInExtraParams: z.string(), + prompt: z.string(), + scope: z.string(), + resource: z.string(), + }) + .partial() satisfies ToZodObject; type LocalUiConfig = { showDevPanel?: boolean; }; -const localUiConfigGuard = z.object({ - showDevPanel: z.boolean(), -}) satisfies z.ZodType; +const localUiConfigGuard = z + .object({ + showDevPanel: z.boolean(), + }) + .partial() satisfies ToZodObject; type Key = 'config' | 'ui'; diff --git a/packages/experience/src/apis/settings.ts b/packages/experience/src/apis/settings.ts index fcc2a7b95..cfa001644 100644 --- a/packages/experience/src/apis/settings.ts +++ b/packages/experience/src/apis/settings.ts @@ -23,6 +23,7 @@ export const getSignInExperience = async (): .get('/api/.well-known/sign-in-exp', { searchParams: buildSearchParameters({ [searchKeys.noCache]: sessionStorage.getItem(searchKeys.noCache), + [searchKeys.organizationId]: sessionStorage.getItem(searchKeys.organizationId), }), }) .json(); diff --git a/packages/experience/src/utils/search-parameters.ts b/packages/experience/src/utils/search-parameters.ts index adbac828f..38e66109f 100644 --- a/packages/experience/src/utils/search-parameters.ts +++ b/packages/experience/src/utils/search-parameters.ts @@ -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()}`) + ); }; diff --git a/packages/integration-tests/src/constants.ts b/packages/integration-tests/src/constants.ts index 9b8232ebd..b6fd8e54d 100644 --- a/packages/integration-tests/src/constants.ts +++ b/packages/integration-tests/src/constants.ts @@ -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, diff --git a/packages/integration-tests/src/tests/api/admin-user.test.ts b/packages/integration-tests/src/tests/api/admin-user.test.ts index 5c4a60c4a..9ee8ec7f1 100644 --- a/packages/integration-tests/src/tests/api/admin-user.test.ts +++ b/packages/integration-tests/src/tests/api/admin-user.test.ts @@ -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, diff --git a/packages/integration-tests/src/tests/api/sign-in-experience.test.ts b/packages/integration-tests/src/tests/api/sign-in-experience.test.ts index ec369087a..1a4a359d1 100644 --- a/packages/integration-tests/src/tests/api/sign-in-experience.test.ts +++ b/packages/integration-tests/src/tests/api/sign-in-experience.test.ts @@ -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: [], diff --git a/packages/integration-tests/src/tests/experience/overrides.test.ts b/packages/integration-tests/src/tests/experience/overrides.test.ts new file mode 100644 index 000000000..9c187864a --- /dev/null +++ b/packages/integration-tests/src/tests/experience/overrides.test.ts @@ -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}"]`); + }); +}); 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 e15c948df..e51b3a457 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 @@ -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: diff --git a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/index.ts b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/index.ts index cdc6e007f..d9fdeded9 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/index.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/index.ts @@ -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)', diff --git a/packages/schemas/alterations/next-1720253939-add-organization-branding.ts b/packages/schemas/alterations/next-1720253939-add-organization-branding.ts new file mode 100644 index 000000000..974edec5f --- /dev/null +++ b/packages/schemas/alterations/next-1720253939-add-organization-branding.ts @@ -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; diff --git a/packages/schemas/src/consts/oidc.ts b/packages/schemas/src/consts/oidc.ts index 162280b5c..f5cebc10b 100644 --- a/packages/schemas/src/consts/oidc.ts +++ b/packages/schemas/src/consts/oidc.ts @@ -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:` (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; -export type ExtraParamsObject = z.infer; +export type ExtraParamsObject = Partial<{ + [ExtraParamsKey.InteractionMode]: InteractionMode; + [ExtraParamsKey.FirstScreen]: FirstScreen; + [ExtraParamsKey.DirectSignIn]: string; + [ExtraParamsKey.OrganizationId]: string; +}>; diff --git a/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts b/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts index b8542d986..7a87362f6 100644 --- a/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts +++ b/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts @@ -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; +/** 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); + export const brandingGuard = z.object({ logoUrl: z.string().url().optional(), darkLogoUrl: z.string().url().optional(), diff --git a/packages/schemas/tables/application_sign_in_experiences.sql b/packages/schemas/tables/application_sign_in_experiences.sql index 0eedea00b..b11244314 100644 --- a/packages/schemas/tables/application_sign_in_experiences.sql +++ b/packages/schemas/tables/application_sign_in_experiences.sql @@ -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) ); diff --git a/packages/schemas/tables/organizations.sql b/packages/schemas/tables/organizations.sql index 1cde09f08..a7a1fa12b 100644 --- a/packages/schemas/tables/organizations.sql +++ b/packages/schemas/tables/organizations.sql @@ -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)