Skip to content

Commit

Permalink
feat(console): google one tap (#6034)
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun authored Jun 18, 2024
1 parent b96848b commit 0ef712e
Show file tree
Hide file tree
Showing 14 changed files with 193 additions and 39 deletions.
5 changes: 5 additions & 0 deletions .changeset/mean-pumpkins-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@logto/console": minor
---

support Google One Tap configuration
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@use '@/scss/underscore' as _;

.figure {
width: 200px;
}

.oneTapConfig {
display: flex;
gap: _.unit(2);
flex-direction: column;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { type GoogleConnectorConfig } from '@logto/connector-kit';
import { Theme } from '@logto/schemas';
import { Controller, useFormContext } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';

import FormCard from '@/components/FormCard';
import Checkbox from '@/ds-components/Checkbox';
import FormField from '@/ds-components/FormField';
import Switch from '@/ds-components/Switch';
import TextLink from '@/ds-components/TextLink';
import useTheme from '@/hooks/use-theme';

import figureDark from './figure-dark.webp';
import figureLight from './figure-light.webp';
import * as styles from './index.module.scss';

type FormContext = { rawConfig: { oneTap: GoogleConnectorConfig['oneTap'] } };

const themeToFigure = Object.freeze({
[Theme.Light]: figureLight,
[Theme.Dark]: figureDark,
} satisfies Record<Theme, string>);

/**
* A card for configuring Google One Tap. It requires the `rawConfig.oneTap` field in the form
* context which can usually be obtained from the connector configuration context.
*/
function GoogleOneTapCard() {
const { t } = useTranslation(undefined, {
keyPrefix: 'admin_console.connector_details.google_one_tap',
});
const { register, control, watch } = useFormContext<FormContext>();
const isEnabled = watch('rawConfig.oneTap.isEnabled');
const theme = useTheme();

return (
<FormCard
title="connector_details.google_one_tap.title"
description="connector_details.google_one_tap.description"
>
<FormField title="connector_details.google_one_tap.enable_google_one_tap">
<Switch
description={
<>
<img
className={styles.figure}
src={themeToFigure[theme]}
alt="Google One Tap figure"
/>
{t('enable_google_one_tap_description')}
</>
}
{...register('rawConfig.oneTap.isEnabled')}
/>
</FormField>
{isEnabled && (
<FormField
title="connector_details.google_one_tap.configure_google_one_tap"
className={styles.oneTapConfig}
>
<Controller
name="rawConfig.oneTap.autoSelect"
control={control}
defaultValue={false}
render={({ field }) => (
<Checkbox label={t('auto_select')} checked={field.value} onChange={field.onChange} />
)}
/>
<Controller
defaultValue
name="rawConfig.oneTap.closeOnTapOutside"
control={control}
render={({ field }) => (
<Checkbox
label={t('close_on_tap_outside')}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
<Controller
defaultValue
name="rawConfig.oneTap.itpSupport"
control={control}
render={({ field }) => (
<Checkbox
label={
<Trans
components={{
a: (
<TextLink
href="https://developers.google.com/identity/gsi/web/guides/features#upgraded_ux_on_itp_browsers"
targetBlank="noopener"
/>
),
}}
i18nKey="admin_console.connector_details.google_one_tap.itp_support"
/>
}
checked={field.value}
onChange={field.onChange}
/>
)}
/>
</FormField>
)}
</FormCard>
);
}

export default GoogleOneTapCard;
1 change: 1 addition & 0 deletions packages/console/src/components/FormCard/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
display: flex;
align-items: center;
gap: _.unit(2);
word-break: break-word;
}

.description {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as styles from './index.module.scss';

type Props = {
/* eslint-disable react/boolean-prop-naming */
readonly checked: boolean;
readonly checked?: boolean;
readonly disabled?: boolean;
readonly indeterminate?: boolean;
/* eslint-enable react/boolean-prop-naming */
Expand Down Expand Up @@ -61,7 +61,7 @@ function Checkbox({
<svg
className={classNames(
styles.icon,
(checked || isIndeterminate) && styles.checked,
(Boolean(checked) || isIndeterminate) && styles.checked,
disabled && styles.disabled
)}
width="20"
Expand Down
5 changes: 4 additions & 1 deletion packages/console/src/ds-components/Switch/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,18 @@

.wrapper {
display: flex;
gap: _.unit(6);
align-items: center;
border: 1px solid var(--color-neutral-90);
border-radius: _.unit(2);
padding: _.unit(4);

.label {
flex: 1;
margin-right: _.unit(2);
font: var(--font-body-2);
display: flex;
gap: _.unit(6);
align-items: center;
}

&.error {
Expand Down
34 changes: 27 additions & 7 deletions packages/console/src/ds-components/Switch/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
import { type AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import type { HTMLProps, ReactNode, Ref } from 'react';
import type { HTMLProps, ReactElement, ReactNode, Ref } from 'react';
import { forwardRef } from 'react';

import DynamicT from '../DynamicT';

import * as styles from './index.module.scss';

type Props = Omit<HTMLProps<HTMLInputElement>, 'label'> & {
readonly label?: ReactNode;
readonly hasError?: boolean;
};
type Props =
| (Omit<HTMLProps<HTMLInputElement>, 'label'> & {
/** @deprecated Use `description` instead */
readonly label?: ReactNode;
readonly hasError?: boolean;
})
| (HTMLProps<HTMLInputElement> & {
readonly description: AdminConsoleKey | ReactElement;
readonly hasError?: boolean;
});

function Switch(props: Props, ref?: Ref<HTMLInputElement>) {
const { label, hasError, ...rest } = props;

function Switch({ label, hasError, ...rest }: Props, ref?: Ref<HTMLInputElement>) {
return (
<div className={classNames(styles.wrapper, hasError && styles.error)}>
<div className={styles.label}>{label}</div>
{'description' in props && (
<div className={styles.label}>
{typeof props.description === 'string' ? (
<DynamicT forKey={props.description} />
) : (
props.description
)}
</div>
)}
{label && <div className={styles.label}>{label}</div>}
<label className={styles.switch}>
<input type="checkbox" {...rest} ref={ref} />
<span className={styles.slider} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';

import type { ConnectorFormType } from '@/types/connector';
import { parseFormConfig } from '@/utils/connector-form';
import { safeParseJson } from '@/utils/json';
import { safeParseJsonObject } from '@/utils/json';

const useJsonStringConfigParser = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
Expand All @@ -16,7 +16,7 @@ const useJsonStringConfigParser = () => {
return;
}

const result = safeParseJson(config);
const result = safeParseJsonObject(config);

if (!result.success) {
toast.error(result.error);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { ServiceConnector } from '@logto/connector-kit';
import { ServiceConnector, GoogleConnector } from '@logto/connector-kit';
import { ConnectorType } from '@logto/schemas';
import type { ConnectorResponse } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';

import BasicForm from '@/components/ConnectorForm/BasicForm';
import ConfigForm from '@/components/ConnectorForm/ConfigForm';
import GoogleOneTapCard from '@/components/ConnectorForm/GoogleOneTapCard';
import ConnectorTester from '@/components/ConnectorTester';
import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard';
Expand All @@ -34,18 +35,10 @@ function ConnectorContent({ isDeleted, connectorData, onConnectorUpdated }: Prop
const { getDocumentationUrl } = useDocumentationUrl();
const api = useApi();
const formData = useMemo(() => convertResponseToForm(connectorData), [connectorData]);

const methods = useForm<ConnectorFormType>({
reValidateMode: 'onBlur',
defaultValues: {
...formData,
/**
* Note:
* The `formConfig` will be setup in the `useEffect` hook since react-hook-form's `useForm` hook infers `Record<string, unknown>` to `{ [x: string]: {} | undefined }` incorrectly,
* this causes we cannot apply the default value of `formConfig` to the form.
*/
formConfig: {},
},
// eslint-disable-next-line no-restricted-syntax -- The original type will cause "infinitely deep type" error.
defaultValues: formData as Record<string, unknown>,
});

const {
Expand All @@ -67,23 +60,15 @@ function ConnectorContent({ isDeleted, connectorData, onConnectorUpdated }: Prop
const isSocialConnector = connectorType === ConnectorType.Social;
const isEmailServiceConnector = connectorId === ServiceConnector.Email;

useEffect(() => {
/**
* Note: should not refresh form data when the form is dirty.
*/
if (isDirty) {
return;
}
reset(formData);
}, [formData, isDirty, reset]);

const configParser = useConnectorFormConfigParser();

const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
const { formItems, isStandard, id } = connectorData;
const config = configParser(data, formItems);
const { syncProfile, name, logo, logoDark, target } = data;
const { syncProfile, name, logo, logoDark, target, rawConfig } = data;
// Apply the raw config first to avoid losing data updated from other forms that are not
// included in the form items.
const config = { ...rawConfig, ...configParser(data, formItems) };

const payload = isSocialConnector
? {
Expand Down Expand Up @@ -165,6 +150,7 @@ function ConnectorContent({ isDeleted, connectorData, onConnectorUpdated }: Prop
/>
</FormCard>
)}
{connectorId === GoogleConnector.factoryId && <GoogleOneTapCard />}
{!isSocialConnector && (
<FormCard title="connector_details.test_connection">
<ConnectorTester
Expand Down
10 changes: 7 additions & 3 deletions packages/console/src/types/connector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ConnectorResponse } from '@logto/schemas';
import type { ConnectorResponse, JsonObject } from '@logto/schemas';
import { type Nullable } from '@silverhand/essentials';

export type ConnectorGroup<T = ConnectorResponse> = Pick<
Expand All @@ -20,6 +20,10 @@ export type ConnectorFormType = {
logoDark?: Nullable<string>;
target?: string;
syncProfile: SyncProfileMode;
jsonConfig: string; // Support editing configs by the code editor
formConfig: Record<string, unknown>; // Support custom connector config form
/** The raw config data in JSON string. Used for code editor. */
jsonConfig: string;
/** The form config data. Used for form rendering. */
formConfig: Record<string, unknown>;
/** The raw config data. */
rawConfig: JsonObject;
};
2 changes: 2 additions & 0 deletions packages/console/src/utils/connector-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const convertResponseToForm = (connector: ConnectorResponse): ConnectorFo
syncProfile: syncProfile ? SyncProfileMode.EachSignIn : SyncProfileMode.OnlyAtRegister,
jsonConfig: JSON.stringify(config, null, 2),
formConfig,
rawConfig: config,
};
};

Expand All @@ -91,5 +92,6 @@ export const convertFactoryResponseToForm = (
syncProfile: SyncProfileMode.OnlyAtRegister,
jsonConfig,
formConfig,
rawConfig: {},
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ const connector_details = {
urls_not_allowed: 'URLs are not allowed',
test_notes: 'Logto uses the “Generic” template for testing.',
},
google_one_tap: {
title: 'Google One Tap',
description: 'Google One Tap is a secure and easy way for users to sign in to your website.',
enable_google_one_tap: 'Enable Google One Tap',
enable_google_one_tap_description:
"Enable Google One Tap in your sign-in experience: Let users quickly sign up or sign in with their Google account if they're already signed in on their device.",
configure_google_one_tap: 'Configure Google One Tap',
auto_select: 'Auto-select credential if possible',
close_on_tap_outside: 'Cancel the prompt if user click/tap outside',
itp_support: 'Enable <a>Upgraded One Tap UX on ITP browsers</a>',
},
};

export default Object.freeze(connector_details);

0 comments on commit 0ef712e

Please sign in to comment.