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", "topbar",
"upsell", "upsell",
"withtyped", "withtyped",
"backchannel" "backchannel",
"deepmerge"
] ]
} }

View file

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

View file

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

View file

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

View file

@ -185,14 +185,16 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
</> </>
)} )}
{data.isThirdParty && ( {data.isThirdParty && (
<> <TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Permissions}`}>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Permissions}`}> {t('application_details.permissions.name')}
{t('application_details.permissions.name')} </TabNavItem>
</TabNavItem> )}
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Branding}`}> {[ApplicationType.Native, ApplicationType.SPA, ApplicationType.Traditional].includes(
{t('application_details.branding.name')} data.type
</TabNavItem> ) && (
</> <TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Branding}`}>
{t('application_details.branding.name')}
</TabNavItem>
)} )}
</TabNav> </TabNav>
<TabWrapper <TabWrapper
@ -257,21 +259,23 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
</> </>
)} )}
{data.isThirdParty && ( {data.isThirdParty && (
<> <TabWrapper
<TabWrapper isActive={tab === ApplicationDetailsTabs.Permissions}
isActive={tab === ApplicationDetailsTabs.Permissions} className={styles.tabContainer}
className={styles.tabContainer} >
> <Permissions application={data} />
<Permissions application={data} /> </TabWrapper>
</TabWrapper> )}
<TabWrapper {[ApplicationType.Native, ApplicationType.SPA, ApplicationType.Traditional].includes(
isActive={tab === ApplicationDetailsTabs.Branding} data.type
className={styles.tabContainer} ) && (
> <TabWrapper
{/* isActive is needed to support conditional render UnsavedChangesAlertModal */} isActive={tab === ApplicationDetailsTabs.Branding}
<Branding application={data} isActive={tab === ApplicationDetailsTabs.Branding} /> className={styles.tabContainer}
</TabWrapper> >
</> {/* 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': Record<string, unknown>;
'custom-phrases-tags': string[]; 'custom-phrases-tags': string[];
'tenant-cache-expires-at': number; '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; type WellKnownCacheType = keyof WellKnownMap;
@ -56,6 +58,9 @@ function getValueGuard(type: WellKnownCacheType): ZodType<WellKnownMap[typeof ty
case 'tenant-cache-expires-at': { case 'tenant-cache-expires-at': {
return z.number(); return z.number();
} }
case 'is-developer-tenant': {
return z.boolean();
}
} }
} }

View file

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

View file

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

View file

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

View file

@ -133,15 +133,19 @@ describe('isOriginAllowed', () => {
describe('buildLoginPromptUrl', () => { describe('buildLoginPromptUrl', () => {
it('should return the correct url for empty parameters', () => { it('should return the correct url for empty parameters', () => {
expect(buildLoginPromptUrl({})).toBe('sign-in'); expect(buildLoginPromptUrl({})).toBe('sign-in');
expect(buildLoginPromptUrl({}, 'foo')).toBe('sign-in'); expect(buildLoginPromptUrl({}, 'foo')).toBe('sign-in?app_id=foo');
expect(buildLoginPromptUrl({}, demoAppApplicationId)).toBe('sign-in'); expect(buildLoginPromptUrl({}, demoAppApplicationId)).toBe(
'sign-in?app_id=' + demoAppApplicationId
);
}); });
it('should return the correct url for firstScreen', () => { it('should return the correct url for firstScreen', () => {
expect(buildLoginPromptUrl({ first_screen: FirstScreen.Register })).toBe('register'); 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( expect(buildLoginPromptUrl({ first_screen: FirstScreen.SignIn }, demoAppApplicationId)).toBe(
'sign-in' 'sign-in?app_id=demo-app'
); );
// Legacy interactionMode support // Legacy interactionMode support
expect(buildLoginPromptUrl({ interaction_mode: InteractionMode.SignUp })).toBe('register'); expect(buildLoginPromptUrl({ interaction_mode: InteractionMode.SignUp })).toBe('register');
@ -152,10 +156,10 @@ describe('buildLoginPromptUrl', () => {
'direct/method/target?fallback=sign-in' 'direct/method/target?fallback=sign-in'
); );
expect(buildLoginPromptUrl({ direct_sign_in: 'method:target' }, 'foo')).toBe( 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( 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( expect(buildLoginPromptUrl({ direct_sign_in: 'method' })).toBe(
'direct/method?fallback=sign-in' 'direct/method?fallback=sign-in'
@ -172,6 +176,6 @@ describe('buildLoginPromptUrl', () => {
{ first_screen: FirstScreen.Register, direct_sign_in: 'method:target' }, { first_screen: FirstScreen.Register, direct_sign_in: 'method:target' },
demoAppApplicationId 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 searchParams = new URLSearchParams();
const getSearchParamString = () => (searchParams.size > 0 ? `?${searchParams.toString()}` : ''); const getSearchParamString = () => (searchParams.size > 0 ? `?${searchParams.toString()}` : '');
if (appId) {
searchParams.append('app_id', String(appId));
}
if (directSignIn) { if (directSignIn) {
searchParams.append('fallback', firstScreen); searchParams.append('fallback', firstScreen);
const [method, target] = directSignIn.split(':'); const [method, target] = directSignIn.split(':');

View file

@ -21,9 +21,6 @@ function applicationSignInExperienceRoutes<T extends ManagementApiRouter>(
updateByApplicationId, updateByApplicationId,
}, },
}, },
libraries: {
applications: { validateThirdPartyApplicationById },
},
}, },
]: RouterInitArgs<T> ]: RouterInitArgs<T>
) { ) {
@ -31,7 +28,6 @@ function applicationSignInExperienceRoutes<T extends ManagementApiRouter>(
* Customize the branding of an application. * 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 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. * - 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. * - 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, body,
} = ctx.guard; } = ctx.guard;
await validateThirdPartyApplicationById(applicationId); await findApplicationById(applicationId);
const applicationSignInExperience = await safeFindSignInExperienceByApplicationId( const applicationSignInExperience = await safeFindSignInExperienceByApplicationId(
applicationId applicationId

View file

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

View file

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

View file

@ -170,13 +170,15 @@ const Main = () => {
}; };
const App = () => { const App = () => {
const params = new URL(window.location.href).searchParams;
const config = getLocalData('config'); const config = getLocalData('config');
return ( return (
<LogtoProvider <LogtoProvider
config={{ config={{
endpoint: window.location.origin, 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 // eslint-disable-next-line no-restricted-syntax
prompt: config.prompt ? (config.prompt.split(' ') as Prompt[]) : [], prompt: config.prompt ? (config.prompt.split(' ') as Prompt[]) : [],
scopes: config.scope ? config.scope.split(' ') : [], scopes: config.scope ? config.scope.split(' ') : [],

View file

@ -1,4 +1,5 @@
import { useLogto } from '@logto/react'; import { useLogto } from '@logto/react';
import { demoAppApplicationId } from '@logto/schemas';
import { decodeJwt } from 'jose'; import { decodeJwt } from 'jose';
import { useCallback, useState, type FormEventHandler } from 'react'; import { useCallback, useState, type FormEventHandler } from 'react';
@ -48,6 +49,15 @@ const DevPanel = () => {
<div className={[styles.card, styles.devPanel].join(' ')}> <div className={[styles.card, styles.devPanel].join(' ')}>
<form onSubmit={submitConfig}> <form onSubmit={submitConfig}>
<div className={styles.title}>Logto config</div> <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.item}>
<div className={styles.text}>Sign-in extra params</div> <div className={styles.text}>Sign-in extra params</div>
<input <input

View file

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

View file

@ -18,12 +18,21 @@ const buildSearchParameters = (record: Record<string, Nullable<Optional<string>>
return conditional(entries.length > 0 && entries); 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> => { export const getSignInExperience = async <T extends SignInExperienceResponse>(): Promise<T> => {
return ky return ky
.get('/api/.well-known/sign-in-exp', { .get('/api/.well-known/sign-in-exp', {
searchParams: buildSearchParameters({ searchParams: buildSearchParameters(
[searchKeys.organizationId]: sessionStorage.getItem(searchKeys.organizationId), Object.fromEntries(
}), Object.values(searchKeys).map((key) => [camelCase(key), sessionStorage.getItem(key)])
)
),
}) })
.json<T>(); .json<T>();
}; };

View file

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

View file

@ -5,18 +5,17 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title></title> <title></title>
<!--Preload well-known settings API--> <!-- Preload well-known APIs -->
<script> <script>
const { search } = window.location; const searchParams = new URLSearchParams(window.location.search);
const isPreview = search.includes('preview'); const isPreview = searchParams.has('preview');
// Preview mode does not query sign-in-exp and phrases // Preview mode does not query phrases
const preLoadLinks = isPreview ? [] : ['/api/.well-known/sign-in-exp', '/api/.well-known/phrases']; const preLoadLinks = isPreview ? [] : ['/api/.well-known/phrases'];
// Append preload well-known settings API links to head
preLoadLinks.forEach((linkUrl) => { preLoadLinks.forEach((linkUrl) => {
const link = document.createElement('link'); const link = document.createElement('link');
link.rel = 'preload'; link.rel = 'preload';
link.href = linkUrl link.href = linkUrl;
link.as = 'fetch'; link.as = 'fetch';
link.crossOrigin = 'anonymous'; link.crossOrigin = 'anonymous';
document.head.appendChild(link); 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. * The key for specifying the organization ID that may be used to override the default settings.
*/ */
organizationId: 'organization_id', organizationId: 'organization_id',
/** The current application ID. */
appId: 'app_id',
}); });
export const handleSearchParametersData = () => { 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 () => { it('should set new application sign in experience', async () => {
const application = applications.get('thirdPartyApp')!; const application = applications.get('thirdPartyApp')!;

View file

@ -3,10 +3,12 @@
*/ */
import { ConnectorType } from '@logto/connector-kit'; 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 { 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 { clearConnectorsByTypes } from '#src/helpers/connector.js';
import { OrganizationApiTest } from '#src/helpers/organization.js'; import { OrganizationApiTest } from '#src/helpers/organization.js';
import ExpectExperience from '#src/ui-helpers/expect-experience.js'; import ExpectExperience from '#src/ui-helpers/expect-experience.js';
@ -39,11 +41,11 @@ describe('override', () => {
}); });
it('should show the overridden organization logos', async () => { it('should show the overridden organization logos', async () => {
const logoUrl = 'mock://fake-url/logo.png'; const logoUrl = 'mock://fake-url-for-organization/logo.png';
const darkLogoUrl = 'mock://fake-url/dark-logo.png'; const darkLogoUrl = 'mock://fake-url-for-organization/dark-logo.png';
const organization = await organizationApi.create({ const organization = await organizationApi.create({
name: 'override-organization', name: 'Sign-in experience override',
branding: { branding: {
logoUrl, logoUrl,
darkLogoUrl, darkLogoUrl,
@ -59,4 +61,105 @@ describe('override', () => {
await experience.navigateTo(demoAppUrl.href + `?organization_id=${organization.id}`); await experience.navigateTo(demoAppUrl.href + `?organization_id=${organization.id}`);
await experience.toMatchElement(`img[src="${darkLogoUrl}"]`); 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>', no_organization_placeholder: 'No organization found. <a>Go to organizations</a>',
branding: { branding: {
name: '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: 'More info',
more_info_description: 'Offer users more details about your application on the consent screen.', more_info_description: 'Offer users more details about your application on the consent screen.',
display_name: 'Display name', display_name: 'Display name',
display_logo: 'Display logo', application_logo: 'Application logo',
display_logo_dark: 'Display logo (dark)', 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', terms_of_use_url: 'Application terms of use URL',
privacy_policy_url: 'Application privacy policy 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: 'App logo image URL (dark)',
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png', dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
logo_image: 'App logo', logo_image: 'App logo',
dark_logo_image: 'App logo (Dark)', dark_logo_image: 'App logo (dark)',
logo_image_error: 'App logo: {{error}}', logo_image_error: 'App logo: {{error}}',
favicon_error: 'Favicon: {{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 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. */ /** Maps a theme to the key of the logo URL in the {@link Branding} object. */
export const themeToLogoKey = Object.freeze({ export const themeToLogoKey = Object.freeze({
[Theme.Light]: 'logoUrl', [Theme.Light]: 'logoUrl',

View file

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