263 lines
9 KiB
TypeScript
263 lines
9 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { authClient, useSession } from "@/lib/auth-client";
|
||
|
|
import { trpc } from "@/lib/trpc";
|
||
|
|
import { useRouter } from "next/navigation";
|
||
|
|
import { useMemo, useState } from "react";
|
||
|
|
|
||
|
|
export default function TwoFactorSetupPage() {
|
||
|
|
const router = useRouter();
|
||
|
|
const { data: session } = useSession();
|
||
|
|
const securityQuery = trpc.security.status.useQuery();
|
||
|
|
const [password, setPassword] = useState("");
|
||
|
|
const [otpCode, setOtpCode] = useState("");
|
||
|
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||
|
|
const [totpUri, setTotpUri] = useState<string | null>(null);
|
||
|
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
|
||
|
|
const qrCodeUrl = useMemo(() => {
|
||
|
|
if (!totpUri) return null;
|
||
|
|
const sanitized = totpUri.trim();
|
||
|
|
return `https://api.qrserver.com/v1/create-qr-code/?size=320x320&margin=16&data=${encodeURIComponent(
|
||
|
|
sanitized,
|
||
|
|
)}`;
|
||
|
|
}, [totpUri]);
|
||
|
|
const manualSecret = useMemo(() => {
|
||
|
|
if (!totpUri) return null;
|
||
|
|
const match = totpUri.match(/[?&]secret=([^&]+)/i);
|
||
|
|
if (!match?.[1]) return null;
|
||
|
|
try {
|
||
|
|
return decodeURIComponent(match[1]);
|
||
|
|
} catch {
|
||
|
|
return match[1];
|
||
|
|
}
|
||
|
|
}, [totpUri]);
|
||
|
|
|
||
|
|
const requiresSetup = securityQuery.data?.requiresTwoFactorSetup ?? false;
|
||
|
|
|
||
|
|
async function handleEnableTwoFactor() {
|
||
|
|
setErrorMessage(null);
|
||
|
|
setStatusMessage(null);
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const result = (await authClient.twoFactor.enable({
|
||
|
|
password,
|
||
|
|
issuer: "Hellth Hub",
|
||
|
|
})) as unknown as {
|
||
|
|
error?: { message?: string };
|
||
|
|
data?: { totpURI?: string; backupCodes?: string[] };
|
||
|
|
totpURI?: string;
|
||
|
|
backupCodes?: string[];
|
||
|
|
};
|
||
|
|
|
||
|
|
if (result?.error) {
|
||
|
|
setErrorMessage(result.error.message ?? "2FA konnte nicht aktiviert werden.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = result.data ?? result;
|
||
|
|
setTotpUri(data.totpURI ?? null);
|
||
|
|
setBackupCodes(data.backupCodes ?? []);
|
||
|
|
setStatusMessage(
|
||
|
|
"OTP vorbereitet. Bitte QR-Code scannen und danach den 6-stelligen Code bestätigen.",
|
||
|
|
);
|
||
|
|
setPassword("");
|
||
|
|
await securityQuery.refetch();
|
||
|
|
} catch {
|
||
|
|
setErrorMessage("2FA konnte derzeit nicht aktiviert werden.");
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleVerifyTotp() {
|
||
|
|
setErrorMessage(null);
|
||
|
|
setStatusMessage(null);
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
if (!totpUri) {
|
||
|
|
setErrorMessage(
|
||
|
|
"OTP ist noch nicht vorbereitet. Bitte zuerst in Schritt 1 mit deinem Passwort aktivieren.",
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = (await authClient.twoFactor.verifyTotp({
|
||
|
|
code: otpCode.trim(),
|
||
|
|
})) as unknown as {
|
||
|
|
error?: { message?: string };
|
||
|
|
};
|
||
|
|
|
||
|
|
if (result?.error) {
|
||
|
|
const message = result.error.message ?? "OTP-Code ist ungültig.";
|
||
|
|
if (message.toLowerCase().includes("totp not enabled")) {
|
||
|
|
setErrorMessage(
|
||
|
|
"TOTP ist noch nicht aktiviert. Bitte Schritt 1 (OTP aktivieren) ausführen.",
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setErrorMessage(message);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setStatusMessage("2FA erfolgreich aktiviert.");
|
||
|
|
setOtpCode("");
|
||
|
|
const refreshed = await securityQuery.refetch();
|
||
|
|
if (!refreshed.data?.requiresTwoFactorSetup) {
|
||
|
|
router.push("/");
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
setErrorMessage("OTP-Code konnte nicht verifiziert werden.");
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="max-w-4xl mx-auto space-y-6">
|
||
|
|
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||
|
|
<h1 className="text-xl font-bold text-[#333333]">Sicherheits-Setup</h1>
|
||
|
|
<p className="text-sm text-gray-600 mt-2">
|
||
|
|
Für Admin-Accounts ist 2FA verpflichtend. Bitte richte jetzt OTP (Authenticator-App)
|
||
|
|
ein.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
|
|
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||
|
|
<h2 className="text-base font-semibold text-[#333333]">1. OTP aktivieren</h2>
|
||
|
|
<p className="text-sm text-gray-600">
|
||
|
|
Gib dein aktuelles Passwort ein, um die OTP-Einrichtung zu starten.
|
||
|
|
</p>
|
||
|
|
<input
|
||
|
|
type="password"
|
||
|
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
|
||
|
|
placeholder="Aktuelles Passwort"
|
||
|
|
value={password}
|
||
|
|
onChange={(event) => setPassword(event.target.value)}
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
onClick={handleEnableTwoFactor}
|
||
|
|
disabled={loading || !password}
|
||
|
|
className="px-4 py-2 rounded-lg bg-[#0F766E] text-white text-sm disabled:opacity-50"
|
||
|
|
>
|
||
|
|
{loading ? "Aktiviere..." : "OTP aktivieren"}
|
||
|
|
</button>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||
|
|
<h2 className="text-base font-semibold text-[#333333]">2. QR-Code scannen</h2>
|
||
|
|
{!qrCodeUrl && (
|
||
|
|
<p className="text-sm text-gray-500">
|
||
|
|
Nach der Aktivierung erscheint hier der QR-Code für deine Authenticator-App.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{qrCodeUrl && (
|
||
|
|
<div className="space-y-3">
|
||
|
|
<img
|
||
|
|
src={qrCodeUrl}
|
||
|
|
alt="OTP QR Code"
|
||
|
|
className="w-72 h-72 rounded-lg bg-white p-2"
|
||
|
|
/>
|
||
|
|
<p className="text-xs text-gray-500 break-all">{totpUri}</p>
|
||
|
|
{manualSecret && (
|
||
|
|
<div className="text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
|
||
|
|
Falls Scannen nicht klappt, manuell eingeben:{" "}
|
||
|
|
<code className="font-mono">{manualSecret}</code>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||
|
|
<h2 className="text-base font-semibold text-[#333333]">3. Backup-Codes sichern</h2>
|
||
|
|
{backupCodes.length === 0 ? (
|
||
|
|
<p className="text-sm text-gray-500">
|
||
|
|
Nach Aktivierung werden hier Backup-Codes angezeigt.
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||
|
|
{backupCodes.map((code) => (
|
||
|
|
<code
|
||
|
|
key={code}
|
||
|
|
className="px-3 py-2 rounded bg-gray-100 text-sm font-mono text-gray-800"
|
||
|
|
>
|
||
|
|
{code}
|
||
|
|
</code>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||
|
|
<h2 className="text-base font-semibold text-[#333333]">4. OTP-Code bestätigen</h2>
|
||
|
|
<p className="text-sm text-gray-600">
|
||
|
|
Bitte den aktuellen 6-stelligen Code aus deiner Authenticator-App eingeben.
|
||
|
|
</p>
|
||
|
|
<input
|
||
|
|
className="w-full max-w-xs border border-gray-200 rounded-lg px-3 py-2 text-sm tracking-[0.3em]"
|
||
|
|
placeholder="123456"
|
||
|
|
maxLength={6}
|
||
|
|
value={otpCode}
|
||
|
|
onChange={(event) =>
|
||
|
|
setOtpCode(event.target.value.replace(/\D/g, "").slice(0, 6))
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
onClick={handleVerifyTotp}
|
||
|
|
disabled={loading || otpCode.length !== 6 || !totpUri}
|
||
|
|
className="px-4 py-2 rounded-lg bg-gray-900 text-white text-sm disabled:opacity-50"
|
||
|
|
>
|
||
|
|
OTP bestätigen
|
||
|
|
</button>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className="bg-white border border-amber-200 rounded-xl p-5 space-y-2">
|
||
|
|
<h2 className="text-base font-semibold text-amber-800">Passkey Hinweis</h2>
|
||
|
|
<p className="text-sm text-amber-700">
|
||
|
|
Passkey ist jetzt verfügbar. Du kannst in den Einstellungen unter Sicherheit einen
|
||
|
|
Passkey hinterlegen. Für Admin-Accounts bleibt OTP weiterhin verpflichtend.
|
||
|
|
</p>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{statusMessage && (
|
||
|
|
<div className="text-sm rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-green-800">
|
||
|
|
{statusMessage}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{errorMessage && (
|
||
|
|
<div className="text-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700">
|
||
|
|
{errorMessage}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<button
|
||
|
|
onClick={() => securityQuery.refetch()}
|
||
|
|
className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 text-sm"
|
||
|
|
>
|
||
|
|
Status aktualisieren
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => router.push("/")}
|
||
|
|
disabled={requiresSetup}
|
||
|
|
className="px-4 py-2 rounded-lg bg-gray-900 text-white text-sm disabled:opacity-50"
|
||
|
|
>
|
||
|
|
Zum Dashboard
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{!session && (
|
||
|
|
<div className="text-sm rounded-lg border border-gray-200 bg-white px-4 py-3 text-gray-600">
|
||
|
|
Keine aktive Session gefunden. Bitte erneut einloggen.
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|