mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
feat(auth): migrate passkeys to fluxer.com (#2964)
This commit is contained in:
1 parent
08e65d41c0
commit
841fb7af41
197 files changed
+17147
-12336
No files matched your search
@@ -1,55 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isPasskeyBridgeAvailable} from '@app/features/auth/utils/PasskeyBridge';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {WarningAlert} from '@app/features/ui/warning_alert/WarningAlert';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {PasswordIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
|
||||
const USE_A_PASSWORD_MANAGER_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Use a password manager passkey',
|
||||
comment:
|
||||
'Button that runs the passkey prompt in a small pop-up window on the old web address, so password manager extensions can find passkeys saved there.',
|
||||
});
|
||||
const PASSWORD_MANAGER_PASSKEY_SUGGESTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Password managers may not offer your passkey on this web address. Try it in a pop-up window on the old address instead.',
|
||||
comment:
|
||||
'Shown after a passkey prompt fails, above the button that retries the passkey prompt in a pop-up window on the old web address.',
|
||||
});
|
||||
|
||||
interface PasswordManagerPasskeyActionProps {
|
||||
suggested: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
export function PasswordManagerPasskeyAction({suggested, disabled, onClick}: PasswordManagerPasskeyActionProps) {
|
||||
const {i18n} = useLingui();
|
||||
if (!isPasskeyBridgeAvailable()) {
|
||||
return null;
|
||||
}
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
fitContainer
|
||||
variant={suggested ? 'primary' : 'secondary'}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
leftIcon={<PasswordIcon size={16} data-flx="auth.password-manager-passkey-action.icon" />}
|
||||
data-flx="auth.password-manager-passkey-action.button"
|
||||
>
|
||||
{i18n._(USE_A_PASSWORD_MANAGER_PASSKEY_DESCRIPTOR)}
|
||||
</Button>
|
||||
);
|
||||
if (!suggested) {
|
||||
return button;
|
||||
}
|
||||
return (
|
||||
<WarningAlert actions={button} data-flx="auth.password-manager-passkey-action.suggestion">
|
||||
{i18n._(PASSWORD_MANAGER_PASSKEY_SUGGESTION_DESCRIPTOR)}
|
||||
</WarningAlert>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import {describePasskeyBridgeFailure, shouldSuggestPasskeyBridge} from '@app/features/auth/utils/PasskeyBridge';
|
||||
import {
|
||||
PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR,
|
||||
PasskeyDomainUnsupportedError,
|
||||
} from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -16,8 +10,6 @@ import * as FormUtils from '@app/lib/forms';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
|
||||
const NAME_PASSKEY_FORM_DESCRIPTOR = msg({
|
||||
@@ -41,58 +33,16 @@ interface FormInputs {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PasskeyNameModalProps {
|
||||
onSubmit: (name: string) => void | Promise<void>;
|
||||
onSubmitWithPasswordManager?: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager}: PasskeyNameModalProps) => {
|
||||
export const PasskeyNameModal = observer(({onSubmit}: {onSubmit: (name: string) => void | Promise<void>}) => {
|
||||
const {i18n} = useLingui();
|
||||
const form = useForm<FormInputs>();
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const [passkeyBridgeSubmitting, setPasskeyBridgeSubmitting] = useState(false);
|
||||
const handlePasswordManagerSubmit = (
|
||||
event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (!onSubmitWithPasswordManager || passkeyBridgeSubmitting || form.formState.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
if (event.currentTarget.form?.reportValidity() === false) {
|
||||
return;
|
||||
}
|
||||
const submission = onSubmitWithPasswordManager(form.getValues('name').trim());
|
||||
form.clearErrors('name');
|
||||
setPasskeyBridgeSubmitting(true);
|
||||
submission
|
||||
.then(() => {
|
||||
ModalCommands.pop();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof HttpError) {
|
||||
FormUtils.handleError(i18n, form, error, 'name');
|
||||
return;
|
||||
}
|
||||
const descriptor = describePasskeyBridgeFailure(error);
|
||||
if (descriptor) {
|
||||
form.setError('name', {type: 'server', message: i18n._(descriptor)});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setPasskeyBridgeSubmitting(false);
|
||||
});
|
||||
};
|
||||
const handleSubmit = async (data: FormInputs) => {
|
||||
try {
|
||||
await onSubmit(data.name.trim());
|
||||
ModalCommands.pop();
|
||||
} catch (error) {
|
||||
if (onSubmitWithPasswordManager && shouldSuggestPasskeyBridge(error)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
}
|
||||
if (error instanceof HttpError) {
|
||||
FormUtils.handleError(i18n, form, error, 'name');
|
||||
} else if (error instanceof PasskeyDomainUnsupportedError) {
|
||||
form.setError('name', {type: 'server', message: i18n._(PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR)});
|
||||
} else {
|
||||
form.setError('name', {type: 'server', message: FormUtils.extractErrorMessage(i18n, error)});
|
||||
}
|
||||
@@ -123,14 +73,6 @@ export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager
|
||||
required={true}
|
||||
type="text"
|
||||
/>
|
||||
{onSubmitWithPasswordManager && (
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passkeyBridgeSuggested}
|
||||
disabled={form.formState.isSubmitting || passkeyBridgeSubmitting}
|
||||
onClick={handlePasswordManagerSubmit}
|
||||
data-flx="auth.passkey-name-modal.password-manager-passkey-action"
|
||||
/>
|
||||
)}
|
||||
</Modal.ContentLayout>
|
||||
</Modal.Content>
|
||||
<Modal.Footer data-flx="auth.passkey-name-modal.modal-footer">
|
||||
@@ -140,7 +82,6 @@ export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager
|
||||
<Button
|
||||
type="submit"
|
||||
submitting={form.formState.isSubmitting}
|
||||
disabled={passkeyBridgeSubmitting}
|
||||
data-flx="auth.passkey-name-modal.button.submit"
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
|
||||
@@ -51,3 +51,9 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.passkeyHint {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
@@ -3,25 +3,37 @@
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import styles from '@app/features/auth/components/modals/SudoVerificationModal.module.css';
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import SudoPrompt, {SudoVerificationMethod} from '@app/features/auth/state/SudoPrompt';
|
||||
import {
|
||||
describePasskeyBridgeFailure,
|
||||
runPasskeyViaBridge,
|
||||
shouldSuggestPasskeyBridge,
|
||||
} from '@app/features/auth/utils/PasskeyBridge';
|
||||
isPasskeyCeremonyDismissed,
|
||||
PasskeyBridgeSudoLink,
|
||||
runPasskeyBridgeNativeSudo,
|
||||
} from '@app/features/auth/passkey_migration/PasskeyLegacyCeremony';
|
||||
import PasskeyMigration from '@app/features/auth/passkey_migration/PasskeyMigration';
|
||||
import {isPasskeyMigrationOrigin} from '@app/features/auth/passkey_migration/PasskeyMigrationOrigin';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Sudo from '@app/features/auth/state/AuthSudo';
|
||||
import SudoPrompt, {SUDO_MODAL_KEY, SudoVerificationMethod} from '@app/features/auth/state/SudoPrompt';
|
||||
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {PASSWORD_DESCRIPTOR, VERIFY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {
|
||||
COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR,
|
||||
PASSWORD_DESCRIPTOR,
|
||||
VERIFY_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import buttonStyles from '@app/features/ui/button/Button.module.css';
|
||||
import {Form} from '@app/features/ui/components/form/Form';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import WebAuthnCredentials from '@app/features/user/state/WebAuthnCredentials';
|
||||
import * as FormUtils from '@app/lib/forms';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import type {PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
@@ -33,10 +45,6 @@ const PASSKEYS_REQUIRE_A_SIGNED_MACOS_BUNDLE_WITH_A_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Sudo (re-auth) modal body shown on unsigned macOS desktop bundles where passkeys cannot work. Direct the user to install the signed client.',
|
||||
});
|
||||
const COULDN_T_VERIFY_WITH_PASSKEY_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
|
||||
message: "Couldn't verify with passkey. Try again.",
|
||||
comment: 'Sudo (re-auth) modal toast error shown when passkey verification fails. Keep plain.',
|
||||
});
|
||||
const ENTER_YOUR_PASSWORD_DESCRIPTOR = msg({
|
||||
message: 'Enter your password.',
|
||||
comment: 'Body text in the authentication sudo verification modal. Keep the tone plain and specific.',
|
||||
@@ -72,6 +80,11 @@ const BACKUP_CODE_DESCRIPTOR = msg({
|
||||
'Label and placeholder for the code field in the authentication sudo verification modal when the only code the account can use is a backup code.',
|
||||
});
|
||||
const VERIFICATION_FAILED_DESCRIPTOR = msg({message: 'Verification failed'});
|
||||
const FINISH_IN_THE_NEW_TAB_DESCRIPTOR = msg({
|
||||
message: 'Finish in the new tab. If you closed it, press Continue with passkey again.',
|
||||
comment:
|
||||
'Sudo (re-auth) modal hint shown after the passkey button opened a new tab to confirm the passkey. "Continue with passkey" is the button label. Keep plain.',
|
||||
});
|
||||
const logger = new Logger('SudoVerificationModal');
|
||||
|
||||
interface FormInputs {
|
||||
@@ -83,14 +96,24 @@ const isMacAppIdentifierError = (error: unknown): boolean => {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
return message.toLowerCase().includes('application identifier');
|
||||
};
|
||||
const holdsPasskeyFor = (matches: (rpId: string) => boolean): boolean =>
|
||||
WebAuthnCredentials.credentials.some((credential) => matches(credential.rp_id));
|
||||
const holdsMigratedPasskey = (): boolean => holdsPasskeyFor((rpId) => rpId === PASSKEY_MIGRATION_RP_ID);
|
||||
const holdsLegacyPasskey = (): boolean => holdsPasskeyFor((rpId) => rpId !== PASSKEY_MIGRATION_RP_ID);
|
||||
const SudoVerificationModal: React.FC = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const {availableMethods, isVerifying, verificationFailed, rawError, lastUsedMfaMethod} = SudoPrompt;
|
||||
const form = useForm<FormInputs>({defaultValues: {password: '', totp: ''}});
|
||||
const [webAuthnInFlight, setWebAuthnInFlight] = useState(false);
|
||||
const [webAuthnError, setWebAuthnError] = useState<string | null>(null);
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const autoTriggeredRef = useRef(false);
|
||||
const [legacyLinkUrl, setLegacyLinkUrl] = useState<string | null>(null);
|
||||
const [legacyLinkActive, setLegacyLinkActive] = useState(false);
|
||||
const [legacyLinkFollowed, setLegacyLinkFollowed] = useState(false);
|
||||
const legacyLinkRef = useRef<PasskeyBridgeSudoLink | null>(null);
|
||||
const preferLegacyRef = useRef(false);
|
||||
const openRef = useRef(true);
|
||||
const userIdAtOpenRef = useRef(AccountManager.currentUserId);
|
||||
const showPasskey = availableMethods.webauthn;
|
||||
const showTotp = availableMethods.totp;
|
||||
const backupCodeOnly = !showTotp && availableMethods.backupCodes;
|
||||
@@ -113,12 +136,59 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
}
|
||||
setWebAuthnInFlight(false);
|
||||
}, [form, verificationFailed, rawError, i18n, i18n.locale, showPassword, showCode]);
|
||||
const finishLegacySudo = (sudoToken: string) => {
|
||||
if (!openRef.current || AccountManager.currentUserId !== userIdAtOpenRef.current) return;
|
||||
Sudo.setToken(sudoToken);
|
||||
PasskeyMigration.checkAfterSudo(SUDO_MODAL_KEY);
|
||||
SudoPrompt.submit({});
|
||||
};
|
||||
const startLegacyLink = () => {
|
||||
if (legacyLinkRef.current === null) {
|
||||
legacyLinkRef.current = new PasskeyBridgeSudoLink({
|
||||
onLink: (url) => {
|
||||
setLegacyLinkUrl(url);
|
||||
if (url === null) {
|
||||
setLegacyLinkFollowed(false);
|
||||
}
|
||||
},
|
||||
onCompleted: finishLegacySudo,
|
||||
onError: (error) => {
|
||||
logger.error('WebAuthn verification in a new tab failed', error);
|
||||
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR));
|
||||
},
|
||||
});
|
||||
}
|
||||
setLegacyLinkActive(true);
|
||||
void legacyLinkRef.current.start();
|
||||
};
|
||||
useEffect(() => {
|
||||
if (showPasskey && isPasskeyMigrationOrigin() && !Platform.isElectron && !holdsMigratedPasskey()) {
|
||||
startLegacyLink();
|
||||
}
|
||||
return () => {
|
||||
openRef.current = false;
|
||||
legacyLinkRef.current?.dispose();
|
||||
};
|
||||
}, []);
|
||||
const handleWebAuthn = async () => {
|
||||
if (webAuthnInFlight || isVerifying) return;
|
||||
setWebAuthnError(null);
|
||||
form.clearErrors();
|
||||
setWebAuthnInFlight(true);
|
||||
const migrationOrigin = isPasskeyMigrationOrigin();
|
||||
const runsLegacyNatively =
|
||||
migrationOrigin && Platform.isElectron && (preferLegacyRef.current || !holdsMigratedPasskey());
|
||||
try {
|
||||
if (runsLegacyNatively) {
|
||||
preferLegacyRef.current = false;
|
||||
const result = await runPasskeyBridgeNativeSudo();
|
||||
if (result.status === 'completed') {
|
||||
finishLegacySudo(result.sudo_token);
|
||||
return;
|
||||
}
|
||||
setWebAuthnInFlight(false);
|
||||
return;
|
||||
}
|
||||
await WebAuthnUtils.assertWebAuthnSupported();
|
||||
const optionsResponse = await http.post<{challenge: string}>(Endpoints.SUDO_WEBAUTHN_OPTIONS);
|
||||
const credential = await WebAuthnUtils.performAuthentication(optionsResponse.body);
|
||||
@@ -130,49 +200,22 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
} catch (err) {
|
||||
logger.error('WebAuthn verification failed', err);
|
||||
setWebAuthnInFlight(false);
|
||||
if (migrationOrigin && !runsLegacyNatively && isPasskeyCeremonyDismissed(err) && holdsLegacyPasskey()) {
|
||||
if (!Platform.isElectron) {
|
||||
startLegacyLink();
|
||||
return;
|
||||
}
|
||||
preferLegacyRef.current = true;
|
||||
}
|
||||
if (isMacAppIdentifierError(err)) {
|
||||
setWebAuthnError(i18n._(PASSKEYS_REQUIRE_A_SIGNED_MACOS_BUNDLE_WITH_A_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
if (shouldSuggestPasskeyBridge(err)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
return;
|
||||
}
|
||||
if (err instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
|
||||
setWebAuthnError(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_PLEASE_TRY_AGAIN_DESCRIPTOR));
|
||||
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR));
|
||||
}
|
||||
};
|
||||
const handlePasskeyBridge = () => {
|
||||
if (webAuthnInFlight || isVerifying) return;
|
||||
const options = http
|
||||
.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.SUDO_WEBAUTHN_OPTIONS)
|
||||
.then((response) => response.body);
|
||||
const credential = runPasskeyViaBridge('authenticate', options);
|
||||
setWebAuthnError(null);
|
||||
form.clearErrors();
|
||||
setWebAuthnInFlight(true);
|
||||
Promise.all([options, credential])
|
||||
.then(([resolvedOptions, resolvedCredential]) => {
|
||||
SudoPrompt.submit({
|
||||
mfa_method: SudoVerificationMethod.WEBAUTHN,
|
||||
webauthn_challenge: resolvedOptions.challenge,
|
||||
webauthn_response: resolvedCredential,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.error('WebAuthn verification in the pop-up window failed', err);
|
||||
setWebAuthnInFlight(false);
|
||||
const descriptor = describePasskeyBridgeFailure(err);
|
||||
if (descriptor) {
|
||||
setWebAuthnError(i18n._(descriptor));
|
||||
}
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (autoTriggeredRef.current) return;
|
||||
if (autoTriggeredRef.current || legacyLinkRef.current !== null) return;
|
||||
if (!showPasskey || showPassword || showCode) return;
|
||||
if (lastUsedMfaMethod && lastUsedMfaMethod !== 'webauthn') return;
|
||||
autoTriggeredRef.current = true;
|
||||
@@ -263,7 +306,39 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
|
||||
{showPasskey && (
|
||||
<>
|
||||
{webAuthnInFlight ? (
|
||||
{legacyLinkActive && legacyLinkUrl !== null ? (
|
||||
<FocusRing offset={-2} data-flx="auth.sudo-verification-modal.focus-ring.legacy-link">
|
||||
<a
|
||||
href={legacyLinkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
setWebAuthnError(null);
|
||||
setLegacyLinkFollowed(true);
|
||||
}}
|
||||
className={clsx(
|
||||
buttonStyles.button,
|
||||
buttonStyles[showCode || showPassword ? 'secondary' : 'primary'],
|
||||
buttonStyles.fitContainer,
|
||||
)}
|
||||
data-flx="auth.sudo-verification-modal.link.web-authn"
|
||||
>
|
||||
<Trans>Continue with passkey</Trans>
|
||||
</a>
|
||||
</FocusRing>
|
||||
) : legacyLinkActive ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={startLegacyLink}
|
||||
submitting={webAuthnError === null}
|
||||
disabled={isVerifying}
|
||||
fitContainer
|
||||
variant={showCode || showPassword ? 'secondary' : 'primary'}
|
||||
data-flx="auth.sudo-verification-modal.button.web-authn-starting"
|
||||
>
|
||||
<Trans>Continue with passkey</Trans>
|
||||
</Button>
|
||||
) : webAuthnInFlight ? (
|
||||
<div
|
||||
className={styles.passkeyVerifying}
|
||||
role="status"
|
||||
@@ -287,13 +362,10 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
<Trans>Continue with passkey</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{!webAuthnInFlight && (
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passkeyBridgeSuggested}
|
||||
disabled={isVerifying}
|
||||
onClick={handlePasskeyBridge}
|
||||
data-flx="auth.sudo-verification-modal.password-manager-passkey-action"
|
||||
/>
|
||||
{legacyLinkActive && legacyLinkFollowed && !webAuthnError && (
|
||||
<p className={styles.passkeyHint} data-flx="auth.sudo-verification-modal.passkey-hint">
|
||||
{i18n._(FINISH_IN_THE_NEW_TAB_DESCRIPTOR)}
|
||||
</p>
|
||||
)}
|
||||
{webAuthnError && (
|
||||
<p className={styles.formError} role="alert" data-flx="auth.sudo-verification-modal.form-error">
|
||||
|
||||
Reference in new issue
Block a user