Files
stackpulse/frontend/src/pages/auth/forgot-password.jsx
T
2025-11-12 10:48:25 +00:00

409 lines
15 KiB
React

import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import {
Card,
CardBody,
CardHeader,
Typography,
Input,
Button,
Spinner,
Alert
} from "@material-tailwind/react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/components/ToastProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
const PHRASE_WORD_COUNT = 8;
const normalizeWord = (value) => String(value || "").trim().toLowerCase();
const extractWordsFromText = (text = "") => {
const normalized = String(text || "").replace(/[\r\n\t]+/g, " ").trim().toLowerCase();
if (!normalized) {
return [];
}
return normalized.split(/\s+/).filter(Boolean);
};
const buildPhrasePayload = (words = []) => {
const normalized = words.map(normalizeWord).filter(Boolean);
return {
words: normalized,
canonical: normalized.join("")
};
};
export function ForgotPassword() {
const navigate = useNavigate();
const { showToast } = useToast();
const { user } = useAuth();
const [username, setUsername] = useState("");
const [phraseInputs, setPhraseInputs] = useState(Array(PHRASE_WORD_COUNT).fill(""));
const [fileName, setFileName] = useState("");
const [fileContent, setFileContent] = useState("");
const [verificationError, setVerificationError] = useState("");
const [verifying, setVerifying] = useState(false);
const [verificationToken, setVerificationToken] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState("");
useEffect(() => {
if (user) {
navigate("/dashboard/stacks", { replace: true });
}
}, [user, navigate]);
const phraseWords = useMemo(() => {
if (fileContent.trim()) {
return extractWordsFromText(fileContent);
}
return phraseInputs.map(normalizeWord).filter(Boolean);
}, [fileContent, phraseInputs]);
const handlePhraseInputChange = useCallback((index, value) => {
setPhraseInputs((prev) => {
const next = [...prev];
next[index] = value;
return next;
});
}, []);
const handleFileUpload = useCallback((event) => {
const [file] = event.target.files || [];
if (!file) {
setFileName("");
setFileContent("");
return;
}
setFileName(file.name);
const reader = new FileReader();
reader.onload = (loadEvent) => {
const text = String(loadEvent.target?.result || "");
setFileContent(text);
setPhraseInputs(Array(PHRASE_WORD_COUNT).fill(""));
};
reader.onerror = () => {
setFileName("");
setFileContent("");
showToast({
variant: "error",
title: "Datei konnte nicht gelesen werden",
description: "Bitte lade die Datei erneut hoch."
});
};
reader.readAsText(file);
}, [showToast]);
const handleVerifyPhrase = useCallback(async () => {
setVerificationError("");
setVerificationToken("");
const trimmedUsername = username.trim();
if (!trimmedUsername) {
setVerificationError("Bitte gib deinen Benutzernamen ein.");
return;
}
const words = phraseWords;
const hasFile = Boolean(fileContent.trim());
if (!hasFile && words.length !== PHRASE_WORD_COUNT) {
setVerificationError("Bitte gib alle acht Wörter des Sicherheitsschlüssels ein.");
return;
}
if (hasFile && words.length !== PHRASE_WORD_COUNT) {
setVerificationError("Die hochgeladene Datei enthält nicht genau acht Wörter.");
return;
}
const payload = buildPhrasePayload(words);
if (!payload.canonical) {
setVerificationError("Der Sicherheitsschlüssel ist ungültig oder leer.");
return;
}
setVerifying(true);
try {
const response = await axios.post("/api/auth/recover/verify", {
username: trimmedUsername,
phrase: payload.canonical,
words: payload.words
});
const responseToken = response.data?.token;
if (!responseToken) {
throw new Error("TOKEN_MISSING");
}
setVerificationToken(responseToken);
showToast({
variant: "success",
title: "Sicherheitsschlüssel bestätigt",
description: "Du kannst jetzt ein neues Passwort festlegen."
});
} catch (error) {
const serverError = error.response?.data?.error;
if (serverError === "USER_NOT_FOUND") {
setVerificationError("Der angegebene Benutzer wurde nicht gefunden.");
} else if (serverError === "PHRASE_MISMATCH") {
setVerificationError("Der Sicherheitsschlüssel passt nicht zum Benutzer.");
} else if (serverError === "PHRASE_NOT_INITIALIZED") {
setVerificationError("Für diesen Benutzer ist aktuell kein Sicherheitsschlüssel hinterlegt.");
} else if (serverError === "PHRASE_REQUIRED") {
setVerificationError("Bitte gib den Sicherheitsschlüssel ein.");
} else if (serverError === "USERNAME_REQUIRED") {
setVerificationError("Der Benutzername darf nicht leer sein.");
} else if (serverError === "INVALID_PASSWORD") {
setVerificationError("Der Sicherheitsschlüssel ist ungültig.");
} else {
setVerificationError("Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
}
} finally {
setVerifying(false);
}
}, [fileContent, phraseWords, showToast, username]);
const handleResetPassword = useCallback(async () => {
setResetError("");
if (!verificationToken) {
setResetError("Bitte bestätige zuerst deinen Sicherheitsschlüssel.");
return;
}
const trimmedPassword = password.trim();
const trimmedConfirm = confirmPassword.trim();
if (!trimmedPassword || !trimmedConfirm) {
setResetError("Bitte gib das neue Passwort zweimal ein.");
return;
}
if (trimmedPassword !== trimmedConfirm) {
setResetError("Die Passwörter stimmen nicht überein.");
return;
}
setResetting(true);
try {
await axios.post("/api/auth/recover/reset", {
token: verificationToken,
password: trimmedPassword,
confirmPassword: trimmedConfirm
});
showToast({
variant: "success",
title: "Passwort aktualisiert",
description: "Du kannst dich jetzt mit deinem neuen Passwort anmelden."
});
navigate("/auth/sign-in", { replace: true });
} catch (error) {
const serverError = error.response?.data?.error;
if (serverError === "TOKEN_INVALID_OR_EXPIRED") {
setResetError("Die Wiederherstellungssitzung ist abgelaufen. Bitte starte den Vorgang erneut.");
setVerificationToken("");
} else if (serverError === "PASSWORD_TOO_SHORT") {
setResetError("Das Passwort muss mindestens 8 Zeichen enthalten.");
} else if (serverError === "PASSWORD_REQUIRED") {
setResetError("Bitte gib ein neues Passwort ein.");
} else if (serverError === "PASSWORD_MISMATCH") {
setResetError("Die Passwörter stimmen nicht überein.");
} else {
setResetError("Das Passwort konnte nicht zurückgesetzt werden. Bitte versuche es erneut.");
}
} finally {
setResetting(false);
}
}, [confirmPassword, navigate, password, showToast, verificationToken]);
const verificationCompleted = Boolean(verificationToken);
return (
<section className="m-8 flex justify-center">
<Card className="w-full max-w-3xl">
<CardHeader floated={false} shadow={false} className="rounded-none border-b border-blue-gray-50 bg-white px-6 py-5">
<Typography variant="h4" color="blue-gray" className="font-semibold">
Passwort zurücksetzen
</Typography>
<Typography color="blue-gray" className="mt-1 text-sm">
Gib deinen Benutzernamen und den Sicherheitsschlüssel (8 Wörter) ein oder lade die gespeicherte Schlüsseldatei.
</Typography>
</CardHeader>
<CardBody className="space-y-8">
<div className="space-y-4">
<Typography variant="h6" color="blue-gray">
Schritt 1: Sicherheitsschlüssel prüfen
</Typography>
<div>
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Benutzername
</Typography>
<Input
size="lg"
placeholder="Benutzername"
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={verificationCompleted || verifying}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{ className: "before:content-none after:content-none" }}
/>
</div>
<div>
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Sicherheitsschlüssel eingeben
</Typography>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4 min-w-[20px] [&>div]:!min-w-0">
{phraseInputs.map((value, index) => (
<Input
key={`phrase-input-${index}`}
placeholder={`Wort ${index + 1}`}
value={value}
onChange={(event) => handlePhraseInputChange(index, event.target.value)}
disabled={verificationCompleted || verifying || Boolean(fileContent.trim())}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{ className: "before:content-none after:content-none" }}
/>
))}
</div>
</div>
<div>
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Alternativ: Schlüsseldatei (.txt) hochladen
</Typography>
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<input
type="file"
accept=".txt"
onChange={handleFileUpload}
disabled={verificationCompleted || verifying}
className="block w-full max-w-xs text-sm text-blue-gray-500 file:mr-4 file:rounded-md file:border-0 file:bg-blue-gray-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-blue-gray-700 hover:file:bg-blue-gray-100"
/>
{fileName && (
<Typography className="text-xs text-blue-gray-500">
Ausgewählte Datei: <span className="font-medium text-blue-gray-700">{fileName}</span>
</Typography>
)}
</div>
</div>
{verificationError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{verificationError}
</Alert>
)}
<div className="flex flex-wrap items-center gap-4">
<Button
color="blue"
className="normal-case"
onClick={handleVerifyPhrase}
disabled={verifying || verificationCompleted}
>
{verifying ? (
<span className="flex items-center gap-2">
<Spinner className="h-4 w-4" /> Prüfe Sicherheitsschlüssel ...
</span>
) : (
"Sicherheitsschlüssel prüfen"
)}
</Button>
{verificationCompleted && (
<Typography className="text-sm text-blue-gray-500">
Die Wiederherstellung bleibt für kurze Zeit aktiv. Bitte setze dein Passwort zeitnah zurück.
</Typography>
)}
</div>
</div>
{/* Schritt 2 */}
<div className="space-y-4">
<Typography variant="h6" color={verificationCompleted ? "blue-gray" : "gray"}>
Schritt 2: Neues Passwort festlegen
</Typography>
{!verificationCompleted && (
<Typography className="text-sm text-blue-gray-500">
Nach erfolgreicher Prüfung des Sicherheitsschlüssels kannst du hier ein neues Passwort vergeben.
</Typography>
)}
{verificationCompleted && (
<div className="space-y-4">
<div>
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Neues Passwort
</Typography>
<Input
type="password"
size="lg"
placeholder="Neues Passwort"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={resetting}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{ className: "before:content-none after:content-none" }}
/>
</div>
<div>
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Passwort bestätigen
</Typography>
<Input
type="password"
size="lg"
placeholder="Passwort bestätigen"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
disabled={resetting}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{ className: "before:content-none after:content-none" }}
/>
</div>
{resetError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{resetError}
</Alert>
)}
<Button
color="green"
className="normal-case"
onClick={handleResetPassword}
disabled={resetting}
>
{resetting ? (
<span className="flex items-center gap-2">
<Spinner className="h-4 w-4" /> Speichere Passwort ...
</span>
) : (
"Neues Passwort speichern"
)}
</Button>
</div>
)}
</div>
<div className="border-t border-blue-gray-50 pt-4">
<Typography
as="button"
type="button"
onClick={() => navigate("/auth/sign-in")}
className="text-sm font-medium text-blue-600 transition hover:text-blue-800"
>
Zurück zur Anmeldung
</Typography>
</div>
</CardBody>
</Card>
</section>
);
}
export default ForgotPassword;