0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(experience): skip mfa binding (#4724)

This commit is contained in:
Xiao Yijun 2023-10-24 14:29:32 +08:00 committed by GitHub
parent 4e991c3083
commit 89e9b4810b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 282 additions and 107 deletions

View file

@ -80,7 +80,6 @@ const App = () => {
{isDevelopmentFeaturesEnabled && (
<>
{/* Mfa binding */}
{/* Todo @xiaoyijun reorg these routes when factors are all implemented */}
<Route path={UserMfaFlow.MfaBinding}>
<Route index element={<MfaBinding />} />
<Route path={MfaFactor.TOTP} element={<TotpBinding />} />
@ -89,7 +88,6 @@ const App = () => {
</Route>
{/* Mfa verification */}
{/* Todo @xiaoyijun reorg these routes when factors are all implemented */}
<Route path={UserMfaFlow.MfaVerification}>
<Route index element={<MfaVerification />} />
<Route path={MfaFactor.TOTP} element={<TotpVerification />} />

View file

@ -14,6 +14,7 @@ type Props = {
title: TFuncKey;
description?: TFuncKey | ReactElement | '';
titleProps?: Record<string, unknown>;
onSkip?: () => void;
descriptionProps?: Record<string, unknown>;
notification?: TFuncKey;
children: React.ReactNode;
@ -23,6 +24,7 @@ const SecondaryPageLayout = ({
title,
description,
titleProps,
onSkip,
descriptionProps,
notification,
children,
@ -32,7 +34,7 @@ const SecondaryPageLayout = ({
return (
<div className={styles.wrapper}>
<PageMeta titleKey={title} />
<NavBar />
<NavBar onSkip={onSkip} />
{isMobile && notification && (
<InlineNotification message={notification} className={styles.notification} />
)}
@ -51,7 +53,6 @@ const SecondaryPageLayout = ({
</div>
)}
</div>
{children}
</div>
{!isMobile && notification && (

View file

@ -253,3 +253,6 @@ export const verifyMfa = async (payload: VerifyMfaPayload) => {
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
export const submitInteraction = async () =>
api.post(`${interactionPrefix}/submit`).json<Response>();

View file

@ -28,6 +28,17 @@
cursor: pointer;
}
.skipButton {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
font: var(--font-label-1);
cursor: pointer;
color: var(--color-type-link);
padding-right: _.unit(1);
}
:global(body.mobile) {
.navButton > span {
display: none;

View file

@ -12,9 +12,10 @@ type Props = {
title?: string;
type?: 'back' | 'close';
onClose?: () => void;
onSkip?: () => void;
};
const NavBar = ({ title, type = 'back', onClose }: Props) => {
const NavBar = ({ title, type = 'back', onClose, onSkip }: Props) => {
const navigate = useNavigate();
const { t } = useTranslation();
@ -49,6 +50,17 @@ const NavBar = ({ title, type = 'back', onClose }: Props) => {
{!isClosable && <span>{t('action.nav_back')}</span>}
</div>
{title && <div className={styles.title}>{title}</div>}
{onSkip && (
<div
role="button"
tabIndex={0}
className={styles.skipButton}
onKeyDown={onKeyDownHandler(onSkip)}
onClick={onSkip}
>
<span>{t('action.nav_skip')}</span>
</div>
)}
</div>
);
};

View file

@ -1,29 +1,35 @@
import { type MfaFactor } from '@logto/schemas';
import SwitchIcon from '@/assets/icons/switch-icon.svg';
import { UserMfaFlow } from '@/types';
import { type MfaFactorsState } from '@/types/guard';
import { type MfaFlowState } from '@/types/guard';
import TextLink from '../TextLink';
type Props = {
flow: UserMfaFlow;
factors: MfaFactor[];
flowState: MfaFlowState;
className?: string;
};
const SwitchMfaFactorsLink = ({ flow, factors, className }: Props) => (
<TextLink
to={`/${flow}`}
text={
flow === UserMfaFlow.MfaBinding
? 'mfa.link_another_mfa_factor'
: 'mfa.try_another_verification_method'
}
className={className}
icon={<SwitchIcon />}
state={{ availableFactors: factors } satisfies MfaFactorsState}
/>
);
const SwitchMfaFactorsLink = ({ flow, flowState, className }: Props) => {
const { availableFactors } = flowState;
if (availableFactors.length < 2) {
return null;
}
return (
<TextLink
to={`/${flow}`}
text={
flow === UserMfaFlow.MfaBinding
? 'mfa.link_another_mfa_factor'
: 'mfa.try_another_verification_method'
}
className={className}
icon={<SwitchIcon />}
state={flowState}
/>
);
};
export default SwitchMfaFactorsLink;

View file

@ -5,36 +5,35 @@ import { useNavigate } from 'react-router-dom';
import MfaFactorButton from '@/components/Button/MfaFactorButton';
import useStartTotpBinding from '@/hooks/use-start-totp-binding';
import { UserMfaFlow } from '@/types';
import { type MfaFactorsState } from '@/types/guard';
import { type MfaFlowState } from '@/types/guard';
import * as styles from './index.module.scss';
type Props = {
flow: UserMfaFlow;
factors: MfaFactor[];
flowState: MfaFlowState;
};
const MfaFactorList = ({ flow, factors }: Props) => {
const MfaFactorList = ({ flow, flowState }: Props) => {
const startTotpBinding = useStartTotpBinding();
const navigate = useNavigate();
const { availableFactors } = flowState;
const handleSelectFactor = useCallback(
(factor: MfaFactor) => {
if (factor === MfaFactor.TOTP && flow === UserMfaFlow.MfaBinding) {
void startTotpBinding(factors);
void startTotpBinding(flowState);
return;
}
navigate(`/${flow}/${factor}`, {
state: { availableFactors: factors } satisfies MfaFactorsState,
});
navigate(`/${flow}/${factor}`, { state: flowState });
},
[factors, flow, navigate, startTotpBinding]
[flow, flowState, navigate, startTotpBinding]
);
return (
<div className={styles.factorList}>
{factors.map((factor) => (
{availableFactors.map((factor) => (
<MfaFactorButton
key={factor}
factor={factor}

View file

@ -5,7 +5,7 @@ import { validate } from 'superstruct';
import { UserMfaFlow } from '@/types';
import {
type MfaFactorsState,
type MfaFlowState,
mfaErrorDataGuard,
backupCodeErrorDataGuard,
type BackupCodeBindingState,
@ -25,13 +25,11 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
const startTotpBinding = useStartTotpBinding({ replace });
const handleMfaRedirect = useCallback(
(flow: UserMfaFlow, availableFactors: MfaFactor[]) => {
const mfaFactorsState: MfaFactorsState = {
availableFactors,
};
(flow: UserMfaFlow, state: MfaFlowState) => {
const { availableFactors } = state;
if (availableFactors.length > 1) {
navigate({ pathname: `/${flow}` }, { replace, state: mfaFactorsState });
navigate({ pathname: `/${flow}` }, { replace, state });
return;
}
@ -42,11 +40,11 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
}
if (factor === MfaFactor.TOTP && flow === UserMfaFlow.MfaBinding) {
void startTotpBinding(availableFactors);
void startTotpBinding(state);
return;
}
navigate({ pathname: `/${flow}/${factor}` }, { replace, state: mfaFactorsState });
navigate({ pathname: `/${flow}/${factor}` }, { replace, state });
},
[navigate, replace, startTotpBinding]
);
@ -56,13 +54,14 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
return (error: RequestErrorBody) => {
const [_, data] = validate(error.data, mfaErrorDataGuard);
const availableFactors = data?.availableFactors ?? [];
const skippable = data?.skippable;
if (availableFactors.length === 0) {
setToast(error.message);
return;
}
handleMfaRedirect(flow, availableFactors);
handleMfaRedirect(flow, { availableFactors, skippable });
};
},
[handleMfaRedirect, setToast]

View file

@ -1,13 +1,13 @@
import { useLocation } from 'react-router-dom';
import { validate } from 'superstruct';
import { mfaFactorsStateGuard } from '@/types/guard';
import { mfaFlowStateGuard } from '@/types/guard';
const useMfaFactorsState = () => {
const useMfaFlowState = () => {
const { state } = useLocation();
const [, mfaFactorsState] = validate(state, mfaFactorsStateGuard);
const [, mfaFlowState] = validate(state, mfaFlowStateGuard);
return mfaFactorsState;
return mfaFlowState;
};
export default useMfaFactorsState;
export default useMfaFlowState;

View file

@ -0,0 +1,28 @@
import { useCallback } from 'react';
import { submitInteraction } from '@/apis/interaction';
import useApi from './use-api';
import useErrorHandler from './use-error-handler';
import usePreSignInErrorHandler from './use-pre-sign-in-error-handler';
const useSkipMfa = () => {
const asyncSubmitInteraction = useApi(submitInteraction);
const handleError = useErrorHandler();
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
return useCallback(async () => {
const [error, result] = await asyncSubmitInteraction();
if (error) {
await handleError(error, preSignInErrorHandler);
return;
}
if (result) {
window.location.replace(result.redirectTo);
}
}, [asyncSubmitInteraction, handleError, preSignInErrorHandler]);
};
export default useSkipMfa;

View file

@ -6,7 +6,7 @@ import { createTotpSecret } from '@/apis/interaction';
import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler';
import { UserMfaFlow } from '@/types';
import { type TotpBindingState } from '@/types/guard';
import { type MfaFlowState, type TotpBindingState } from '@/types/guard';
type Options = {
replace?: boolean;
@ -19,7 +19,7 @@ const useStartTotpBinding = ({ replace }: Options = {}) => {
const handleError = useErrorHandler();
return useCallback(
async (availableFactors: MfaFactor[]) => {
async (flowState: MfaFlowState) => {
const [error, result] = await asyncCreateTotpSecret();
if (error) {
@ -33,7 +33,7 @@ const useStartTotpBinding = ({ replace }: Options = {}) => {
const state: TotpBindingState = {
secret,
secretQrCode,
availableFactors,
...flowState,
};
navigate({ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.TOTP}` }, { replace, state });
}

View file

@ -1,9 +1,11 @@
import { conditional } from '@silverhand/essentials';
import { useLocation } from 'react-router-dom';
import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import Divider from '@/components/Divider';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import useSkipMfa from '@/hooks/use-skip-mfa';
import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types';
import { totpBindingStateGuard } from '@/types/guard';
@ -15,15 +17,19 @@ import * as styles from './index.module.scss';
const TotpBinding = () => {
const { state } = useLocation();
const [, totpBindingState] = validate(state, totpBindingStateGuard);
const skipMfa = useSkipMfa();
if (!totpBindingState) {
return <ErrorPage title="error.invalid_session" />;
}
const { availableFactors } = totpBindingState;
const { availableFactors, skippable } = totpBindingState;
return (
<SecondaryPageLayout title="mfa.add_authenticator_app">
<SecondaryPageLayout
title="mfa.add_authenticator_app"
onSkip={conditional(skippable && skipMfa)}
>
<div className={styles.container}>
<SecretSection {...totpBindingState} />
<Divider />
@ -33,7 +39,7 @@ const TotpBinding = () => {
<Divider />
<SwitchMfaFactorsLink
flow={UserMfaFlow.MfaBinding}
factors={availableFactors}
flowState={totpBindingState}
className={styles.switchLink}
/>
</>

View file

@ -1,7 +1,10 @@
import { conditional } from '@silverhand/essentials';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import Button from '@/components/Button';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import useSkipMfa from '@/hooks/use-skip-mfa';
import useWebAuthnOperation from '@/hooks/use-webauthn-operation';
import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types';
@ -9,25 +12,28 @@ import { UserMfaFlow } from '@/types';
import * as styles from './index.module.scss';
const WebAuthnBinding = () => {
const mfaFactorsState = useMfaFactorsState();
const flowState = useMfaFlowState();
const bindWebAuthn = useWebAuthnOperation(UserMfaFlow.MfaBinding);
const skipMfa = useSkipMfa();
if (!mfaFactorsState) {
if (!flowState) {
return <ErrorPage title="error.invalid_session" />;
}
const { availableFactors } = mfaFactorsState;
const { skippable } = flowState;
return (
<SecondaryPageLayout title="mfa.create_a_passkey" description="mfa.create_passkey_description">
<SecondaryPageLayout
title="mfa.create_a_passkey"
description="mfa.create_passkey_description"
onSkip={conditional(skippable && skipMfa)}
>
<Button title="mfa.create_a_passkey" onClick={bindWebAuthn} />
{availableFactors.length > 1 && (
<SwitchMfaFactorsLink
flow={UserMfaFlow.MfaBinding}
factors={availableFactors}
className={styles.switchLink}
/>
)}
<SwitchMfaFactorsLink
flow={UserMfaFlow.MfaBinding}
flowState={flowState}
className={styles.switchLink}
/>
</SecondaryPageLayout>
);
};

View file

@ -1,20 +1,28 @@
import { conditional } from '@silverhand/essentials';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import MfaFactorList from '@/containers/MfaFactorList';
import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import useSkipMfa from '@/hooks/use-skip-mfa';
import { UserMfaFlow } from '@/types';
import ErrorPage from '../ErrorPage';
const MfaBinding = () => {
const { availableFactors } = useMfaFactorsState() ?? {};
const flowState = useMfaFlowState();
const skipMfa = useSkipMfa();
if (!availableFactors || availableFactors.length === 0) {
if (!flowState) {
return <ErrorPage title="error.invalid_session" />;
}
return (
<SecondaryPageLayout title="mfa.add_mfa_factors" description="mfa.add_mfa_description">
<MfaFactorList flow={UserMfaFlow.MfaBinding} factors={availableFactors} />
<SecondaryPageLayout
title="mfa.add_mfa_factors"
description="mfa.add_mfa_description"
onSkip={conditional(flowState.skippable && skipMfa)}
>
<MfaFactorList flow={UserMfaFlow.MfaBinding} flowState={flowState} />
</SecondaryPageLayout>
);
};

View file

@ -8,7 +8,7 @@ import SectionLayout from '@/Layout/SectionLayout';
import Button from '@/components/Button';
import { InputField } from '@/components/InputFields';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import useSendMfaPayload from '@/hooks/use-send-mfa-payload';
import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types';
@ -20,7 +20,7 @@ type FormState = {
};
const BackupCodeVerification = () => {
const mfaFactorsState = useMfaFactorsState();
const flowState = useMfaFlowState();
const sendMfaPayload = useSendMfaPayload();
const { register, handleSubmit } = useForm<FormState>({ defaultValues: { code: '' } });
@ -36,12 +36,10 @@ const BackupCodeVerification = () => {
[handleSubmit, sendMfaPayload]
);
if (!mfaFactorsState) {
if (!flowState) {
return <ErrorPage title="error.invalid_session" />;
}
const { availableFactors } = mfaFactorsState;
return (
<SecondaryPageLayout title="mfa.verify_mfa_factors">
<SectionLayout
@ -57,13 +55,11 @@ const BackupCodeVerification = () => {
<Button title="action.continue" htmlType="submit" />
</form>
</SectionLayout>
{availableFactors.length > 1 && (
<SwitchMfaFactorsLink
flow={UserMfaFlow.MfaVerification}
factors={availableFactors}
className={styles.switchFactorLink}
/>
)}
<SwitchMfaFactorsLink
flow={UserMfaFlow.MfaVerification}
flowState={flowState}
className={styles.switchFactorLink}
/>
</SecondaryPageLayout>
);
};

View file

@ -2,21 +2,19 @@ import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import SectionLayout from '@/Layout/SectionLayout';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import TotpCodeVerification from '@/containers/TotpCodeVerification';
import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types';
import * as styles from './index.module.scss';
const TotpVerification = () => {
const mfaFactorsState = useMfaFactorsState();
const flowState = useMfaFlowState();
if (!mfaFactorsState) {
if (!flowState) {
return <ErrorPage title="error.invalid_session" />;
}
const { availableFactors } = mfaFactorsState;
return (
<SecondaryPageLayout title="mfa.verify_mfa_factors">
<SectionLayout
@ -25,13 +23,11 @@ const TotpVerification = () => {
>
<TotpCodeVerification flow={UserMfaFlow.MfaVerification} />
</SectionLayout>
{availableFactors.length > 1 && (
<SwitchMfaFactorsLink
flow={UserMfaFlow.MfaVerification}
factors={availableFactors}
className={styles.switchFactorLink}
/>
)}
<SwitchMfaFactorsLink
flow={UserMfaFlow.MfaVerification}
flowState={flowState}
className={styles.switchFactorLink}
/>
</SecondaryPageLayout>
);
};

View file

@ -2,7 +2,7 @@ import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import SectionLayout from '@/Layout/SectionLayout';
import Button from '@/components/Button';
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import useWebAuthnOperation from '@/hooks/use-webauthn-operation';
import ErrorPage from '@/pages/ErrorPage';
import { UserMfaFlow } from '@/types';
@ -10,15 +10,13 @@ import { UserMfaFlow } from '@/types';
import * as styles from './index.module.scss';
const WebAuthnVerification = () => {
const mfaFactorsState = useMfaFactorsState();
const flowState = useMfaFlowState();
const verifyWebAuthn = useWebAuthnOperation(UserMfaFlow.MfaVerification);
if (!mfaFactorsState) {
if (!flowState) {
return <ErrorPage title="error.invalid_session" />;
}
const { availableFactors } = mfaFactorsState;
return (
<SecondaryPageLayout title="mfa.verify_mfa_factors">
<SectionLayout
@ -31,9 +29,7 @@ const WebAuthnVerification = () => {
onClick={verifyWebAuthn}
/>
</SectionLayout>
{availableFactors.length > 1 && (
<SwitchMfaFactorsLink flow={UserMfaFlow.MfaVerification} factors={availableFactors} />
)}
<SwitchMfaFactorsLink flow={UserMfaFlow.MfaVerification} flowState={flowState} />
</SecondaryPageLayout>
);
};

View file

@ -1,20 +1,20 @@
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import MfaFactorList from '@/containers/MfaFactorList';
import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
import { UserMfaFlow } from '@/types';
import ErrorPage from '../ErrorPage';
const MfaVerification = () => {
const { availableFactors } = useMfaFactorsState() ?? {};
const flowState = useMfaFlowState();
if (!availableFactors || availableFactors.length === 0) {
if (!flowState) {
return <ErrorPage title="error.invalid_session" />;
}
return (
<SecondaryPageLayout title="mfa.verify_mfa_factors" description="mfa.verify_mfa_description">
<MfaFactorList flow={UserMfaFlow.MfaVerification} factors={availableFactors} />
<MfaFactorList flow={UserMfaFlow.MfaVerification} flowState={flowState} />
</SecondaryPageLayout>
);
};

View file

@ -73,18 +73,19 @@ const mfaFactorsGuard = s.array(
export const mfaErrorDataGuard = s.object({
availableFactors: mfaFactorsGuard,
skippable: s.optional(s.boolean()),
});
export const mfaFactorsStateGuard = mfaErrorDataGuard;
export const mfaFlowStateGuard = mfaErrorDataGuard;
export type MfaFactorsState = s.Infer<typeof mfaFactorsStateGuard>;
export type MfaFlowState = s.Infer<typeof mfaFlowStateGuard>;
export const totpBindingStateGuard = s.assign(
s.object({
secret: s.string(),
secretQrCode: s.string(),
}),
mfaFactorsStateGuard
mfaFlowStateGuard
);
export type TotpBindingState = s.Infer<typeof totpBindingStateGuard>;

View file

@ -0,0 +1,94 @@
import { ConnectorType } from '@logto/connector-kit';
import { SignInIdentifier } from '@logto/schemas';
import { deleteUser } from '#src/api/admin-user.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { demoAppUrl } from '#src/constants.js';
import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js';
import {
enableUserControlledMfaWithTotp,
resetMfaSettings,
} from '#src/helpers/sign-in-experience.js';
import { generateNewUser } from '#src/helpers/user.js';
import ExpectTotpExperience from '#src/ui-helpers/expect-totp-experience.js';
import { waitFor } from '#src/utils.js';
describe('MFA - User controlled', () => {
beforeAll(async () => {
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms, ConnectorType.Social]);
await setSocialConnector();
await updateSignInExperience({
signUp: {
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
},
signIn: {
methods: [
{
identifier: SignInIdentifier.Username,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
],
},
});
await enableUserControlledMfaWithTotp();
});
afterAll(async () => {
await resetMfaSettings();
});
it('can skip MFA binding when signing in at the first time', async () => {
const { userProfile, user } = await generateNewUser({ username: true, password: true });
const experience = new ExpectTotpExperience(await browser.newPage());
await experience.startWith(demoAppUrl, 'sign-in');
await experience.toFillForm(
{
identifier: userProfile.username,
password: userProfile.password,
},
{ submit: true, shouldNavigate: false }
);
// Wait for the TOTP page rendered
await waitFor(1000);
await experience.toClick('div[role=button][class$=skipButton]');
await experience.verifyThenEnd();
await deleteUser(user.id);
});
it('should verify MFA when the user has not skip the MFA binding', async () => {
const { userProfile, user } = await generateNewUser({ username: true, password: true });
const experience = new ExpectTotpExperience(await browser.newPage());
await experience.startWith(demoAppUrl, 'sign-in');
await experience.toFillForm(
{
identifier: userProfile.username,
password: userProfile.password,
},
{ submit: true }
);
const totpSecret = await experience.toBindTotp();
await experience.verifyThenEnd(false);
await experience.startWith(demoAppUrl, 'sign-in');
await experience.toFillForm(
{
identifier: userProfile.username,
password: userProfile.password,
},
{ submit: true }
);
await experience.toVerifyTotp(totpSecret);
await experience.verifyThenEnd();
await deleteUser(user.id);
});
});

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Verknüpfen und weiter',
back: 'Gehe zurück',
nav_back: 'Zurück',
nav_skip: 'Überspringen',
agree: 'Zustimmen',
got_it: 'Alles klar',
sign_in_with: 'Mit {{name}} anmelden',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Link and continue',
back: 'Go back',
nav_back: 'Back',
nav_skip: 'Skip',
agree: 'Agree',
got_it: 'Got it',
sign_in_with: 'Continue with {{name}}',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Vincular y continuar',
back: 'Regresar',
nav_back: 'Atrás',
nav_skip: 'Omitir',
agree: 'Aceptar',
got_it: 'Entendido',
sign_in_with: 'Continuar con {{name}}',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Lier et continuer',
back: 'Aller en arrière',
nav_back: 'Retour',
nav_skip: 'Passer',
agree: 'Accepter',
got_it: 'Compris',
sign_in_with: 'Continuer avec {{name}}',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Collega e continua',
back: 'Torna indietro',
nav_back: 'Indietro',
nav_skip: 'Salta',
agree: 'Accetto',
got_it: 'Capito',
sign_in_with: 'Continua con {{name}}',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'リンクして続行する',
back: '戻る',
nav_back: '戻る',
nav_skip: 'スキップ',
agree: '同意する',
got_it: 'わかりました',
sign_in_with: '{{name}} で続ける',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: '연동하고 계속하기',
back: '뒤로 가기',
nav_back: '뒤로',
nav_skip: '건너뛰기',
agree: '동의',
got_it: '알겠습니다',
sign_in_with: '{{name}} 계속',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Połącz i kontynuuj',
back: 'Wróć',
nav_back: 'Wstecz',
nav_skip: 'Pomiń',
agree: 'Zgadzam się',
got_it: 'Zrozumiałem',
sign_in_with: 'Kontynuuj z {{name}}',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Linkar e continuar',
back: 'Voltar',
nav_back: 'Voltar',
nav_skip: 'Pular',
agree: 'Aceito',
got_it: 'Entendido',
sign_in_with: 'Continuar com {{name}}',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Ligar e continuar',
back: 'Voltar',
nav_back: 'Anterior',
nav_skip: 'Saltar',
agree: 'Aceito',
got_it: 'Entendi',
sign_in_with: 'Continuar com {{name}}',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Привязать и продолжить',
back: 'Назад',
nav_back: 'Назад',
nav_skip: 'Пропустить',
agree: 'Согласен',
got_it: 'Понял',
sign_in_with: 'Войти через {{name}}',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: 'Bağla ve devam et',
back: 'Geri Dön',
nav_back: 'Geri',
nav_skip: 'Atla',
agree: 'Kabul Et',
got_it: 'Anladım',
sign_in_with: '{{name}} ile ilerle',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: '绑定并继续',
back: '返回',
nav_back: '返回',
nav_skip: '跳过',
agree: '同意',
got_it: '知道了',
sign_in_with: '通过 {{name}} 继续',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: '綁定並繼續',
back: '返回',
nav_back: '返回',
nav_skip: '跳過',
agree: '同意',
got_it: '知道了',
sign_in_with: '通過 {{name}} 繼續',

View file

@ -12,6 +12,7 @@ const action = {
bind_and_continue: '綁定並繼續',
back: '返回',
nav_back: '返回',
nav_skip: '跳過',
agree: '同意',
got_it: '知道了',
sign_in_with: '通過 {{name}} 繼續',