mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(auth): make passkey two-factor authentication opt-in (#2857)
This commit is contained in:
1 parent
3256af8d92
commit
ba7d8781cf
94 files changed
+7322
-2747
No files matched your search
@@ -38,6 +38,11 @@ const ENTER_THE_CODE_FROM_YOUR_AUTHENTICATOR_APP_DESCRIPTOR = msg({
|
||||
message: 'Enter the code from your authenticator app.',
|
||||
comment: 'Body text in the authentication sudo verification modal. Keep the tone plain and specific.',
|
||||
});
|
||||
const ENTER_ONE_OF_YOUR_BACKUP_CODES_DESCRIPTOR = msg({
|
||||
message: 'Enter one of your backup codes.',
|
||||
comment:
|
||||
'Body text in the authentication sudo verification modal, shown when the only code the account can enter is a backup code. Keep the tone plain and specific.',
|
||||
});
|
||||
const VERIFY_IDENTITY_FORM_DESCRIPTOR = msg({
|
||||
message: 'Verify identity form',
|
||||
comment: 'Accessible form label in the authentication sudo verification modal. Keep the tone plain and specific.',
|
||||
@@ -54,6 +59,11 @@ const MESSAGE_6_DIGIT_CODE_DESCRIPTOR = msg({
|
||||
message: '6-digit code',
|
||||
comment: 'Short label in the authentication sudo verification modal. Keep the tone plain and specific.',
|
||||
});
|
||||
const BACKUP_CODE_DESCRIPTOR = msg({
|
||||
message: 'Backup code',
|
||||
comment:
|
||||
'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 logger = new Logger('SudoVerificationModal');
|
||||
|
||||
@@ -75,8 +85,10 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
const autoTriggeredRef = useRef(false);
|
||||
const showPasskey = availableMethods.webauthn;
|
||||
const showTotp = availableMethods.totp;
|
||||
const backupCodeOnly = !showTotp && availableMethods.backupCodes;
|
||||
const showCode = showTotp || availableMethods.backupCodes;
|
||||
const showPassword = availableMethods.password;
|
||||
const noMethodsAvailable = !showPasskey && !showTotp && !showPassword;
|
||||
const noMethodsAvailable = !showPasskey && !showCode && !showPassword;
|
||||
useEffect(() => {
|
||||
form.reset({password: '', totp: ''});
|
||||
setWebAuthnError(null);
|
||||
@@ -85,14 +97,14 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
}, [form]);
|
||||
useEffect(() => {
|
||||
if (!verificationFailed && !rawError) return;
|
||||
const fallback: keyof FormInputs = showTotp ? 'totp' : showPassword ? 'password' : 'password';
|
||||
const fallback: keyof FormInputs = showCode ? 'totp' : 'password';
|
||||
if (rawError) {
|
||||
FormUtils.handleError(i18n, form, rawError, fallback);
|
||||
} else if (verificationFailed) {
|
||||
form.setError(fallback, {type: 'server', message: i18n._(VERIFICATION_FAILED_DESCRIPTOR)});
|
||||
}
|
||||
setWebAuthnInFlight(false);
|
||||
}, [form, verificationFailed, rawError, i18n, i18n.locale, showPassword, showTotp]);
|
||||
}, [form, verificationFailed, rawError, i18n, i18n.locale, showPassword, showCode]);
|
||||
const handleWebAuthn = async () => {
|
||||
if (webAuthnInFlight || isVerifying) return;
|
||||
setWebAuthnError(null);
|
||||
@@ -119,17 +131,17 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
};
|
||||
useEffect(() => {
|
||||
if (autoTriggeredRef.current) return;
|
||||
if (!showPasskey || showPassword || showTotp) return;
|
||||
if (!showPasskey || showPassword || showCode) return;
|
||||
if (lastUsedMfaMethod && lastUsedMfaMethod !== 'webauthn') return;
|
||||
autoTriggeredRef.current = true;
|
||||
void handleWebAuthn();
|
||||
}, [showPasskey, showPassword, showTotp]);
|
||||
}, [showPasskey, showPassword, showCode]);
|
||||
const handleClose = () => {
|
||||
SudoPrompt.reject(new DOMException('User cancelled verification', 'AbortError'));
|
||||
};
|
||||
const onSubmit = (values: FormInputs) => {
|
||||
form.clearErrors();
|
||||
if (showTotp && values.totp) {
|
||||
if (showCode && values.totp) {
|
||||
SudoPrompt.submit({mfa_method: SudoVerificationMethod.TOTP, mfa_code: values.totp});
|
||||
return;
|
||||
}
|
||||
@@ -137,17 +149,19 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
SudoPrompt.submit({password: values.password});
|
||||
return;
|
||||
}
|
||||
if (showPasskey && !showTotp && !showPassword) {
|
||||
if (showPasskey && !showCode && !showPassword) {
|
||||
void handleWebAuthn();
|
||||
return;
|
||||
}
|
||||
const target: keyof FormInputs = showTotp ? 'totp' : 'password';
|
||||
const target: keyof FormInputs = showCode ? 'totp' : 'password';
|
||||
form.setError(target, {
|
||||
type: 'manual',
|
||||
message:
|
||||
target === 'password'
|
||||
? i18n._(ENTER_YOUR_PASSWORD_DESCRIPTOR)
|
||||
: i18n._(ENTER_THE_CODE_FROM_YOUR_AUTHENTICATOR_APP_DESCRIPTOR),
|
||||
: backupCodeOnly
|
||||
? i18n._(ENTER_ONE_OF_YOUR_BACKUP_CODES_DESCRIPTOR)
|
||||
: i18n._(ENTER_THE_CODE_FROM_YOUR_AUTHENTICATOR_APP_DESCRIPTOR),
|
||||
});
|
||||
};
|
||||
return (
|
||||
@@ -186,19 +200,19 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showTotp && (
|
||||
{showCode && (
|
||||
<Input
|
||||
id="totp"
|
||||
data-flx="auth.sudo-verification-modal.input"
|
||||
{...form.register('totp')}
|
||||
label={i18n._(AUTHENTICATOR_CODE_DESCRIPTOR)}
|
||||
placeholder={i18n._(MESSAGE_6_DIGIT_CODE_DESCRIPTOR)}
|
||||
label={i18n._(backupCodeOnly ? BACKUP_CODE_DESCRIPTOR : AUTHENTICATOR_CODE_DESCRIPTOR)}
|
||||
placeholder={i18n._(backupCodeOnly ? BACKUP_CODE_DESCRIPTOR : MESSAGE_6_DIGIT_CODE_DESCRIPTOR)}
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
enterKeyHint="done"
|
||||
inputMode="numeric"
|
||||
inputMode={backupCodeOnly ? 'text' : 'numeric'}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
error={form.formState.errors.totp?.message}
|
||||
@@ -224,8 +238,8 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
onClick={handleWebAuthn}
|
||||
disabled={isVerifying}
|
||||
fitContainer
|
||||
autoFocus={!showTotp && !showPassword}
|
||||
variant={showTotp || showPassword ? 'secondary' : 'primary'}
|
||||
autoFocus={!showCode && !showPassword}
|
||||
variant={showCode || showPassword ? 'secondary' : 'primary'}
|
||||
data-flx="auth.sudo-verification-modal.button.web-authn"
|
||||
>
|
||||
<Trans>Continue with passkey</Trans>
|
||||
@@ -245,7 +259,7 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
{...form.register('password')}
|
||||
label={i18n._(PASSWORD_DESCRIPTOR)}
|
||||
type="password"
|
||||
autoFocus={!showPasskey && !showTotp}
|
||||
autoFocus={!showPasskey && !showCode}
|
||||
error={form.formState.errors.password?.message}
|
||||
/>
|
||||
)}
|
||||
@@ -263,7 +277,7 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
{!noMethodsAvailable && (showTotp || showPassword) && (
|
||||
{!noMethodsAvailable && (showCode || showPassword) && (
|
||||
<Button
|
||||
type="submit"
|
||||
submitting={isVerifying}
|
||||
|
||||
@@ -81,6 +81,7 @@ const ResetPasswordPage = observer(function ResetPasswordPage() {
|
||||
ticket: response.challenge.ticket,
|
||||
totp: response.challenge.totp,
|
||||
webauthn: response.challenge.webauthn,
|
||||
backupCodes: response.challenge.backupCodes,
|
||||
});
|
||||
RouterUtils.replaceWith('/login');
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user