Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/routeModules/auth/login-mfa.component.tsx
T

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>
);
}