Add Phase 1 MVP: D&D text-adventure server
FastAPI + Postgres backend with fastapi-users auth, games/characters CRUD, and a WebSocket chat endpoint where an x.ai Grok DM narrates play, rolls dice, and maintains character sheets via tool calls. React + Tailwind frontend covers registration/login, game list and creation, participation-code join flow, character viewer, and a real-time chat session view. Docker Compose skeleton (Postgres + backend) included; Caddy/frontend container wiring is deferred to the deploy milestone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { Character, getCharacter } from "../api/characters";
|
||||
|
||||
export default function CharacterDetail() {
|
||||
const { characterId } = useParams<{ characterId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const backGameId = searchParams.get("back");
|
||||
|
||||
const [character, setCharacter] = useState<Character | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!characterId) return;
|
||||
getCharacter(characterId)
|
||||
.then(setCharacter)
|
||||
.finally(() => setLoading(false));
|
||||
}, [characterId]);
|
||||
|
||||
if (loading) return <p className="p-8 text-slate-400">Lädt…</p>;
|
||||
if (!character) return <p className="p-8 text-slate-400">Charakter nicht gefunden.</p>;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-8">
|
||||
{backGameId && (
|
||||
<Link
|
||||
to={`/games/${backGameId}`}
|
||||
className="mb-4 inline-block rounded bg-slate-800 px-3 py-2 text-sm hover:bg-slate-700"
|
||||
>
|
||||
← Zurück zum Spiel
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<h1 className="text-2xl font-bold text-amber-400">{character.name}</h1>
|
||||
<p className="mt-1 text-slate-400">
|
||||
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
|
||||
</p>
|
||||
|
||||
{Object.keys(character.stats).length > 0 && (
|
||||
<div className="mt-4 grid grid-cols-3 gap-3 sm:grid-cols-6">
|
||||
{Object.entries(character.stats).map(([stat, value]) => (
|
||||
<div key={stat} className="rounded bg-slate-800 p-2 text-center">
|
||||
<div className="text-xs uppercase text-slate-500">{stat}</div>
|
||||
<div className="text-lg font-semibold text-slate-100">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 whitespace-pre-wrap rounded-lg border border-slate-800 bg-slate-900 p-4 text-slate-300">
|
||||
{character.description || "Noch keine Beschreibung."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Character, listCharacters } from "../api/characters";
|
||||
import CharacterCard from "../components/CharacterCard";
|
||||
|
||||
export default function CharactersList() {
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
listCharacters()
|
||||
.then(setCharacters)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-400">Charaktere</h1>
|
||||
{loading ? (
|
||||
<p className="text-slate-400">Lädt…</p>
|
||||
) : characters.length === 0 ? (
|
||||
<p className="text-slate-400">
|
||||
Noch keine Charaktere — sie entstehen im Laufe eines Spiels.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{characters.map((c) => (
|
||||
<CharacterCard key={c.id} character={c} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { forgotPassword } from "../api/auth";
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
await forgotPassword(email).catch(() => undefined);
|
||||
setSent(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-sm px-4">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-400">Passwort vergessen</h1>
|
||||
{sent ? (
|
||||
<p className="text-slate-300">
|
||||
Falls ein Konto mit dieser E-Mail existiert, wurde eine Nachricht mit einem
|
||||
Reset-Link verschickt.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="E-Mail"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400"
|
||||
>
|
||||
Link anfordern
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-4 text-sm text-slate-400">
|
||||
<Link to="/login" className="hover:text-amber-400">
|
||||
Zurück zur Anmeldung
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { listCharacters, Character } from "../api/characters";
|
||||
import { Game, getFullMessages, getGame, getRecentMessages, joinGame, Message } from "../api/games";
|
||||
import SendMessageForm from "../components/SendMessageForm";
|
||||
import TranscriptPane from "../components/TranscriptPane";
|
||||
import { useGameSocket } from "../hooks/useGameSocket";
|
||||
|
||||
export default function GameSession() {
|
||||
const { gameId } = useParams<{ gameId: string }>();
|
||||
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [showingFull, setShowingFull] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [joinCode, setJoinCode] = useState("");
|
||||
const [selectedCharacterId, setSelectedCharacterId] = useState("");
|
||||
const [joinError, setJoinError] = useState<string | null>(null);
|
||||
|
||||
async function loadGame() {
|
||||
if (!gameId) return;
|
||||
const g = await getGame(gameId);
|
||||
setGame(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
async function loadRecent() {
|
||||
if (!gameId) return;
|
||||
setMessages(await getRecentMessages(gameId, 5));
|
||||
setShowingFull(false);
|
||||
}
|
||||
|
||||
async function loadFull() {
|
||||
if (!gameId) return;
|
||||
setMessages(await getFullMessages(gameId));
|
||||
setShowingFull(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const g = await loadGame();
|
||||
if (g?.is_participant) {
|
||||
await loadRecent();
|
||||
} else {
|
||||
setCharacters(await listCharacters());
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gameId]);
|
||||
|
||||
async function handleJoin(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!gameId) return;
|
||||
setJoinError(null);
|
||||
try {
|
||||
const g = await joinGame(gameId, joinCode, selectedCharacterId || undefined);
|
||||
setGame(g);
|
||||
await loadRecent();
|
||||
} catch {
|
||||
setJoinError("Beitritt fehlgeschlagen — Teilnahmecode prüfen.");
|
||||
}
|
||||
}
|
||||
|
||||
const { send } = useGameSocket(
|
||||
game?.is_participant ? gameId : undefined,
|
||||
(incoming) => {
|
||||
setMessages((prev) => {
|
||||
const next = [...prev, incoming];
|
||||
return showingFull ? next : next.slice(-5);
|
||||
});
|
||||
},
|
||||
(detail) => {
|
||||
const text =
|
||||
detail === "connection_lost"
|
||||
? "Verbindung zum Server verloren. Bitte lade die Seite neu."
|
||||
: "Der Dungeon Master antwortet gerade nicht. Bitte versuche es erneut.";
|
||||
const errorMessage: Message = {
|
||||
id: -Date.now(),
|
||||
sender_type: "system",
|
||||
user_id: null,
|
||||
player_name: null,
|
||||
character_id: null,
|
||||
character_name: null,
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const next = [...prev, errorMessage];
|
||||
return showingFull ? next : next.slice(-5);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
async function handleSend(content: string) {
|
||||
send(content);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="p-8 text-slate-400">Lädt…</p>;
|
||||
}
|
||||
|
||||
if (!game) {
|
||||
return <p className="p-8 text-slate-400">Spiel nicht gefunden.</p>;
|
||||
}
|
||||
|
||||
if (!game.is_participant) {
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-sm px-4">
|
||||
<h1 className="mb-2 text-2xl font-bold text-amber-400">{game.name}</h1>
|
||||
<p className="mb-6 text-sm text-slate-400">{game.description}</p>
|
||||
<form onSubmit={handleJoin} className="flex flex-col gap-4">
|
||||
<input
|
||||
required
|
||||
placeholder="Teilnahmecode"
|
||||
value={joinCode}
|
||||
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
|
||||
className="rounded bg-slate-800 px-3 py-2 font-mono outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
{characters.length > 0 && (
|
||||
<select
|
||||
value={selectedCharacterId}
|
||||
onChange={(e) => setSelectedCharacterId(e.target.value)}
|
||||
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||
>
|
||||
<option value="">Neuen Charakter im Spiel erstellen</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.race} {c.char_class})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{joinError && <p className="text-sm text-red-400">{joinError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400"
|
||||
>
|
||||
Beitreten
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-[calc(100vh-56px)] max-w-3xl flex-col px-4 py-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-amber-400">{game.name}</h1>
|
||||
<p className="text-xs text-slate-500">{game.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{game.my_character_id && (
|
||||
<Link
|
||||
to={`/characters/${game.my_character_id}?back=${gameId}`}
|
||||
className="rounded bg-slate-800 px-3 py-2 text-sm hover:bg-slate-700"
|
||||
>
|
||||
Mein Charakter
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={showingFull ? loadRecent : loadFull}
|
||||
className="rounded bg-slate-800 px-3 py-2 text-sm hover:bg-slate-700"
|
||||
>
|
||||
{showingFull ? "Nur letzte 5" : "Volltext laden"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-slate-800 bg-slate-900">
|
||||
<TranscriptPane messages={messages} />
|
||||
<SendMessageForm onSend={handleSend} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { createGame, Game, listGames } from "../api/games";
|
||||
import GameCard from "../components/GameCard";
|
||||
|
||||
export default function GamesList() {
|
||||
const navigate = useNavigate();
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
setGames(await listGames());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
const game = await createGame(name, description);
|
||||
navigate(`/games/${game.id}`);
|
||||
} catch {
|
||||
setError("Spiel konnte nicht erstellt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-amber-400">Spiele</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreate((v) => !v)}
|
||||
className="rounded bg-amber-500 px-3 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400"
|
||||
>
|
||||
Neues Spiel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<form onSubmit={handleCreate} className="mb-6 flex flex-col gap-3 rounded-lg border border-slate-800 bg-slate-900 p-4">
|
||||
<input
|
||||
required
|
||||
placeholder="Name des Spiels"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Kurzbeschreibung (Genre, Welt, ...)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||
rows={3}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="self-start rounded bg-amber-500 px-3 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400"
|
||||
>
|
||||
Erstellen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && <p className="mb-4 text-sm text-red-400">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-slate-400">Lädt…</p>
|
||||
) : games.length === 0 ? (
|
||||
<p className="text-slate-400">Noch keine Spiele vorhanden.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{games.map((game) => (
|
||||
<GameCard key={game.id} game={game} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate("/games");
|
||||
} catch {
|
||||
setError("Anmeldung fehlgeschlagen. E-Mail oder Passwort falsch.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-sm px-4">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-400">Anmelden</h1>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="E-Mail"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
placeholder="Passwort"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-4 flex justify-between text-sm text-slate-400">
|
||||
<Link to="/register" className="hover:text-amber-400">
|
||||
Registrieren
|
||||
</Link>
|
||||
<Link to="/forgot-password" className="hover:text-amber-400">
|
||||
Passwort vergessen?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
export default function Register() {
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await register(email, password, name);
|
||||
navigate("/games");
|
||||
} catch {
|
||||
setError("Registrierung fehlgeschlagen. E-Mail evtl. schon vergeben oder Passwort zu schwach.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-sm px-4">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-400">Registrieren</h1>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="E-Mail"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="Passwort (mind. 8 Zeichen)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
|
||||
>
|
||||
Konto erstellen
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-4 text-sm text-slate-400">
|
||||
<Link to="/login" className="hover:text-amber-400">
|
||||
Schon registriert? Anmelden
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { resetPassword } from "../api/auth";
|
||||
|
||||
export default function ResetPassword() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get("token") ?? "";
|
||||
const navigate = useNavigate();
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await resetPassword(token, password);
|
||||
setDone(true);
|
||||
setTimeout(() => navigate("/login"), 1500);
|
||||
} catch {
|
||||
setError("Zurücksetzen fehlgeschlagen. Der Link ist evtl. abgelaufen.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-sm px-4 text-slate-300">
|
||||
Kein gültiger Reset-Link.{" "}
|
||||
<Link to="/forgot-password" className="text-amber-400">
|
||||
Neu anfordern
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-sm px-4">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-400">Neues Passwort setzen</h1>
|
||||
{done ? (
|
||||
<p className="text-slate-300">Passwort geändert. Du wirst weitergeleitet…</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="Neues Passwort"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400"
|
||||
>
|
||||
Passwort setzen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user