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",
|
"topbar",
|
||||||
"upsell",
|
"upsell",
|
||||||
"withtyped",
|
"withtyped",
|
||||||
"backchannel"
|
"backchannel",
|
||||||
|
"deepmerge"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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={() => {
|
||||||
|
|
|
@ -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 { 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} />}
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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(':');
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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(' ') : [],
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
||||||
|
|
|
@ -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>();
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 = () => {
|
||||||
|
|
|
@ -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')!;
|
||||||
|
|
||||||
|
|
|
@ -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)');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
|
|
|
@ -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}}',
|
||||||
},
|
},
|
||||||
|
|
|
@ -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 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',
|
||||||
|
|
|
@ -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),
|
||||||
|
|
Loading…
Reference in a new issue