0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat: support app-level branding

This commit is contained in:
Gao Sun 2024-07-08 16:52:15 +08:00
parent a6f96f1d8d
commit 4a8b7c0648
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
30 changed files with 398 additions and 128 deletions

View file

@ -55,6 +55,7 @@
"topbar",
"upsell",
"withtyped",
"backchannel"
"backchannel",
"deepmerge"
]
}

View file

@ -60,6 +60,7 @@
display: flex;
align-items: center;
gap: _.unit(1);
user-select: none;
}
&.disabled {

View file

@ -9,11 +9,12 @@ import Dropdown from '../Dropdown';
import * as styles from './index.module.scss';
type Props = {
readonly name?: string;
readonly value?: string;
readonly onChange: (value: string) => void;
};
function ColorPicker({ onChange, value = '#000000' }: Props) {
function ColorPicker({ name, onChange, value = '#000000' }: Props) {
const anchorRef = useRef<HTMLSpanElement>(null);
const [isOpen, setIsOpen] = useState(false);
@ -29,6 +30,7 @@ function ColorPicker({ onChange, value = '#000000' }: Props) {
setIsOpen(true);
})}
>
<input hidden readOnly name={name} value={value} />
<span ref={anchorRef} className={styles.brick} style={{ backgroundColor: value }} />
<span>{value.toUpperCase()}</span>
<Dropdown

View file

@ -31,7 +31,7 @@ function LogoUploader({ isDarkModeEnabled }: Props) {
className={isDarkModeEnabled ? styles.multiColumn : undefined}
name={name}
value={value ?? ''}
actionDescription={t('sign_in_exp.branding.logo_image_url')}
actionDescription={t('sign_in_exp.branding.logo_image')}
onCompleted={onChange}
onUploadErrorChange={setUploadLogoError}
onDelete={() => {
@ -50,7 +50,7 @@ function LogoUploader({ isDarkModeEnabled }: Props) {
name={name}
value={value ?? ''}
className={value ? styles.darkMode : undefined}
actionDescription={t('sign_in_exp.branding.dark_logo_image_url')}
actionDescription={t('sign_in_exp.branding.dark_logo_image')}
onCompleted={onChange}
onUploadErrorChange={setUploadDarkLogoError}
onDelete={() => {

View file

@ -0,0 +1,5 @@
@use '@/scss/underscore' as _;
.colors {
margin-top: _.unit(6);
}

View file

@ -1,6 +1,6 @@
import { type Application, type ApplicationSignInExperience } from '@logto/schemas';
import { useCallback, useEffect } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { useForm, FormProvider, Controller } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
@ -9,6 +9,8 @@ import FormCard, { FormCardSkeleton } from '@/components/FormCard';
import RequestDataError from '@/components/RequestDataError';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { logtoThirdPartyAppBrandingLink } from '@/consts';
import Checkbox from '@/ds-components/Checkbox';
import ColorPicker from '@/ds-components/ColorPicker';
import FormField from '@/ds-components/FormField';
import TextInput from '@/ds-components/TextInput';
import useApi from '@/hooks/use-api';
@ -18,6 +20,7 @@ import { trySubmitSafe } from '@/utils/form';
import { uriValidator } from '@/utils/validator';
import LogoUploader from './LogoUploader';
import * as styles from './index.module.scss';
import useApplicationSignInExperienceSWR from './use-application-sign-in-experience-swr';
import useSignInExperienceSWR from './use-sign-in-experience-swr';
import { formatFormToSubmitData, formatResponseDataToForm } from './utils';
@ -36,6 +39,7 @@ function Branding({ application, isActive }: Props) {
tenantId: application.tenantId,
applicationId: application.id,
branding: {},
color: {},
},
});
@ -43,6 +47,9 @@ function Branding({ application, isActive }: Props) {
handleSubmit,
register,
reset,
setValue,
watch,
control,
formState: { isDirty, isSubmitting, errors },
} = formMethods;
@ -56,6 +63,8 @@ function Branding({ application, isActive }: Props) {
const isApplicationSieLoading = !data && !error;
const isSieLoading = !sieData && !sieError;
const isLoading = isApplicationSieLoading || isSieLoading || isUserAssetsServiceLoading;
const color = watch('color');
const isColorEmpty = !color.primaryColor && !color.darkPrimaryColor;
const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
@ -91,7 +100,6 @@ function Branding({ application, isActive }: Props) {
return <FormCardSkeleton />;
}
// Show error details if the error is not 404
if (error && error.status !== 404) {
return <RequestDataError error={error} onRetry={onRetryFetch} />;
}
@ -109,23 +117,31 @@ function Branding({ application, isActive }: Props) {
>
<FormCard
title="application_details.branding.name"
description="application_details.branding.description"
learnMoreLink={{
href: getDocumentationUrl(logtoThirdPartyAppBrandingLink),
targetBlank: 'noopener',
}}
description={`application_details.branding.${
application.isThirdParty ? 'description_third_party' : 'description'
}`}
learnMoreLink={
application.isThirdParty
? {
href: getDocumentationUrl(logtoThirdPartyAppBrandingLink),
targetBlank: 'noopener',
}
: undefined
}
>
<FormField title="application_details.branding.display_name">
<TextInput {...register('displayName')} placeholder={application.name} />
</FormField>
{application.isThirdParty && (
<FormField title="application_details.branding.display_name">
<TextInput {...register('displayName')} placeholder={application.name} />
</FormField>
)}
{isUserAssetsServiceReady && (
<FormField title="application_details.branding.display_logo">
<FormField title="application_details.branding.application_logo">
<LogoUploader isDarkModeEnabled={isDarkModeEnabled} />
</FormField>
)}
{/* Display the TextInput field if image upload service is not available */}
{!isUserAssetsServiceReady && (
<FormField title="application_details.branding.display_logo">
<FormField title="application_details.branding.application_logo">
<TextInput
{...register('branding.logoUrl', {
validate: (value) =>
@ -138,7 +154,7 @@ function Branding({ application, isActive }: Props) {
)}
{/* Display the Dark logo field only if the dark mode is enabled in the global sign-in-experience */}
{!isUserAssetsServiceReady && isDarkModeEnabled && (
<FormField title="application_details.branding.display_logo_dark">
<FormField title="application_details.branding.application_logo_dark">
<TextInput
{...register('branding.darkLogoUrl', {
validate: (value) =>
@ -149,32 +165,76 @@ function Branding({ application, isActive }: Props) {
/>
</FormField>
)}
{!application.isThirdParty && (
<div className={styles.colors}>
<Checkbox
label={t('application_details.branding.use_different_brand_color')}
checked={!isColorEmpty}
onChange={(value) => {
setValue(
'color',
value
? {
primaryColor: '#ffffff',
darkPrimaryColor: '#000000',
}
: {},
{ shouldDirty: true }
);
}}
/>
{!isColorEmpty && (
<>
<Controller
control={control}
name="color.primaryColor"
render={({ field: { name, value, onChange } }) => (
<FormField title="application_details.branding.brand_color">
<ColorPicker name={name} value={value} onChange={onChange} />
</FormField>
)}
/>
<Controller
control={control}
name="color.darkPrimaryColor"
render={({ field: { name, value, onChange } }) => (
<FormField title="application_details.branding.brand_color_dark">
<ColorPicker name={name} value={value} onChange={onChange} />
</FormField>
)}
/>
</>
)}
</div>
)}
</FormCard>
<FormCard
title="application_details.branding.more_info"
description="application_details.branding.more_info_description"
>
<FormField title="application_details.branding.terms_of_use_url">
<TextInput
{...register('termsOfUseUrl', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={errors.termsOfUseUrl?.message}
placeholder="https://"
/>
</FormField>
<FormField title="application_details.branding.privacy_policy_url">
<TextInput
{...register('privacyPolicyUrl', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={errors.privacyPolicyUrl?.message}
placeholder="https://"
/>
</FormField>
</FormCard>
{application.isThirdParty && (
<FormCard
title="application_details.branding.more_info"
description="application_details.branding.more_info_description"
>
<FormField title="application_details.branding.terms_of_use_url">
<TextInput
{...register('termsOfUseUrl', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={errors.termsOfUseUrl?.message}
placeholder="https://"
/>
</FormField>
<FormField title="application_details.branding.privacy_policy_url">
<TextInput
{...register('privacyPolicyUrl', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={errors.privacyPolicyUrl?.message}
placeholder="https://"
/>
</FormField>
</FormCard>
)}
</DetailsForm>
</FormProvider>
{isActive && <UnsavedChangesAlertModal hasUnsavedChanges={isDirty} onConfirm={reset} />}

View file

@ -185,14 +185,16 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
</>
)}
{data.isThirdParty && (
<>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Permissions}`}>
{t('application_details.permissions.name')}
</TabNavItem>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Branding}`}>
{t('application_details.branding.name')}
</TabNavItem>
</>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Permissions}`}>
{t('application_details.permissions.name')}
</TabNavItem>
)}
{[ApplicationType.Native, ApplicationType.SPA, ApplicationType.Traditional].includes(
data.type
) && (
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Branding}`}>
{t('application_details.branding.name')}
</TabNavItem>
)}
</TabNav>
<TabWrapper
@ -257,21 +259,23 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
</>
)}
{data.isThirdParty && (
<>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Permissions}
className={styles.tabContainer}
>
<Permissions application={data} />
</TabWrapper>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Branding}
className={styles.tabContainer}
>
{/* isActive is needed to support conditional render UnsavedChangesAlertModal */}
<Branding application={data} isActive={tab === ApplicationDetailsTabs.Branding} />
</TabWrapper>
</>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Permissions}
className={styles.tabContainer}
>
<Permissions application={data} />
</TabWrapper>
)}
{[ApplicationType.Native, ApplicationType.SPA, ApplicationType.Traditional].includes(
data.type
) && (
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Branding}
className={styles.tabContainer}
>
{/* isActive is needed to support conditional render UnsavedChangesAlertModal */}
<Branding application={data} isActive={tab === ApplicationDetailsTabs.Branding} />
</TabWrapper>
)}
</>
);

View file

@ -13,6 +13,8 @@ type WellKnownMap = {
'custom-phrases': Record<string, unknown>;
'custom-phrases-tags': string[];
'tenant-cache-expires-at': number;
// Currently, tenant type cannot be updated once created. So it's safe to cache.
'is-developer-tenant': boolean;
};
type WellKnownCacheType = keyof WellKnownMap;
@ -56,6 +58,9 @@ function getValueGuard(type: WellKnownCacheType): ZodType<WellKnownMap[typeof ty
case 'tenant-cache-expires-at': {
return z.number();
}
case 'is-developer-tenant': {
return z.boolean();
}
}
}

View file

@ -1,6 +1,7 @@
import type { LanguageTag } from '@logto/language-kit';
import { builtInLanguages } from '@logto/phrases-experience';
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import { TtlCache } from '@logto/shared';
import {
mockGithubConnector,
@ -11,6 +12,7 @@ import {
socialTarget02,
wellConfiguredSsoConnector,
} from '#src/__mocks__/index.js';
import { WellKnownCache } from '#src/caches/well-known.js';
import RequestError from '#src/errors/RequestError/index.js';
import { ssoConnectorFactories } from '#src/sso/index.js';
import { mockLogtoConfigsLibrary } from '#src/test-utils/mock-libraries.js';
@ -66,7 +68,13 @@ const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors');
const { createSignInExperienceLibrary } = await import('./index.js');
const { validateLanguageInfo, removeUnavailableSocialConnectorTargets, getFullSignInExperience } =
createSignInExperienceLibrary(queries, connectorLibrary, ssoConnectorLibrary, cloudConnection);
createSignInExperienceLibrary(
queries,
connectorLibrary,
ssoConnectorLibrary,
cloudConnection,
new WellKnownCache('foo', new TtlCache())
);
beforeEach(() => {
jest.clearAllMocks();

View file

@ -8,9 +8,10 @@ import type {
SsoConnectorMetadata,
} from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { deduplicate, trySafe } from '@silverhand/essentials';
import { deduplicate, pick, trySafe } from '@silverhand/essentials';
import deepmerge from 'deepmerge';
import { type WellKnownCache } from '#src/caches/well-known.js';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import type { ConnectorLibrary } from '#src/libraries/connector.js';
@ -34,11 +35,13 @@ export const createSignInExperienceLibrary = (
queries: Queries,
{ getLogtoConnectors }: ConnectorLibrary,
{ getAvailableSsoConnectors }: SsoConnectorLibrary,
cloudConnection: CloudConnectionLibrary
cloudConnection: CloudConnectionLibrary,
wellKnownCache: WellKnownCache
) => {
const {
customPhrases: { findAllCustomLanguageTags },
signInExperiences: { findDefaultSignInExperience, updateDefaultSignInExperience },
applicationSignInExperiences: { safeFindSignInExperienceByApplicationId },
organizations,
} = queries;
@ -93,8 +96,11 @@ export const createSignInExperienceLibrary = (
/**
* Query the tenant subscription plan to determine if the tenant is a development tenant.
*
* **Caveats**: The result will be cached without updating mechanism since the tenant type is
* not editable at the moment.
*/
const getIsDevelopmentTenant = async (): Promise<boolean> => {
const getIsDevelopmentTenant = wellKnownCache.memoize(async (): Promise<boolean> => {
const { isCloud, isIntegrationTest } = EnvSet.values;
// Cloud only feature, return false in non-cloud environments
@ -110,7 +116,7 @@ export const createSignInExperienceLibrary = (
const plan = await getTenantSubscriptionPlan(cloudConnection);
return plan.id === developmentTenantPlanId;
};
}, ['is-developer-tenant']);
/**
* Get the override data for the sign-in experience by reading from organization data. If the
@ -130,20 +136,42 @@ export const createSignInExperienceLibrary = (
return { branding: organization.branding };
};
const findApplicationSignInExperience = async (appId?: string) => {
if (!appId) {
return;
}
const found = await safeFindSignInExperienceByApplicationId(appId);
if (!found) {
return;
}
return pick(found, 'branding', 'color');
};
const getFullSignInExperience = async ({
locale,
organizationId,
appId,
}: {
locale: string;
organizationId?: string;
appId?: string;
}): Promise<FullSignInExperience> => {
const [signInExperience, logtoConnectors, isDevelopmentTenant, organizationOverride] =
await Promise.all([
findDefaultSignInExperience(),
getLogtoConnectors(),
getIsDevelopmentTenant(),
getOrganizationOverride(organizationId),
]);
const [
signInExperience,
logtoConnectors,
isDevelopmentTenant,
organizationOverride,
appSignInExperience,
] = await Promise.all([
findDefaultSignInExperience(),
getLogtoConnectors(),
getIsDevelopmentTenant(),
getOrganizationOverride(organizationId),
findApplicationSignInExperience(appId),
]);
// Always return empty array if single-sign-on is disabled
const ssoConnectors = signInExperience.singleSignOnEnabled
@ -196,7 +224,10 @@ export const createSignInExperienceLibrary = (
};
return {
...deepmerge(signInExperience, organizationOverride ?? {}),
...deepmerge(
deepmerge(signInExperience, appSignInExperience ?? {}),
organizationOverride ?? {}
),
socialConnectors,
ssoConnectors,
forgotPassword,

View file

@ -197,6 +197,7 @@ export default function initOidc(
},
interactions: {
url: (ctx, { params: { client_id: appId }, prompt }) => {
// @deprecated use search params instead
ctx.cookies.set(
logtoCookieKey,
JSON.stringify({

View file

@ -133,15 +133,19 @@ describe('isOriginAllowed', () => {
describe('buildLoginPromptUrl', () => {
it('should return the correct url for empty parameters', () => {
expect(buildLoginPromptUrl({})).toBe('sign-in');
expect(buildLoginPromptUrl({}, 'foo')).toBe('sign-in');
expect(buildLoginPromptUrl({}, demoAppApplicationId)).toBe('sign-in');
expect(buildLoginPromptUrl({}, 'foo')).toBe('sign-in?app_id=foo');
expect(buildLoginPromptUrl({}, demoAppApplicationId)).toBe(
'sign-in?app_id=' + demoAppApplicationId
);
});
it('should return the correct url for firstScreen', () => {
expect(buildLoginPromptUrl({ first_screen: FirstScreen.Register })).toBe('register');
expect(buildLoginPromptUrl({ first_screen: FirstScreen.Register }, 'foo')).toBe('register');
expect(buildLoginPromptUrl({ first_screen: FirstScreen.Register }, 'foo')).toBe(
'register?app_id=foo'
);
expect(buildLoginPromptUrl({ first_screen: FirstScreen.SignIn }, demoAppApplicationId)).toBe(
'sign-in'
'sign-in?app_id=demo-app'
);
// Legacy interactionMode support
expect(buildLoginPromptUrl({ interaction_mode: InteractionMode.SignUp })).toBe('register');
@ -152,10 +156,10 @@ describe('buildLoginPromptUrl', () => {
'direct/method/target?fallback=sign-in'
);
expect(buildLoginPromptUrl({ direct_sign_in: 'method:target' }, 'foo')).toBe(
'direct/method/target?fallback=sign-in'
'direct/method/target?app_id=foo&fallback=sign-in'
);
expect(buildLoginPromptUrl({ direct_sign_in: 'method:target' }, demoAppApplicationId)).toBe(
'direct/method/target?fallback=sign-in'
'direct/method/target?app_id=demo-app&fallback=sign-in'
);
expect(buildLoginPromptUrl({ direct_sign_in: 'method' })).toBe(
'direct/method?fallback=sign-in'
@ -172,6 +176,6 @@ describe('buildLoginPromptUrl', () => {
{ first_screen: FirstScreen.Register, direct_sign_in: 'method:target' },
demoAppApplicationId
)
).toBe('direct/method/target?fallback=register');
).toBe('direct/method/target?app_id=demo-app&fallback=register');
});
});

View file

@ -90,6 +90,10 @@ export const buildLoginPromptUrl = (params: ExtraParamsObject, appId?: unknown):
const searchParams = new URLSearchParams();
const getSearchParamString = () => (searchParams.size > 0 ? `?${searchParams.toString()}` : '');
if (appId) {
searchParams.append('app_id', String(appId));
}
if (directSignIn) {
searchParams.append('fallback', firstScreen);
const [method, target] = directSignIn.split(':');

View file

@ -21,9 +21,6 @@ function applicationSignInExperienceRoutes<T extends ManagementApiRouter>(
updateByApplicationId,
},
},
libraries: {
applications: { validateThirdPartyApplicationById },
},
},
]: RouterInitArgs<T>
) {
@ -31,7 +28,6 @@ function applicationSignInExperienceRoutes<T extends ManagementApiRouter>(
* Customize the branding of an application.
*
* - Only branding and terms links customization is supported for now. e.g. per app level sign-in method customization is not supported.
* - Only third-party applications can be customized for now.
* - Application level sign-in experience customization is optional, if provided, it will override the default branding and terms links.
* - We use application ID as the unique identifier of the application level sign-in experience ID.
*/
@ -51,7 +47,7 @@ function applicationSignInExperienceRoutes<T extends ManagementApiRouter>(
body,
} = ctx.guard;
await validateThirdPartyApplicationById(applicationId);
await findApplicationById(applicationId);
const applicationSignInExperience = await safeFindSignInExperienceByApplicationId(
applicationId

View file

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

View file

@ -46,7 +46,8 @@ export default class Libraries {
this.queries,
this.connectors,
this.ssoConnectors,
this.cloudConnection
this.cloudConnection,
this.queries.wellKnownCache
);
organizationInvitations = new OrganizationInvitationLibrary(

View file

@ -170,13 +170,15 @@ const Main = () => {
};
const App = () => {
const params = new URL(window.location.href).searchParams;
const config = getLocalData('config');
return (
<LogtoProvider
config={{
endpoint: window.location.origin,
appId: demoAppApplicationId,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- We need to fall back for empty string
appId: params.get('app_id') || config.appId || demoAppApplicationId,
// eslint-disable-next-line no-restricted-syntax
prompt: config.prompt ? (config.prompt.split(' ') as Prompt[]) : [],
scopes: config.scope ? config.scope.split(' ') : [],

View file

@ -1,4 +1,5 @@
import { useLogto } from '@logto/react';
import { demoAppApplicationId } from '@logto/schemas';
import { decodeJwt } from 'jose';
import { useCallback, useState, type FormEventHandler } from 'react';
@ -48,6 +49,15 @@ 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}>App ID</div>
<input
name="appId"
defaultValue={config.appId}
type="text"
placeholder={demoAppApplicationId}
/>
</div>
<div className={styles.item}>
<div className={styles.text}>Sign-in extra params</div>
<input

View file

@ -10,6 +10,7 @@ type LocalLogtoConfig = {
prompt?: string;
scope?: string;
resource?: string;
appId?: string;
};
const localLogtoConfigGuard = z
@ -18,6 +19,7 @@ const localLogtoConfigGuard = z
prompt: z.string(),
scope: z.string(),
resource: z.string(),
appId: z.string(),
})
.partial() satisfies ToZodObject<LocalLogtoConfig>;

View file

@ -18,12 +18,21 @@ const buildSearchParameters = (record: Record<string, Nullable<Optional<string>>
return conditional(entries.length > 0 && entries);
};
// A simple camelCase utility to prevent the need to add a dependency.
const camelCase = (string: string): string =>
string.replaceAll(
/_([^_])([^_]*)/g,
(_, letter: string, rest: string) => letter.toUpperCase() + rest.toLowerCase()
);
export const getSignInExperience = async <T extends SignInExperienceResponse>(): Promise<T> => {
return ky
.get('/api/.well-known/sign-in-exp', {
searchParams: buildSearchParameters({
[searchKeys.organizationId]: sessionStorage.getItem(searchKeys.organizationId),
}),
searchParams: buildSearchParameters(
Object.fromEntries(
Object.values(searchKeys).map((key) => [camelCase(key), sessionStorage.getItem(key)])
)
),
})
.json<T>();
};

View file

@ -4,9 +4,11 @@
.signature {
@include _.flex-row;
font: var(--font-label-2);
font-weight: normal;
color: var(--color-neutral-variant-70);
padding: _.unit(1) _.unit(2);
text-decoration: none;
opacity: 75%;
.staticIcon {
display: block;
@ -18,6 +20,8 @@
&:hover,
&:active {
opacity: 100%;
.staticIcon {
display: none;
}

View file

@ -5,18 +5,17 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title></title>
<!--Preload well-known settings API-->
<!-- Preload well-known APIs -->
<script>
const { search } = window.location;
const isPreview = search.includes('preview');
// Preview mode does not query sign-in-exp and phrases
const preLoadLinks = isPreview ? [] : ['/api/.well-known/sign-in-exp', '/api/.well-known/phrases'];
const searchParams = new URLSearchParams(window.location.search);
const isPreview = searchParams.has('preview');
// Preview mode does not query phrases
const preLoadLinks = isPreview ? [] : ['/api/.well-known/phrases'];
// Append preload well-known settings API links to head
preLoadLinks.forEach((linkUrl) => {
const link = document.createElement('link');
link.rel = 'preload';
link.href = linkUrl
link.href = linkUrl;
link.as = 'fetch';
link.crossOrigin = 'anonymous';
document.head.appendChild(link);

View file

@ -5,6 +5,8 @@ export const searchKeys = Object.freeze({
* The key for specifying the organization ID that may be used to override the default settings.
*/
organizationId: 'organization_id',
/** The current application ID. */
appId: 'app_id',
});
export const handleSearchParametersData = () => {

View file

@ -56,19 +56,6 @@ describe('application sign in experience', () => {
);
});
it('should throw if application is not third-party', async () => {
await expectRejects(
setApplicationSignInExperience(
applications.get('firstPartyApp')!.id,
applicationSignInExperiences
),
{
code: 'application.third_party_application_only',
status: 422,
}
);
});
it('should set new application sign in experience', async () => {
const application = applications.get('thirdPartyApp')!;

View file

@ -3,10 +3,12 @@
*/
import { ConnectorType } from '@logto/connector-kit';
import { SignInIdentifier } from '@logto/schemas';
import { ApplicationType, SignInIdentifier } from '@logto/schemas';
import { setApplicationSignInExperience } from '#src/api/application-sign-in-experience.js';
import { createApplication, deleteApplication } from '#src/api/application.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { demoAppUrl } from '#src/constants.js';
import { demoAppRedirectUri, 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';
@ -39,11 +41,11 @@ describe('override', () => {
});
it('should show the overridden organization logos', async () => {
const logoUrl = 'mock://fake-url/logo.png';
const darkLogoUrl = 'mock://fake-url/dark-logo.png';
const logoUrl = 'mock://fake-url-for-organization/logo.png';
const darkLogoUrl = 'mock://fake-url-for-organization/dark-logo.png';
const organization = await organizationApi.create({
name: 'override-organization',
name: 'Sign-in experience override',
branding: {
logoUrl,
darkLogoUrl,
@ -59,4 +61,105 @@ describe('override', () => {
await experience.navigateTo(demoAppUrl.href + `?organization_id=${organization.id}`);
await experience.toMatchElement(`img[src="${darkLogoUrl}"]`);
});
it('should show app-level logo and color', async () => {
const logoUrl = 'mock://fake-url-for-app/logo.png';
const darkLogoUrl = 'mock://fake-url-for-app/dark-logo.png';
const primaryColor = '#f00';
const darkPrimaryColor = '#0f0';
const application = await createApplication(
'Sign-in experience override',
ApplicationType.SPA,
{
oidcClientMetadata: {
redirectUris: [demoAppRedirectUri],
postLogoutRedirectUris: [demoAppRedirectUri],
},
}
);
await setApplicationSignInExperience(application.id, {
color: {
primaryColor,
darkPrimaryColor,
},
branding: {
logoUrl,
darkLogoUrl,
},
});
const experience = new ExpectExperience(await browser.newPage());
const expectMatchBranding = async (theme: string, logoUrl: string, primaryColor: string) => {
await experience.page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: theme }]);
await experience.navigateTo(demoAppUrl.href + `?app_id=${application.id}`);
await experience.toMatchElement(`img[src="${logoUrl}"]`);
const button1 = await experience.toMatchElement('button[name="submit"]');
expect(
await button1.evaluate((element) => window.getComputedStyle(element).backgroundColor)
).toBe(primaryColor);
};
await expectMatchBranding('light', logoUrl, 'rgb(255, 0, 0)');
await expectMatchBranding('dark', darkLogoUrl, 'rgb(0, 255, 0)');
await deleteApplication(application.id);
});
it('should combine app-level and organization-level branding', async () => {
const organizationLogoUrl = 'mock://fake-url-for-organization/logo.png';
const organizationDarkLogoUrl = 'mock://fake-url-for-organization/dark-logo.png';
const appLogoUrl = 'mock://fake-url-for-app/logo.png';
const appDarkLogoUrl = 'mock://fake-url-for-app/dark-logo.png';
const appPrimaryColor = '#00f';
const appDarkPrimaryColor = '#f0f';
const organization = await organizationApi.create({
name: 'Sign-in experience override',
branding: {
logoUrl: organizationLogoUrl,
darkLogoUrl: organizationDarkLogoUrl,
},
});
const application = await createApplication(
'Sign-in experience override',
ApplicationType.SPA,
{
oidcClientMetadata: {
redirectUris: [demoAppRedirectUri],
postLogoutRedirectUris: [demoAppRedirectUri],
},
}
);
await setApplicationSignInExperience(application.id, {
color: {
primaryColor: appPrimaryColor,
darkPrimaryColor: appDarkPrimaryColor,
},
branding: {
logoUrl: appLogoUrl,
darkLogoUrl: appDarkLogoUrl,
},
});
const experience = new ExpectExperience(await browser.newPage());
const expectMatchBranding = async (theme: string, logoUrl: string, primaryColor: string) => {
await experience.page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: theme }]);
await experience.navigateTo(
demoAppUrl.href + `?app_id=${application.id}&organization_id=${organization.id}`
);
await experience.toMatchElement(`img[src="${logoUrl}"]`);
const button1 = await experience.toMatchElement('button[name="submit"]');
expect(
await button1.evaluate((element) => window.getComputedStyle(element).backgroundColor)
).toBe(primaryColor);
};
await expectMatchBranding('light', organizationLogoUrl, 'rgb(0, 0, 255)');
await expectMatchBranding('dark', organizationDarkLogoUrl, 'rgb(255, 0, 255)');
});
});

View file

@ -95,12 +95,18 @@ const application_details = {
no_organization_placeholder: 'No organization found. <a>Go to organizations</a>',
branding: {
name: 'Branding',
description: "Customize your application's display name and logo on the consent screen.",
description:
'Customize the logos and brand colors of this application. The settings here will override the global sign-in experience settings.',
description_third_party:
"Customize your application's display name and logo on the consent screen.",
more_info: 'More info',
more_info_description: 'Offer users more details about your application on the consent screen.',
display_name: 'Display name',
display_logo: 'Display logo',
display_logo_dark: 'Display logo (dark)',
application_logo: 'Application logo',
application_logo_dark: 'Application logo (dark)',
use_different_brand_color: 'Use a different brand color for the app-level sign-in experience',
brand_color: 'Brand color',
brand_color_dark: 'Brand color (dark)',
terms_of_use_url: 'Application terms of use URL',
privacy_policy_url: 'Application privacy policy URL',
},

View file

@ -39,7 +39,7 @@ const sign_in_exp = {
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)',
dark_logo_image: 'App logo (dark)',
logo_image_error: 'App logo: {{error}}',
favicon_error: 'Favicon: {{error}}',
},

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 application_sign_in_experiences add column color jsonb not null default '{}'::jsonb;
`);
},
down: async (pool) => {
await pool.query(sql`
alter table application_sign_in_experiences drop column color;
`);
},
};
export default alteration;

View file

@ -13,6 +13,10 @@ export const colorGuard = z.object({
export type Color = z.infer<typeof colorGuard>;
export const partialColorGuard = colorGuard.partial();
export type PartialColor = Partial<Color>;
/** Maps a theme to the key of the logo URL in the {@link Branding} object. */
export const themeToLogoKey = Object.freeze({
[Theme.Light]: 'logoUrl',

View file

@ -6,6 +6,7 @@ create table application_sign_in_experiences (
references tenants (id) on update cascade on delete cascade,
application_id varchar(21) not null
references applications (id) on update cascade on delete cascade,
color jsonb /* @use PartialColor */ not null default '{}'::jsonb,
branding jsonb /* @use Branding */ not null default '{}'::jsonb,
terms_of_use_url varchar(2048),
privacy_policy_url varchar(2048),