409 lines
15 KiB
React
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;
|