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:
parent
a6f96f1d8d
commit
4a8b7c0648
30 changed files with 398 additions and 128 deletions
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -55,6 +55,7 @@
|
|||
"topbar",
|
||||
"upsell",
|
||||
"withtyped",
|
||||
"backchannel"
|
||||
"backchannel",
|
||||
"deepmerge"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -60,6 +60,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: _.unit(1);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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={() => {
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.colors {
|
||||
margin-top: _.unit(6);
|
||||
}
|
|
@ -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} />}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(':');
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -46,7 +46,8 @@ export default class Libraries {
|
|||
this.queries,
|
||||
this.connectors,
|
||||
this.ssoConnectors,
|
||||
this.cloudConnection
|
||||
this.cloudConnection,
|
||||
this.queries.wellKnownCache
|
||||
);
|
||||
|
||||
organizationInvitations = new OrganizationInvitationLibrary(
|
||||
|
|
|
@ -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(' ') : [],
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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>();
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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')!;
|
||||
|
||||
|
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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}}',
|
||||
},
|
||||
|
|
|
@ -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;
|
|
@ -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',
|
||||
|
|
|
@ -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),
|
||||
|
|
Loading…
Reference in a new issue