Skip to content

Commit

Permalink
feat: organization logo
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun committed Jul 7, 2024
1 parent 1b7418c commit bb8016d
Show file tree
Hide file tree
Showing 24 changed files with 368 additions and 37 deletions.
33 changes: 33 additions & 0 deletions .changeset/dull-goats-help.md
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions .changeset/lazy-geese-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@logto/demo-app": minor
---

support extra token params in dev panel
13 changes: 13 additions & 0 deletions packages/console/src/hooks/use-user-assets-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
gap: _.unit(3);
}

.branding {
section + section {
margin-top: _.unit(6);
}
}

.mfaWarning {
margin-top: _.unit(3);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -136,6 +137,7 @@ function Settings() {
)}
</FormField>
</FormCard>
<Branding form={form} />
<JitSettings form={form} />
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
</DetailsForm>
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/libraries/sign-in-experience/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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();

Expand All @@ -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];

Expand Down Expand Up @@ -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([
{
Expand Down
43 changes: 35 additions & 8 deletions packages/core/src/libraries/sign-in-experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -37,6 +39,7 @@ export const createSignInExperienceLibrary = (
const {
customPhrases: { findAllCustomLanguageTags },
signInExperiences: { findDefaultSignInExperience, updateDefaultSignInExperience },
organizations,
} = queries;

const validateLanguageInfo = async (languageInfo: LanguageInfo) => {
Expand Down Expand Up @@ -109,12 +112,36 @@ 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 };

Check warning on line 128 in packages/core/src/libraries/sign-in-experience/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/sign-in-experience/index.ts#L125-L128

Added lines #L125 - L128 were not covered by tests
};

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
Expand Down Expand Up @@ -167,7 +194,7 @@ export const createSignInExperienceLibrary = (
};

return {
...signInExperience,
...deepmerge(signInExperience, organizationOverride ?? {}),
socialConnectors,
ssoConnectors,
forgotPassword,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/oidc/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Check warning on line 108 in packages/core/src/oidc/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/utils.ts#L107-L108

Added lines #L107 - L108 were not covered by tests

return firstScreen + getSearchParamString();
};
11 changes: 8 additions & 3 deletions packages/core/src/routes/well-known.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
}
Expand Down
19 changes: 17 additions & 2 deletions packages/demo-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'>>();
Expand Down Expand Up @@ -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) => {
Expand Down
18 changes: 16 additions & 2 deletions packages/demo-app/src/DevPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Loading

0 comments on commit bb8016d

Please sign in to comment.