From 4a8b7c064862b49afd51005b553175308c73b8c4 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 8 Jul 2024 16:52:15 +0800 Subject: [PATCH] feat: support app-level branding --- .vscode/settings.json | 3 +- .../Checkbox/Checkbox/index.module.scss | 1 + .../src/ds-components/ColorPicker/index.tsx | 4 +- .../Branding/LogoUploader.tsx | 4 +- .../Branding/index.module.scss | 5 + .../Branding/index.tsx | 136 +++++++++++++----- .../ApplicationDetailsContent/index.tsx | 50 ++++--- packages/core/src/caches/well-known.ts | 5 + .../sign-in-experience/index.test.ts | 10 +- .../src/libraries/sign-in-experience/index.ts | 55 +++++-- packages/core/src/oidc/init.ts | 1 + packages/core/src/oidc/utils.test.ts | 18 ++- packages/core/src/oidc/utils.ts | 4 + .../application-sign-in-experience.ts | 6 +- packages/core/src/routes/well-known.ts | 8 +- packages/core/src/tenants/Libraries.ts | 3 +- packages/demo-app/src/App.tsx | 4 +- packages/demo-app/src/DevPanel.tsx | 10 ++ packages/demo-app/src/utils.ts | 2 + packages/experience/src/apis/settings.ts | 15 +- .../LogtoSignature/index.module.scss | 4 + packages/experience/src/index.html | 13 +- .../experience/src/utils/search-parameters.ts | 2 + .../application-sign-in-experience.test.ts | 13 -- .../src/tests/experience/overrides.test.ts | 113 ++++++++++++++- .../admin-console/application-details.ts | 12 +- .../admin-console/sign-in-exp/index.ts | 2 +- .../next-1720345784-add-color-to-app-sie.ts | 18 +++ .../jsonb-types/sign-in-experience.ts | 4 + .../application_sign_in_experiences.sql | 1 + 30 files changed, 398 insertions(+), 128 deletions(-) create mode 100644 packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Branding/index.module.scss create mode 100644 packages/schemas/alterations/next-1720345784-add-color-to-app-sie.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 805805faa..87d0de73e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -55,6 +55,7 @@ "topbar", "upsell", "withtyped", - "backchannel" + "backchannel", + "deepmerge" ] } diff --git a/packages/console/src/ds-components/Checkbox/Checkbox/index.module.scss b/packages/console/src/ds-components/Checkbox/Checkbox/index.module.scss index 1d9558468..8cf75f171 100644 --- a/packages/console/src/ds-components/Checkbox/Checkbox/index.module.scss +++ b/packages/console/src/ds-components/Checkbox/Checkbox/index.module.scss @@ -60,6 +60,7 @@ display: flex; align-items: center; gap: _.unit(1); + user-select: none; } &.disabled { diff --git a/packages/console/src/ds-components/ColorPicker/index.tsx b/packages/console/src/ds-components/ColorPicker/index.tsx index f075248e4..f8d430233 100644 --- a/packages/console/src/ds-components/ColorPicker/index.tsx +++ b/packages/console/src/ds-components/ColorPicker/index.tsx @@ -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(null); const [isOpen, setIsOpen] = useState(false); @@ -29,6 +30,7 @@ function ColorPicker({ onChange, value = '#000000' }: Props) { setIsOpen(true); })} > + {value.toUpperCase()} { @@ -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={() => { diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Branding/index.module.scss b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Branding/index.module.scss new file mode 100644 index 000000000..f99c87282 --- /dev/null +++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Branding/index.module.scss @@ -0,0 +1,5 @@ +@use '@/scss/underscore' as _; + +.colors { + margin-top: _.unit(6); +} diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Branding/index.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Branding/index.tsx index f25778366..87a8c796c 100644 --- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Branding/index.tsx +++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Branding/index.tsx @@ -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 ; } - // Show error details if the error is not 404 if (error && error.status !== 404) { return ; } @@ -109,23 +117,31 @@ function Branding({ application, isActive }: Props) { > - - - + {application.isThirdParty && ( + + + + )} {isUserAssetsServiceReady && ( - + )} {/* Display the TextInput field if image upload service is not available */} {!isUserAssetsServiceReady && ( - + @@ -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 && ( - + @@ -149,32 +165,76 @@ function Branding({ application, isActive }: Props) { /> )} + {!application.isThirdParty && ( +
+ { + setValue( + 'color', + value + ? { + primaryColor: '#ffffff', + darkPrimaryColor: '#000000', + } + : {}, + { shouldDirty: true } + ); + }} + /> + {!isColorEmpty && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + + )} +
+ )}
- - - - !value || uriValidator(value) || t('errors.invalid_uri_format'), - })} - error={errors.termsOfUseUrl?.message} - placeholder="https://" - /> - - - - !value || uriValidator(value) || t('errors.invalid_uri_format'), - })} - error={errors.privacyPolicyUrl?.message} - placeholder="https://" - /> - - + {application.isThirdParty && ( + + + + !value || uriValidator(value) || t('errors.invalid_uri_format'), + })} + error={errors.termsOfUseUrl?.message} + placeholder="https://" + /> + + + + !value || uriValidator(value) || t('errors.invalid_uri_format'), + })} + error={errors.privacyPolicyUrl?.message} + placeholder="https://" + /> + + + )} {isActive && } diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx index da6d0836b..ec4edd57a 100644 --- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx +++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx @@ -185,14 +185,16 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P )} {data.isThirdParty && ( - <> - - {t('application_details.permissions.name')} - - - {t('application_details.branding.name')} - - + + {t('application_details.permissions.name')} + + )} + {[ApplicationType.Native, ApplicationType.SPA, ApplicationType.Traditional].includes( + data.type + ) && ( + + {t('application_details.branding.name')} + )} )} {data.isThirdParty && ( - <> - - - - - {/* isActive is needed to support conditional render UnsavedChangesAlertModal */} - - - + + + + )} + {[ApplicationType.Native, ApplicationType.SPA, ApplicationType.Traditional].includes( + data.type + ) && ( + + {/* isActive is needed to support conditional render UnsavedChangesAlertModal */} + + )} ); diff --git a/packages/core/src/caches/well-known.ts b/packages/core/src/caches/well-known.ts index 044d99b5e..df2536687 100644 --- a/packages/core/src/caches/well-known.ts +++ b/packages/core/src/caches/well-known.ts @@ -13,6 +13,8 @@ type WellKnownMap = { 'custom-phrases': Record; '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 { jest.clearAllMocks(); diff --git a/packages/core/src/libraries/sign-in-experience/index.ts b/packages/core/src/libraries/sign-in-experience/index.ts index a50519d42..0d1724080 100644 --- a/packages/core/src/libraries/sign-in-experience/index.ts +++ b/packages/core/src/libraries/sign-in-experience/index.ts @@ -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 => { + const getIsDevelopmentTenant = wellKnownCache.memoize(async (): Promise => { 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 => { - 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, diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 9e6a12f6c..a90250973 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -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({ diff --git a/packages/core/src/oidc/utils.test.ts b/packages/core/src/oidc/utils.test.ts index 7632bd6f2..8d7c2b951 100644 --- a/packages/core/src/oidc/utils.test.ts +++ b/packages/core/src/oidc/utils.test.ts @@ -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'); }); }); diff --git a/packages/core/src/oidc/utils.ts b/packages/core/src/oidc/utils.ts index 468c3483f..276b56bb1 100644 --- a/packages/core/src/oidc/utils.ts +++ b/packages/core/src/oidc/utils.ts @@ -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(':'); diff --git a/packages/core/src/routes/applications/application-sign-in-experience.ts b/packages/core/src/routes/applications/application-sign-in-experience.ts index b5c8db0db..618cb8bf3 100644 --- a/packages/core/src/routes/applications/application-sign-in-experience.ts +++ b/packages/core/src/routes/applications/application-sign-in-experience.ts @@ -21,9 +21,6 @@ function applicationSignInExperienceRoutes( updateByApplicationId, }, }, - libraries: { - applications: { validateThirdPartyApplicationById }, - }, }, ]: RouterInitArgs ) { @@ -31,7 +28,6 @@ function applicationSignInExperienceRoutes( * 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( body, } = ctx.guard; - await validateThirdPartyApplicationById(applicationId); + await findApplicationById(applicationId); const applicationSignInExperience = await safeFindSignInExperienceByApplicationId( applicationId diff --git a/packages/core/src/routes/well-known.ts b/packages/core/src/routes/well-known.ts index 93e771d46..3738c0f74 100644 --- a/packages/core/src/routes/well-known.ts +++ b/packages/core/src/routes/well-known.ts @@ -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( 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(); } diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts index 92f5a3bae..15a4eec2d 100644 --- a/packages/core/src/tenants/Libraries.ts +++ b/packages/core/src/tenants/Libraries.ts @@ -46,7 +46,8 @@ export default class Libraries { this.queries, this.connectors, this.ssoConnectors, - this.cloudConnection + this.cloudConnection, + this.queries.wellKnownCache ); organizationInvitations = new OrganizationInvitationLibrary( diff --git a/packages/demo-app/src/App.tsx b/packages/demo-app/src/App.tsx index 5a83a7fe2..7e49e0f55 100644 --- a/packages/demo-app/src/App.tsx +++ b/packages/demo-app/src/App.tsx @@ -170,13 +170,15 @@ const Main = () => { }; const App = () => { + const params = new URL(window.location.href).searchParams; const config = getLocalData('config'); return ( {
Logto config
+
+
App ID
+ +
Sign-in extra params
; diff --git a/packages/experience/src/apis/settings.ts b/packages/experience/src/apis/settings.ts index 1ca7db41a..52d1395c3 100644 --- a/packages/experience/src/apis/settings.ts +++ b/packages/experience/src/apis/settings.ts @@ -18,12 +18,21 @@ const buildSearchParameters = (record: Record> 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 (): Promise => { 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(); }; diff --git a/packages/experience/src/components/LogtoSignature/index.module.scss b/packages/experience/src/components/LogtoSignature/index.module.scss index 9191d5ae8..d629966e0 100644 --- a/packages/experience/src/components/LogtoSignature/index.module.scss +++ b/packages/experience/src/components/LogtoSignature/index.module.scss @@ -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; } diff --git a/packages/experience/src/index.html b/packages/experience/src/index.html index 7909bc311..e317d1415 100644 --- a/packages/experience/src/index.html +++ b/packages/experience/src/index.html @@ -5,18 +5,17 @@ - +