mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
feat(console): google one tap (#6034)
This commit is contained in:
parent
b96848b01d
commit
0ef712e4ea
14 changed files with 193 additions and 39 deletions
5
.changeset/mean-pumpkins-scream.md
Normal file
5
.changeset/mean-pumpkins-scream.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/console": minor
|
||||
---
|
||||
|
||||
support Google One Tap configuration
|
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
|
@ -0,0 +1,11 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.figure {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.oneTapConfig {
|
||||
display: flex;
|
||||
gap: _.unit(2);
|
||||
flex-direction: column;
|
||||
}
|
|
@ -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;
|
|
@ -6,6 +6,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: _.unit(2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.description {
|
||||
|
|
|
@ -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 */
|
||||
|
@ -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"
|
||||
|
|
|
@ -54,6 +54,7 @@
|
|||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
gap: _.unit(6);
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-neutral-90);
|
||||
border-radius: _.unit(2);
|
||||
|
@ -61,8 +62,10 @@
|
|||
|
||||
.label {
|
||||
flex: 1;
|
||||
margin-right: _.unit(2);
|
||||
font: var(--font-body-2);
|
||||
display: flex;
|
||||
gap: _.unit(6);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.error {
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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' });
|
||||
|
@ -16,7 +16,7 @@ const useJsonStringConfigParser = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const result = safeParseJson(config);
|
||||
const result = safeParseJsonObject(config);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
|
|
|
@ -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';
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
? {
|
||||
|
@ -165,6 +150,7 @@ function ConnectorContent({ isDeleted, connectorData, onConnectorUpdated }: Prop
|
|||
/>
|
||||
</FormCard>
|
||||
)}
|
||||
{connectorId === GoogleConnector.factoryId && <GoogleOneTapCard />}
|
||||
{!isSocialConnector && (
|
||||
<FormCard title="connector_details.test_connection">
|
||||
<ConnectorTester
|
||||
|
|
|
@ -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<
|
||||
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -91,5 +92,6 @@ export const convertFactoryResponseToForm = (
|
|||
syncProfile: SyncProfileMode.OnlyAtRegister,
|
||||
jsonConfig,
|
||||
formConfig,
|
||||
rawConfig: {},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Reference in a new issue