147 lines
5.8 KiB
TypeScript
147 lines
5.8 KiB
TypeScript
import { useRef, useState } from "react";
|
|
import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useRootData } from "~/utils/data/useRootData";
|
|
import { AuthShell } from "~/components/auth/AuthShell";
|
|
import { Button } from "~/ui/components/Button";
|
|
import { Checkbox } from "~/ui/components/Checkbox";
|
|
import { InputOtp } from "~/ui/components/InputOtp";
|
|
import { LinkButton } from "~/ui/components/LinkButton";
|
|
import { TextInput } from "~/ui/components/TextInput";
|
|
import type { ActionData, LoaderData } from "~/routeModules/auth/login-mfa.impl";
|
|
|
|
// D-D0a — 2FA-Code-Eingabe nach Davids Subframe-Design ("_2FA _ Code Entry"): Logo + Card
|
|
// (Bestaetigung in zwei Schritten) + 6-stellige InputOtp + "Geraet vertrauen"-Checkbox + Bestaetigen +
|
|
// Recovery-Code-Toggle. An die bestehende login-mfa-Action verdrahtet (code/recoveryCode/rememberDevice/csrf).
|
|
// Die 6 OTP-Boxen werden zu einem hidden "code"-Feld kombiniert (Auto-Advance, Paste, Backspace).
|
|
|
|
const OTP_LENGTH = 6;
|
|
|
|
export default function LoginMfaRoute() {
|
|
const { t } = useTranslation();
|
|
const { csrf } = useRootData();
|
|
const data = useLoaderData<LoaderData>();
|
|
const actionData = useActionData<ActionData>();
|
|
const navigation = useNavigation();
|
|
const isSubmitting = navigation.state !== "idle";
|
|
|
|
const [digits, setDigits] = useState<string[]>(Array(OTP_LENGTH).fill(""));
|
|
const [trustDevice, setTrustDevice] = useState(false);
|
|
const [showRecovery, setShowRecovery] = useState(false);
|
|
const inputs = useRef<(HTMLInputElement | null)[]>([]);
|
|
const code = digits.join("");
|
|
|
|
function handleChange(index: number, raw: string) {
|
|
const clean = raw.replace(/\D/g, "");
|
|
setDigits((prev) => {
|
|
const next = [...prev];
|
|
if (!clean) {
|
|
next[index] = "";
|
|
return next;
|
|
}
|
|
// Einzelzeichen ODER Paste (z.B. "123456") ab dieser Box vorwaerts fuellen.
|
|
for (let k = 0; k < clean.length && index + k < OTP_LENGTH; k++) {
|
|
next[index + k] = clean[k];
|
|
}
|
|
return next;
|
|
});
|
|
if (clean) {
|
|
inputs.current[Math.min(index + clean.length, OTP_LENGTH - 1)]?.focus();
|
|
}
|
|
}
|
|
|
|
function handleKeyDown(index: number, event: React.KeyboardEvent<HTMLInputElement>) {
|
|
if (event.key === "Backspace" && !digits[index] && index > 0) {
|
|
inputs.current[index - 1]?.focus();
|
|
setDigits((prev) => {
|
|
const next = [...prev];
|
|
next[index - 1] = "";
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
|
|
const errorState = Boolean(actionData?.error && navigation.state === "idle");
|
|
|
|
return (
|
|
<AuthShell
|
|
title={t("account.loginMfa.twoStepTitle")}
|
|
subtitle={t("account.loginMfa.codeSubtitle")}
|
|
logoIconOnly
|
|
subtitleStrong
|
|
dataUiId="ui.auth.loginMfa.page"
|
|
>
|
|
<Form method="post" noValidate className="flex w-full flex-col gap-4" data-ui-id="ui.auth.loginMfa.form">
|
|
{csrf ? <input type="hidden" name="csrf" value={csrf} readOnly /> : null}
|
|
<input type="hidden" name="redirectTo" value={data.redirectTo} readOnly />
|
|
<input type="hidden" name="code" value={code} readOnly />
|
|
<input type="hidden" name="rememberDevice" value={trustDevice ? "on" : ""} readOnly />
|
|
|
|
{!showRecovery ? (
|
|
<div className="flex w-full flex-col items-center py-2">
|
|
<InputOtp state={errorState ? "error" : "default"} helperText={t("account.loginMfa.codeHelper")}>
|
|
{Array.from({ length: OTP_LENGTH }).map((_, i) => (
|
|
<InputOtp.OtpInputItem key={i} error={errorState}>
|
|
<InputOtp.Input
|
|
className="h-auto w-12 flex-none self-stretch"
|
|
ref={(el) => {
|
|
inputs.current[i] = el;
|
|
}}
|
|
value={digits[i]}
|
|
onChange={(event) => handleChange(i, event.target.value)}
|
|
onKeyDown={(event) => handleKeyDown(i, event)}
|
|
inputMode="numeric"
|
|
autoComplete={i === 0 ? "one-time-code" : "off"}
|
|
maxLength={OTP_LENGTH}
|
|
aria-label={`${t("account.loginMfa.fields.authenticatorCode")} ${i + 1}`}
|
|
data-ui-id={i === 0 ? "ui.auth.loginMfa.code" : undefined}
|
|
/>
|
|
</InputOtp.OtpInputItem>
|
|
))}
|
|
</InputOtp>
|
|
</div>
|
|
) : (
|
|
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.loginMfa.fields.recoveryCode")}>
|
|
<TextInput.Input
|
|
name="recoveryCode"
|
|
autoFocus
|
|
autoComplete="off"
|
|
placeholder={t("account.loginMfa.fields.recoveryCode")}
|
|
disabled={isSubmitting}
|
|
data-ui-id="ui.auth.loginMfa.recoveryCode"
|
|
/>
|
|
</TextInput>
|
|
)}
|
|
|
|
<Checkbox
|
|
label={t("account.loginMfa.trustDevice")}
|
|
checked={trustDevice}
|
|
onCheckedChange={setTrustDevice}
|
|
data-ui-id="ui.auth.loginMfa.rememberDevice"
|
|
/>
|
|
|
|
<Button type="submit" loading={isSubmitting} disabled={isSubmitting} data-ui-id="ui.auth.loginMfa.submit">
|
|
{t("account.loginMfa.confirm")}
|
|
</Button>
|
|
|
|
{errorState ? (
|
|
<p className="text-center text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative" role="alert">
|
|
{actionData?.error}
|
|
</p>
|
|
) : null}
|
|
</Form>
|
|
|
|
<div className="flex w-full items-center justify-center">
|
|
<LinkButton
|
|
size="small"
|
|
hideIcon
|
|
onClick={() => setShowRecovery((v) => !v)}
|
|
data-ui-id="ui.auth.loginMfa.useRecoveryCode"
|
|
>
|
|
{showRecovery ? t("account.loginMfa.useAuthenticator") : t("account.loginMfa.useRecoveryCode")}
|
|
</LinkButton>
|
|
</div>
|
|
</AuthShell>
|
|
);
|
|
}
|