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 1fa9f85 commit 0558dc0
Show file tree
Hide file tree
Showing 20 changed files with 271 additions and 32 deletions.
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 @@ -48,6 +49,8 @@ function Settings() {

const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
console.log('data', data, 'isSubmitting', isSubmitting);

if (isSubmitting) {
return;
}
Expand Down Expand Up @@ -79,6 +82,8 @@ function Settings() {
})
);

console.log('isSubmitting', isSubmitting, 'errors', errors);

return (
<DetailsForm
isDirty={isDirty}
Expand Down Expand Up @@ -136,6 +141,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
50 changes: 42 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,43 @@ 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 parsing the override string. If the
* override is not provided, or is not in the correct format, or the entity is not found, return
* `undefined`.
*/
const getOverride = async (override?: string): Promise<Partial<SignInExperience> | undefined> => {
if (!override) {
return;
}

const [type, id] = override.split(':');

Check warning on line 125 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

Added line #L125 was not covered by tests
if (type !== 'organization' || !id) {
return;
}

const organization = await trySafe(organizations.findById(id));

Check warning on line 130 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#L127-L130

Added lines #L127 - L130 were not covered by tests
if (!organization?.branding) {
return;
}

return { branding: organization.branding };

Check warning on line 135 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#L132-L135

Added lines #L132 - L135 were not covered by tests
};

const getFullSignInExperience = async ({
locale,
override,
}: {
locale: string;
override?: string;
}): Promise<FullSignInExperience> => {
const [signInExperience, logtoConnectors, isDevelopmentTenant, overrideData] =
await Promise.all([
findDefaultSignInExperience(),
getLogtoConnectors(),
getIsDevelopmentTenant(),
getOverride(override),
]);

// Always return empty array if single-sign-on is disabled
const ssoConnectors = signInExperience.singleSignOnEnabled
Expand Down Expand Up @@ -167,7 +201,7 @@ export const createSignInExperienceLibrary = (
};

return {
...signInExperience,
...deepmerge(signInExperience, overrideData ?? {}),
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();
};
9 changes: 7 additions & 2 deletions packages/core/src/routes/well-known.ts
Original file line number Diff line number Diff line change
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({ override: z.string().optional() }),
response: guardFullSignInExperience,
status: 200,
}),
async (ctx, next) => {
ctx.body = await getFullSignInExperience(ctx.locale);
const { override } = ctx.guard.query;
ctx.body = await getFullSignInExperience({ locale: ctx.locale, override });

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 0558dc0

Please sign in to comment.