mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
feat: organization logo
This commit is contained in:
parent
1b7418c29d
commit
3a839f6d60
28 changed files with 389 additions and 41 deletions
33
.changeset/dull-goats-help.md
Normal file
33
.changeset/dull-goats-help.md
Normal file
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
"@logto/experience": minor
|
||||
"@logto/console": minor
|
||||
"@logto/core": minor
|
||||
"@logto/integration-tests": patch
|
||||
"@logto/phrases": patch
|
||||
"@logto/schemas": patch
|
||||
---
|
||||
|
||||
support organization logo and sign-in experience override
|
||||
|
||||
Now it's able to set light and dark logos for organizations. You can upload the logos in the organization settings page.
|
||||
|
||||
Also, it's possible to override the sign-in experience logo from an organization. Simply add the `organization_id` parameter to the authentication request. In most Logto SDKs, it can be done by using the `extraParams` field in the `signIn` method.
|
||||
|
||||
For example, in the JavaScript SDK:
|
||||
|
||||
```ts
|
||||
import LogtoClient from '@logto/client';
|
||||
|
||||
const logtoClient = new LogtoClient(/* your configuration */);
|
||||
|
||||
logtoClient.signIn({
|
||||
redirectUri: 'https://your-app.com/callback',
|
||||
extraParams: {
|
||||
organization_id: '<organization-id>'
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The value `<organization-id>` can be found in the organization settings page.
|
||||
|
||||
If you could not find the `extraParams` field in the SDK you are using, please let us know.
|
5
.changeset/lazy-geese-bow.md
Normal file
5
.changeset/lazy-geese-bow.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/demo-app": minor
|
||||
---
|
||||
|
||||
support extra token params in dev panel
|
|
@ -30,7 +30,7 @@ const getAuthorizationUri: GetAuthorizationUri = async (
|
|||
}
|
||||
}
|
||||
|
||||
return `http://mock.social.com/?state=${state}&redirect_uri=${redirectUri}`;
|
||||
return `http://mock-social/?state=${state}&redirect_uri=${redirectUri}`;
|
||||
};
|
||||
|
||||
const getUserInfo: GetUserInfo = async (data, getSession) => {
|
||||
|
|
|
@ -9,6 +9,15 @@ import { GlobalRoute } from '@/contexts/TenantsProvider';
|
|||
import useApi, { useStaticApi, type RequestError } from './use-api';
|
||||
import useSwrFetcher from './use-swr-fetcher';
|
||||
|
||||
/**
|
||||
* Hook to check if the user assets service (file uploading) is ready.
|
||||
*
|
||||
* Caveats: When using it in a form, remember to check `isLoading` first and don't render the form
|
||||
* until it's settled. Otherwise, the form may be rendered with unexpected behavior, such as
|
||||
* registering a unexpected validate function. If you really need to render the form while loading,
|
||||
* you can use the `shouldUnregister` option from `react-hook-form` to unregister the field when
|
||||
* the component is unmounted.
|
||||
*/
|
||||
const useUserAssetsService = () => {
|
||||
const adminApi = useStaticApi({
|
||||
prefixUrl: adminTenantEndpoint,
|
||||
|
@ -27,6 +36,10 @@ const useUserAssetsService = () => {
|
|||
);
|
||||
|
||||
return {
|
||||
/**
|
||||
* Whether the user assets service (file uploading) is ready.
|
||||
* @see {@link useUserAssetsService} for caveats.
|
||||
*/
|
||||
isReady: data?.status === 'ready',
|
||||
isLoading: !error && !data,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
import { Theme, themeToLogoKey } from '@logto/schemas';
|
||||
import { Controller, type UseFormReturn } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FormCard, { FormCardSkeleton } from '@/components/FormCard';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import TextInput from '@/ds-components/TextInput';
|
||||
import ImageUploaderField from '@/ds-components/Uploader/ImageUploaderField';
|
||||
import useUserAssetsService from '@/hooks/use-user-assets-service';
|
||||
import { uriValidator } from '@/utils/validator';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
import { type FormData } from './utils';
|
||||
|
||||
type Props = {
|
||||
readonly form: UseFormReturn<FormData>;
|
||||
};
|
||||
|
||||
function Branding({ form }: Props) {
|
||||
const { isReady: isUserAssetsServiceReady, isLoading } = useUserAssetsService();
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
register,
|
||||
} = form;
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
if (isLoading) {
|
||||
return <FormCardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormCard
|
||||
title="organization_details.branding.title"
|
||||
description="organization_details.branding.description"
|
||||
>
|
||||
<div className={styles.branding}>
|
||||
{Object.values(Theme).map((theme) => (
|
||||
<section key={theme}>
|
||||
<FormField title={`organization_details.branding.${theme}_logo`}>
|
||||
{isUserAssetsServiceReady ? (
|
||||
<Controller
|
||||
control={control}
|
||||
name={`branding.${themeToLogoKey[theme]}`}
|
||||
render={({ field: { onChange, value, name } }) => (
|
||||
<ImageUploaderField
|
||||
name={name}
|
||||
value={value ?? ''}
|
||||
actionDescription={t('organization_details.branding.logo_upload_description')}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
{...register(`branding.${themeToLogoKey[theme]}`, {
|
||||
validate: (value?: string) =>
|
||||
!value || uriValidator(value) || t('errors.invalid_uri_format'),
|
||||
})}
|
||||
error={errors.branding?.[themeToLogoKey[theme]]?.message}
|
||||
placeholder={t('sign_in_exp.branding.logo_image_url_placeholder')}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</FormCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default Branding;
|
|
@ -22,6 +22,12 @@
|
|||
gap: _.unit(3);
|
||||
}
|
||||
|
||||
.branding {
|
||||
section + section {
|
||||
margin-top: _.unit(6);
|
||||
}
|
||||
}
|
||||
|
||||
.mfaWarning {
|
||||
margin-top: _.unit(3);
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ import { trySubmitSafe } from '@/utils/form';
|
|||
|
||||
import { type OrganizationDetailsOutletContext } from '../types';
|
||||
|
||||
import Branding from './Branding';
|
||||
import JitSettings from './JitSettings';
|
||||
import * as styles from './index.module.scss';
|
||||
import { assembleData, isJsonObject, normalizeData, type FormData } from './utils';
|
||||
|
@ -136,6 +137,7 @@ function Settings() {
|
|||
)}
|
||||
</FormField>
|
||||
</FormCard>
|
||||
<Branding form={form} />
|
||||
<JitSettings form={form} />
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
|
||||
</DetailsForm>
|
||||
|
|
|
@ -25,14 +25,24 @@ export const normalizeData = (
|
|||
customData: JSON.stringify(data.customData, undefined, 2),
|
||||
});
|
||||
|
||||
const assembleBranding = (branding?: Organization['branding']) => {
|
||||
if (!branding) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(branding).filter(([, value]) => Boolean(value)));
|
||||
};
|
||||
|
||||
export const assembleData = ({
|
||||
jitEmailDomains,
|
||||
jitRoles,
|
||||
jitSsoConnectorIds,
|
||||
customData,
|
||||
branding,
|
||||
...data
|
||||
}: FormData): Partial<Organization> => ({
|
||||
...data,
|
||||
branding: assembleBranding(branding),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
customData: JSON.parse(customData ?? '{}'),
|
||||
});
|
||||
|
|
|
@ -149,7 +149,7 @@ describe('getFullSignInExperience()', () => {
|
|||
wellConfiguredSsoConnector,
|
||||
]);
|
||||
|
||||
const fullSignInExperience = await getFullSignInExperience('en');
|
||||
const fullSignInExperience = await getFullSignInExperience({ locale: 'en' });
|
||||
const connectorFactory = ssoConnectorFactories[wellConfiguredSsoConnector.providerName];
|
||||
|
||||
expect(fullSignInExperience).toStrictEqual({
|
||||
|
@ -183,7 +183,7 @@ describe('getFullSignInExperience()', () => {
|
|||
wellConfiguredSsoConnector,
|
||||
]);
|
||||
|
||||
const fullSignInExperience = await getFullSignInExperience('en');
|
||||
const fullSignInExperience = await getFullSignInExperience({ locale: 'en' });
|
||||
const connectorFactory = ssoConnectorFactories[wellConfiguredSsoConnector.providerName];
|
||||
|
||||
expect(fullSignInExperience).toStrictEqual({
|
||||
|
@ -224,7 +224,7 @@ describe('get sso connectors', () => {
|
|||
singleSignOnEnabled: false,
|
||||
});
|
||||
|
||||
const { ssoConnectors } = await getFullSignInExperience('en');
|
||||
const { ssoConnectors } = await getFullSignInExperience({ locale: 'en' });
|
||||
|
||||
expect(ssoConnectorLibrary.getAvailableSsoConnectors).not.toBeCalled();
|
||||
|
||||
|
@ -239,7 +239,7 @@ describe('get sso connectors', () => {
|
|||
wellConfiguredSsoConnector,
|
||||
]);
|
||||
|
||||
const { ssoConnectors } = await getFullSignInExperience('jp');
|
||||
const { ssoConnectors } = await getFullSignInExperience({ locale: 'jp' });
|
||||
|
||||
const connectorFactory = ssoConnectorFactories[wellConfiguredSsoConnector.providerName];
|
||||
|
||||
|
@ -270,7 +270,7 @@ describe('get sso connectors', () => {
|
|||
|
||||
const connectorFactory = ssoConnectorFactories[wellConfiguredSsoConnector.providerName];
|
||||
|
||||
const { ssoConnectors } = await getFullSignInExperience('en');
|
||||
const { ssoConnectors } = await getFullSignInExperience({ locale: 'en' });
|
||||
|
||||
expect(ssoConnectors).toEqual([
|
||||
{
|
||||
|
|
|
@ -4,10 +4,12 @@ import type {
|
|||
ConnectorMetadata,
|
||||
FullSignInExperience,
|
||||
LanguageInfo,
|
||||
SignInExperience,
|
||||
SsoConnectorMetadata,
|
||||
} from '@logto/schemas';
|
||||
import { ConnectorType } from '@logto/schemas';
|
||||
import { deduplicate } from '@silverhand/essentials';
|
||||
import { deduplicate, trySafe } from '@silverhand/essentials';
|
||||
import deepmerge from 'deepmerge';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
|
@ -37,6 +39,7 @@ export const createSignInExperienceLibrary = (
|
|||
const {
|
||||
customPhrases: { findAllCustomLanguageTags },
|
||||
signInExperiences: { findDefaultSignInExperience, updateDefaultSignInExperience },
|
||||
organizations,
|
||||
} = queries;
|
||||
|
||||
const validateLanguageInfo = async (languageInfo: LanguageInfo) => {
|
||||
|
@ -109,12 +112,38 @@ export const createSignInExperienceLibrary = (
|
|||
return plan.id === developmentTenantPlanId;
|
||||
};
|
||||
|
||||
const getFullSignInExperience = async (locale: string): Promise<FullSignInExperience> => {
|
||||
const [signInExperience, logtoConnectors, isDevelopmentTenant] = await Promise.all([
|
||||
findDefaultSignInExperience(),
|
||||
getLogtoConnectors(),
|
||||
getIsDevelopmentTenant(),
|
||||
]);
|
||||
/**
|
||||
* Get the override data for the sign-in experience by reading from organization data. If the
|
||||
* entity is not found, return `undefined`.
|
||||
*/
|
||||
const getOrganizationOverride = async (
|
||||
organizationId?: string
|
||||
): Promise<Partial<SignInExperience> | undefined> => {
|
||||
if (!organizationId) {
|
||||
return;
|
||||
}
|
||||
const organization = await trySafe(organizations.findById(organizationId));
|
||||
if (!organization?.branding) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { branding: organization.branding };
|
||||
};
|
||||
|
||||
const getFullSignInExperience = async ({
|
||||
locale,
|
||||
organizationId,
|
||||
}: {
|
||||
locale: string;
|
||||
organizationId?: string;
|
||||
}): Promise<FullSignInExperience> => {
|
||||
const [signInExperience, logtoConnectors, isDevelopmentTenant, organizationOverride] =
|
||||
await Promise.all([
|
||||
findDefaultSignInExperience(),
|
||||
getLogtoConnectors(),
|
||||
getIsDevelopmentTenant(),
|
||||
getOrganizationOverride(organizationId),
|
||||
]);
|
||||
|
||||
// Always return empty array if single-sign-on is disabled
|
||||
const ssoConnectors = signInExperience.singleSignOnEnabled
|
||||
|
@ -167,7 +196,7 @@ export const createSignInExperienceLibrary = (
|
|||
};
|
||||
|
||||
return {
|
||||
...signInExperience,
|
||||
...deepmerge(signInExperience, organizationOverride ?? {}),
|
||||
socialConnectors,
|
||||
ssoConnectors,
|
||||
forgotPassword,
|
||||
|
|
|
@ -101,5 +101,11 @@ export const buildLoginPromptUrl = (params: ExtraParamsObject, appId?: unknown):
|
|||
return path.join('direct', method ?? '', target ?? '') + getSearchParamString();
|
||||
}
|
||||
|
||||
// Append other valid params as-is
|
||||
const { first_screen: _, interaction_mode: __, direct_sign_in: ___, ...rest } = params;
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
searchParams.append(key, value);
|
||||
}
|
||||
|
||||
return firstScreen + getSearchParamString();
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { isBuiltInLanguageTag } from '@logto/phrases-experience';
|
||||
import { adminTenantId, guardFullSignInExperience } from '@logto/schemas';
|
||||
import { ExtraParamsKey, adminTenantId, guardFullSignInExperience } from '@logto/schemas';
|
||||
import { conditionalArray } from '@silverhand/essentials';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
@ -41,9 +41,14 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
|
|||
|
||||
router.get(
|
||||
'/.well-known/sign-in-exp',
|
||||
koaGuard({ response: guardFullSignInExperience, status: 200 }),
|
||||
koaGuard({
|
||||
query: z.object({ [ExtraParamsKey.OrganizationId]: z.string().optional() }),
|
||||
response: guardFullSignInExperience,
|
||||
status: 200,
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
ctx.body = await getFullSignInExperience(ctx.locale);
|
||||
const { [ExtraParamsKey.OrganizationId]: organizationId } = ctx.guard.query;
|
||||
ctx.body = await getFullSignInExperience({ locale: ctx.locale, organizationId });
|
||||
|
||||
return next();
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import { getLocalData, setLocalData } from './utils';
|
|||
void initI18n();
|
||||
|
||||
const Main = () => {
|
||||
const config = getLocalData('config');
|
||||
const params = new URL(window.location.href).searchParams;
|
||||
const { isAuthenticated, isLoading, getIdTokenClaims, signIn, signOut } = useLogto();
|
||||
const [user, setUser] = useState<Pick<IdTokenClaims, 'sub' | 'username'>>();
|
||||
|
@ -53,10 +54,24 @@ const Main = () => {
|
|||
if (!isAuthenticated) {
|
||||
void signIn({
|
||||
redirectUri: window.location.origin + window.location.pathname,
|
||||
extraParams: Object.fromEntries(new URLSearchParams(window.location.search).entries()),
|
||||
extraParams: Object.fromEntries(
|
||||
new URLSearchParams([
|
||||
...new URLSearchParams(config.signInExtraParams).entries(),
|
||||
...new URLSearchParams(window.location.search).entries(),
|
||||
]).entries()
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [error, getIdTokenClaims, isAuthenticated, isInCallback, isLoading, signIn, user]);
|
||||
}, [
|
||||
config.signInExtraParams,
|
||||
error,
|
||||
getIdTokenClaims,
|
||||
isAuthenticated,
|
||||
isInCallback,
|
||||
isLoading,
|
||||
signIn,
|
||||
user,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const onThemeChange = (event: MediaQueryListEvent) => {
|
||||
|
|
|
@ -48,13 +48,27 @@ 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}>Sign-in extra params</div>
|
||||
<input
|
||||
name="signInExtraParams"
|
||||
defaultValue={config.signInExtraParams}
|
||||
type="text"
|
||||
placeholder="foo=bar&baz=qux"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<div className={styles.text}>Prompt</div>
|
||||
<input name="prompt" defaultValue={config.prompt} type="text" />
|
||||
<input
|
||||
name="prompt"
|
||||
defaultValue={config.prompt}
|
||||
type="text"
|
||||
placeholder="login consent"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<div className={styles.text}>Scope</div>
|
||||
<input name="scope" defaultValue={config.scope} type="text" />
|
||||
<input name="scope" defaultValue={config.scope} type="text" placeholder="foo bar" />
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<div className={styles.text}>Resource (space delimited)</div>
|
||||
|
|
|
@ -1,25 +1,35 @@
|
|||
import { Prompt, UserScope } from '@logto/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
type ToZodObject<T> = z.ZodObject<{
|
||||
[K in keyof T]-?: z.ZodType<T[K]>;
|
||||
}>;
|
||||
|
||||
type LocalLogtoConfig = {
|
||||
signInExtraParams?: string;
|
||||
prompt?: string;
|
||||
scope?: string;
|
||||
resource?: string;
|
||||
};
|
||||
|
||||
const localLogtoConfigGuard = z.object({
|
||||
prompt: z.string(),
|
||||
scope: z.string(),
|
||||
resource: z.string(),
|
||||
}) satisfies z.ZodType<LocalLogtoConfig>;
|
||||
const localLogtoConfigGuard = z
|
||||
.object({
|
||||
signInExtraParams: z.string(),
|
||||
prompt: z.string(),
|
||||
scope: z.string(),
|
||||
resource: z.string(),
|
||||
})
|
||||
.partial() satisfies ToZodObject<LocalLogtoConfig>;
|
||||
|
||||
type LocalUiConfig = {
|
||||
showDevPanel?: boolean;
|
||||
};
|
||||
|
||||
const localUiConfigGuard = z.object({
|
||||
showDevPanel: z.boolean(),
|
||||
}) satisfies z.ZodType<LocalUiConfig>;
|
||||
const localUiConfigGuard = z
|
||||
.object({
|
||||
showDevPanel: z.boolean(),
|
||||
})
|
||||
.partial() satisfies ToZodObject<LocalUiConfig>;
|
||||
|
||||
type Key = 'config' | 'ui';
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ export const getSignInExperience = async <T extends SignInExperienceResponse>():
|
|||
.get('/api/.well-known/sign-in-exp', {
|
||||
searchParams: buildSearchParameters({
|
||||
[searchKeys.noCache]: sessionStorage.getItem(searchKeys.noCache),
|
||||
[searchKeys.organizationId]: sessionStorage.getItem(searchKeys.organizationId),
|
||||
}),
|
||||
})
|
||||
.json<T>();
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
import { condString } from '@silverhand/essentials';
|
||||
|
||||
export const searchKeys = Object.freeze({
|
||||
noCache: 'no_cache',
|
||||
/**
|
||||
* The key for specifying the organization ID that may be used to override the default settings.
|
||||
*/
|
||||
organizationId: 'organization_id',
|
||||
});
|
||||
|
||||
export const handleSearchParametersData = () => {
|
||||
|
@ -9,9 +15,22 @@ export const handleSearchParametersData = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// TODO: will refactor soon
|
||||
const parameters = new URLSearchParams(search);
|
||||
|
||||
if (parameters.get(searchKeys.noCache) !== null) {
|
||||
sessionStorage.setItem(searchKeys.noCache, 'true');
|
||||
}
|
||||
|
||||
const organizationId = parameters.get(searchKeys.organizationId);
|
||||
if (organizationId) {
|
||||
sessionStorage.setItem(searchKeys.organizationId, organizationId);
|
||||
parameters.delete(searchKeys.organizationId);
|
||||
}
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
window.location.pathname + condString(parameters.size > 0 && `?${parameters.toString()}`)
|
||||
);
|
||||
};
|
||||
|
|
|
@ -30,7 +30,7 @@ export const signUpIdentifiers = {
|
|||
|
||||
export const consoleUsername = 'svhd';
|
||||
export const consolePassword = 'silverhandasd_1';
|
||||
export const mockSocialAuthPageUrl = 'http://mock.social.com';
|
||||
export const mockSocialAuthPageUrl = 'http://mock-social';
|
||||
|
||||
export const newOidcSsoConnectorPayload = {
|
||||
providerName: SsoProviderName.OIDC,
|
||||
|
|
|
@ -212,7 +212,7 @@ describe('admin console user management', () => {
|
|||
});
|
||||
|
||||
const state = 'random_state';
|
||||
const redirectUri = 'http://mock.social.com/callback/random_string';
|
||||
const redirectUri = 'http://mock-social/callback/random_string';
|
||||
const code = 'random_code_from_social';
|
||||
const socialUserId = 'social_platform_user_id_' + randomString();
|
||||
const socialUserEmail = `johndoe_${randomString()}@gmail.com`;
|
||||
|
@ -229,7 +229,7 @@ describe('admin console user management', () => {
|
|||
const { id: userId } = await createUserByAdmin();
|
||||
const { redirectTo } = await getConnectorAuthorizationUri(connectorId, state, redirectUri);
|
||||
|
||||
expect(redirectTo).toBe(`http://mock.social.com/?state=${state}&redirect_uri=${redirectUri}`);
|
||||
expect(redirectTo).toBe(`http://mock-social/?state=${state}&redirect_uri=${redirectUri}`);
|
||||
|
||||
const identities = await postUserIdentity(userId, connectorId, {
|
||||
code,
|
||||
|
|
|
@ -18,11 +18,11 @@ describe('admin console sign-in experience', () => {
|
|||
isDarkModeEnabled: true,
|
||||
},
|
||||
branding: {
|
||||
logoUrl: 'https://logto.io/new-logo.png',
|
||||
darkLogoUrl: 'https://logto.io/new-dark-logo.png',
|
||||
logoUrl: 'mock://fake-url/logo.png',
|
||||
darkLogoUrl: 'mock://fake-url/dark-logo.png',
|
||||
},
|
||||
termsOfUseUrl: 'https://logto.io/terms',
|
||||
privacyPolicyUrl: 'https://logto.io/privacy',
|
||||
termsOfUseUrl: 'mock://fake-url/terms',
|
||||
privacyPolicyUrl: 'mock://fake-url/privacy',
|
||||
mfa: {
|
||||
policy: MfaPolicy.UserControlled,
|
||||
factors: [],
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* @fileoverview Tests for overriding sign-in experience settings via `override` parameter.
|
||||
*/
|
||||
|
||||
import { ConnectorType } from '@logto/connector-kit';
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
|
||||
import { demoAppUrl } from '#src/constants.js';
|
||||
import { clearConnectorsByTypes } from '#src/helpers/connector.js';
|
||||
import { OrganizationApiTest } from '#src/helpers/organization.js';
|
||||
import ExpectExperience from '#src/ui-helpers/expect-experience.js';
|
||||
|
||||
describe('override', () => {
|
||||
const organizationApi = new OrganizationApiTest();
|
||||
|
||||
afterEach(async () => {
|
||||
await organizationApi.cleanUp();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email, ConnectorType.Sms]);
|
||||
await updateSignInExperience({
|
||||
termsOfUseUrl: null,
|
||||
privacyPolicyUrl: null,
|
||||
color: { primaryColor: '#000', darkPrimaryColor: '#fff', isDarkModeEnabled: true },
|
||||
signUp: { identifiers: [], password: true, verify: false },
|
||||
signIn: {
|
||||
methods: [
|
||||
{
|
||||
identifier: SignInIdentifier.Username,
|
||||
password: true,
|
||||
verificationCode: false,
|
||||
isPasswordPrimary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show the overridden organization logos', async () => {
|
||||
const logoUrl = 'mock://fake-url/logo.png';
|
||||
const darkLogoUrl = 'mock://fake-url/dark-logo.png';
|
||||
|
||||
const organization = await organizationApi.create({
|
||||
name: 'override-organization',
|
||||
branding: {
|
||||
logoUrl,
|
||||
darkLogoUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const experience = new ExpectExperience(await browser.newPage());
|
||||
await experience.page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'light' }]);
|
||||
await experience.navigateTo(demoAppUrl.href + `?organization_id=${organization.id}`);
|
||||
await experience.toMatchElement(`img[src="${logoUrl}"]`);
|
||||
|
||||
await experience.page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
|
||||
await experience.navigateTo(demoAppUrl.href + `?organization_id=${organization.id}`);
|
||||
await experience.toMatchElement(`img[src="${darkLogoUrl}"]`);
|
||||
});
|
||||
});
|
|
@ -38,6 +38,14 @@ const organization_details = {
|
|||
custom_data_tip:
|
||||
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
|
||||
invalid_json_object: 'Invalid JSON object.',
|
||||
branding: {
|
||||
title: 'Branding',
|
||||
description:
|
||||
'Customize the branding of the organization. The branding can be used in the sign-in experience or for your own reference.',
|
||||
light_logo: 'Organization logo',
|
||||
dark_logo: 'Organization logo (dark)',
|
||||
logo_upload_description: 'Click or drop an image to upload',
|
||||
},
|
||||
jit: {
|
||||
title: 'Just-in-time provisioning',
|
||||
description:
|
||||
|
|
|
@ -23,7 +23,7 @@ const sign_in_exp = {
|
|||
color: {
|
||||
title: 'COLOR',
|
||||
primary_color: 'Brand color',
|
||||
dark_primary_color: 'Brand color (Dark)',
|
||||
dark_primary_color: 'Brand color (dark)',
|
||||
dark_mode: 'Enable dark mode',
|
||||
dark_mode_description:
|
||||
'Your app will have an auto-generated dark mode theme based on your brand color and Logto algorithm. You are free to customize.',
|
||||
|
@ -36,7 +36,7 @@ const sign_in_exp = {
|
|||
favicon: 'Favicon',
|
||||
logo_image_url: 'App logo image URL',
|
||||
logo_image_url_placeholder: 'https://your.cdn.domain/logo.png',
|
||||
dark_logo_image_url: 'App logo image URL (Dark)',
|
||||
dark_logo_image_url: 'App logo image URL (dark)',
|
||||
dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png',
|
||||
logo_image: 'App logo',
|
||||
dark_logo_image: 'App logo (Dark)',
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { sql } from '@silverhand/slonik';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await pool.query(sql`
|
||||
alter table organizations add column branding jsonb not null default '{}'::jsonb;
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`
|
||||
alter table organizations drop column branding;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { type CustomClientMetadata } from '../foundations/index.js';
|
||||
import { type ToZodObject } from '../utils/zod.js';
|
||||
|
||||
import { inSeconds } from './date.js';
|
||||
|
||||
|
@ -35,6 +36,11 @@ export enum ExtraParamsKey {
|
|||
* - `sso:<connector-id>` (Use the specified SSO connector, e.g. `sso:123456`)
|
||||
*/
|
||||
DirectSignIn = 'direct_sign_in',
|
||||
/**
|
||||
* Override the default sign-in experience configuration with the settings from the specified
|
||||
* organization ID.
|
||||
*/
|
||||
OrganizationId = 'organization_id',
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link FirstScreen} instead. */
|
||||
|
@ -53,7 +59,13 @@ export const extraParamsObjectGuard = z
|
|||
[ExtraParamsKey.InteractionMode]: z.nativeEnum(InteractionMode),
|
||||
[ExtraParamsKey.FirstScreen]: z.nativeEnum(FirstScreen),
|
||||
[ExtraParamsKey.DirectSignIn]: z.string(),
|
||||
[ExtraParamsKey.OrganizationId]: z.string(),
|
||||
})
|
||||
.partial();
|
||||
.partial() satisfies ToZodObject<ExtraParamsObject>;
|
||||
|
||||
export type ExtraParamsObject = z.infer<typeof extraParamsObjectGuard>;
|
||||
export type ExtraParamsObject = Partial<{
|
||||
[ExtraParamsKey.InteractionMode]: InteractionMode;
|
||||
[ExtraParamsKey.FirstScreen]: FirstScreen;
|
||||
[ExtraParamsKey.DirectSignIn]: string;
|
||||
[ExtraParamsKey.OrganizationId]: string;
|
||||
}>;
|
||||
|
|
|
@ -2,6 +2,7 @@ import { hexColorRegEx } from '@logto/core-kit';
|
|||
import { languageTagGuard } from '@logto/language-kit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Theme } from '../../types/theme.js';
|
||||
import { type ToZodObject } from '../../utils/zod.js';
|
||||
|
||||
export const colorGuard = z.object({
|
||||
|
@ -12,6 +13,12 @@ export const colorGuard = z.object({
|
|||
|
||||
export type Color = z.infer<typeof colorGuard>;
|
||||
|
||||
/** Maps a theme to the key of the logo URL in the {@link Branding} object. */
|
||||
export const themeToLogoKey = Object.freeze({
|
||||
[Theme.Light]: 'logoUrl',
|
||||
[Theme.Dark]: 'darkLogoUrl',
|
||||
} satisfies Record<Theme, keyof Branding>);
|
||||
|
||||
export const brandingGuard = z.object({
|
||||
logoUrl: z.string().url().optional(),
|
||||
darkLogoUrl: z.string().url().optional(),
|
||||
|
|
|
@ -10,6 +10,5 @@ create table application_sign_in_experiences (
|
|||
terms_of_use_url varchar(2048),
|
||||
privacy_policy_url varchar(2048),
|
||||
display_name varchar(256),
|
||||
|
||||
primary key (tenant_id, application_id)
|
||||
);
|
||||
|
|
|
@ -14,6 +14,8 @@ create table organizations (
|
|||
custom_data jsonb /* @use JsonObject */ not null default '{}'::jsonb,
|
||||
/** Whether multi-factor authentication configuration is required for the members of the organization. */
|
||||
is_mfa_required boolean not null default false,
|
||||
/** The organization's branding configuration. */
|
||||
branding jsonb /* @use Branding */ not null default '{}'::jsonb,
|
||||
/** When the organization was created. */
|
||||
created_at timestamptz not null default(now()),
|
||||
primary key (id)
|
||||
|
|
Loading…
Add table
Reference in a new issue