feat(auth): Einrichtung, Anmeldeseite und Registrierung
- /setup fuehrt beim ersten Aufruf durch das Anlegen des Administratorkontos und die Wahl der Betriebsart. Der Endpunkt funktioniert nur, solange es keinen Benutzer gibt; danach kann sich niemand mehr nachtraeglich ein Adminkonto erschleichen. Vorhandene Projekte ohne Besitzer werden dabei uebernommen und im Formular vorher angekuendigt. - /login mit Passwort oder Passkey. Der Passkey-Knopf entfaellt, wenn APP_URL keinen sicheren Kontext ergibt - mit Hinweis, woran es liegt. Das Ziel nach der Anmeldung wird auf relative Pfade begrenzt, sonst waere der weiter-Parameter ein offener Redirect. - /register nur im Mehrbenutzerbetrieb und nur, wenn ein Administrator die Selbstregistrierung freigegeben hat. - Kopfzeile mit Benutzermenue: eigenes Konto, Admin-Einstellungen, Abmelden. Ohne Anmeldung bleibt sie auf das Logo reduziert. - Layout liest Titel und Namen ueber generateMetadata, damit APP_NAME zur Laufzeit greift statt beim Build eingefroren zu werden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+153
-25
@@ -1,8 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { GitBranch, LayoutDashboard, Sparkles } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
GitBranch,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import type { SessionUser } from "@/lib/auth/session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LINKS = [
|
||||
@@ -10,7 +20,15 @@ const LINKS = [
|
||||
{ href: "/connections", label: "Git-Verbindungen", icon: GitBranch },
|
||||
];
|
||||
|
||||
export function TopNav() {
|
||||
export function TopNav({
|
||||
user,
|
||||
appName,
|
||||
tagline,
|
||||
}: {
|
||||
user: SessionUser | null;
|
||||
appName: string;
|
||||
tagline: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
@@ -22,35 +40,145 @@ export function TopNav() {
|
||||
</span>
|
||||
<span className="flex flex-col leading-none">
|
||||
<span className="text-gradient text-lg font-semibold tracking-tight">
|
||||
Nebula
|
||||
{appName}
|
||||
</span>
|
||||
<span className="mt-0.5 text-[10px] uppercase tracking-[0.2em] text-slate-500">
|
||||
Vibe Coding
|
||||
{tagline}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-1">
|
||||
{LINKS.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
active
|
||||
? "bg-white/8 text-white"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-slate-100",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{user ? (
|
||||
<>
|
||||
<nav className="flex items-center gap-1">
|
||||
{LINKS.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
active
|
||||
? "bg-white/8 text-white"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-slate-100",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto">
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu({ user }: { user: SessionUser }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
}
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
async function logout() {
|
||||
setBusy(true);
|
||||
await fetch("/api/auth/logout", { method: "POST" }).catch(() => undefined);
|
||||
setOpen(false);
|
||||
router.replace("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const label = user.displayName?.trim() || user.username;
|
||||
const initials = label.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-violet-500/80 to-cyan-400/70 text-[11px] font-semibold text-white">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="hidden max-w-[10rem] truncate sm:inline">{label}</span>
|
||||
{user.role === "ADMIN" ? (
|
||||
<ShieldCheck className="hidden h-3.5 w-3.5 text-violet-300 sm:inline" />
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="glass fade-up absolute right-0 z-50 mt-2 w-60 overflow-hidden p-1.5"
|
||||
>
|
||||
<div className="border-b border-white/6 px-3 py-2">
|
||||
<p className="truncate text-sm text-slate-100">{label}</p>
|
||||
<p className="truncate text-[11px] text-slate-500">
|
||||
@{user.username}
|
||||
{user.role === "ADMIN" ? " · Administrator" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/account"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<UserRound className="h-4 w-4" />
|
||||
Mein Konto
|
||||
</Link>
|
||||
|
||||
{user.role === "ADMIN" ? (
|
||||
<Link
|
||||
href="/admin"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Admin-Einstellungen
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={logout}
|
||||
disabled={busy}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-rose-200 transition-colors hover:bg-rose-500/10 disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
/** Zentrierte Karte fuer Einrichtung, Anmeldung und Registrierung. */
|
||||
export function AuthShell({
|
||||
appName,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
appName: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] w-full max-w-md flex-col justify-center py-10">
|
||||
<div className="glass fade-up relative overflow-hidden p-7">
|
||||
<span className="accent-bar absolute inset-x-0 top-0 h-[3px]" aria-hidden="true" />
|
||||
<div
|
||||
className="pointer-events-none absolute -right-24 -top-24 h-56 w-56 rounded-full opacity-25 blur-3xl"
|
||||
style={{ background: "radial-gradient(circle, #8b5cf6, transparent 70%)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-xl bg-gradient-to-br from-violet-500 via-fuchsia-500 to-cyan-400">
|
||||
<Sparkles className="h-5 w-5 text-white" strokeWidth={2.4} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-gradient text-lg font-semibold leading-none">{appName}</p>
|
||||
<p className="mt-1 text-[10px] uppercase tracking-[0.2em] text-slate-500">
|
||||
Vibe Coding Dashboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-xl font-semibold text-white">{title}</h1>
|
||||
{subtitle ? <p className="mt-1.5 text-sm text-slate-400">{subtitle}</p> : null}
|
||||
|
||||
<div className="mt-6">{children}</div>
|
||||
</div>
|
||||
|
||||
{footer ? <div className="mt-4 text-center text-sm text-slate-500">{footer}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
import { Fingerprint, KeyRound, Loader2, LogIn } from "lucide-react";
|
||||
|
||||
export function LoginForm({
|
||||
passkeysAvailable,
|
||||
registrationOpen,
|
||||
next,
|
||||
}: {
|
||||
passkeysAvailable: boolean;
|
||||
registrationOpen: boolean;
|
||||
next: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState<"password" | "passkey" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function done() {
|
||||
// replace statt push: die Anmeldeseite soll nicht im Verlauf landen.
|
||||
router.replace(next);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function loginWithPassword(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy("password");
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error ?? "Anmeldung fehlgeschlagen.");
|
||||
done();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithPasskey() {
|
||||
setBusy("passkey");
|
||||
setError(null);
|
||||
try {
|
||||
const optionsResponse = await fetch("/api/auth/passkey/login/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const optionsPayload = await optionsResponse.json().catch(() => ({}));
|
||||
if (!optionsResponse.ok) {
|
||||
throw new Error(optionsPayload.error ?? "Passkey-Anmeldung nicht moeglich.");
|
||||
}
|
||||
|
||||
const assertion = await startAuthentication({
|
||||
optionsJSON: optionsPayload.options,
|
||||
});
|
||||
|
||||
const verifyResponse = await fetch("/api/auth/passkey/login/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ response: assertion }),
|
||||
});
|
||||
const verifyPayload = await verifyResponse.json().catch(() => ({}));
|
||||
if (!verifyResponse.ok) {
|
||||
throw new Error(verifyPayload.error ?? "Passkey wurde nicht akzeptiert.");
|
||||
}
|
||||
done();
|
||||
} catch (caught) {
|
||||
// Bricht der Benutzer den Systemdialog ab, ist das kein Fehler.
|
||||
const message = caught instanceof Error ? caught.message : String(caught);
|
||||
setError(/abort|NotAllowed/i.test(message) ? null : message);
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{passkeysAvailable ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loginWithPasskey}
|
||||
disabled={busy !== null}
|
||||
className="btn btn-ghost w-full py-2.5"
|
||||
>
|
||||
{busy === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Fingerprint className="h-4 w-4 text-violet-300" />
|
||||
)}
|
||||
Mit Passkey anmelden
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wider text-slate-600">
|
||||
<span className="h-px flex-1 bg-white/8" />
|
||||
oder
|
||||
<span className="h-px flex-1 bg-white/8" />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={loginWithPassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="login-username">
|
||||
Benutzername
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
className="field"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username webauthn"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="login-password">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
className="field"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy !== null}>
|
||||
{busy === "password" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="h-4 w-4" />
|
||||
)}
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{registrationOpen ? (
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Noch kein Konto?{" "}
|
||||
<Link href="/register" className="text-violet-300 hover:text-violet-200">
|
||||
Registrieren
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!passkeysAvailable ? (
|
||||
<p className="flex items-start gap-2 rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-[11px] leading-snug text-slate-500">
|
||||
<KeyRound className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Passkeys sind deaktiviert: Sie brauchen einen sicheren Kontext. Dafuer muss
|
||||
<code className="mx-1 text-violet-300">APP_URL</code>
|
||||
auf eine https-Adresse oder auf http://localhost zeigen.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, UserPlus } from "lucide-react";
|
||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
||||
|
||||
export function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
if (password !== repeat) {
|
||||
setFieldErrors({ repeat: "Die Passwoerter stimmen nicht ueberein." });
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, displayName, email, password }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setFieldErrors(payload.fields ?? {});
|
||||
throw new Error(payload.error ?? "Registrierung fehlgeschlagen.");
|
||||
}
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="register-username">
|
||||
Benutzername *
|
||||
</label>
|
||||
<input
|
||||
id="register-username"
|
||||
className="field"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
{fieldErrors.username ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="register-display">
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
id="register-display"
|
||||
className="field"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="register-email">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="register-email"
|
||||
className="field"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
placeholder="optional"
|
||||
/>
|
||||
{fieldErrors.email ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="register-password">
|
||||
Passwort *
|
||||
</label>
|
||||
<input
|
||||
id="register-password"
|
||||
className="field"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
/>
|
||||
{fieldErrors.password ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="register-repeat">
|
||||
Passwort wiederholen *
|
||||
</label>
|
||||
<input
|
||||
id="register-repeat"
|
||||
className="field"
|
||||
type="password"
|
||||
value={repeat}
|
||||
onChange={(event) => setRepeat(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
{fieldErrors.repeat ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.repeat}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
|
||||
Konto anlegen
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Schon ein Konto?{" "}
|
||||
<Link href="/login" className="text-violet-300 hover:text-violet-200">
|
||||
Anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Rocket, User, Users } from "lucide-react";
|
||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Mode = "SINGLE_USER" | "MULTI_USER";
|
||||
|
||||
const MODES: Array<{ value: Mode; label: string; hint: string; icon: typeof User }> = [
|
||||
{
|
||||
value: "SINGLE_USER",
|
||||
label: "Nur ich",
|
||||
hint: "Ein Konto, keine Registrierung, keine Benutzerliste.",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
value: "MULTI_USER",
|
||||
label: "Mehrere Personen",
|
||||
hint: "Weitere Konten moeglich, jedes mit streng getrennten Daten.",
|
||||
icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
export function SetupForm() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [mode, setMode] = useState<Mode>("SINGLE_USER");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
if (password !== repeat) {
|
||||
setFieldErrors({ repeat: "Die Passwoerter stimmen nicht ueberein." });
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, displayName, email, password, mode }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setFieldErrors(payload.fields ?? {});
|
||||
throw new Error(payload.error ?? "Einrichtung fehlgeschlagen.");
|
||||
}
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
<div>
|
||||
<span className="field-label">Betriebsart</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{MODES.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = mode === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setMode(option.value)}
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-3 text-left transition-all",
|
||||
active
|
||||
? "border-violet-400/60 bg-violet-500/12 shadow-[0_0_24px_-10px_rgba(139,92,246,0.9)]"
|
||||
: "border-white/9 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-slate-100">
|
||||
<Icon className={cn("h-4 w-4", active ? "text-violet-300" : "text-slate-500")} />
|
||||
{option.label}
|
||||
</span>
|
||||
<span className="mt-1 block text-[11px] leading-snug text-slate-500">
|
||||
{option.hint}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-slate-500">
|
||||
Laesst sich spaeter in den Admin-Einstellungen umstellen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="setup-username">
|
||||
Benutzername *
|
||||
</label>
|
||||
<input
|
||||
id="setup-username"
|
||||
className="field"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
placeholder="florian"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
{fieldErrors.username ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="setup-display">
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
id="setup-display"
|
||||
className="field"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="Florian"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="setup-email">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="setup-email"
|
||||
className="field"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
placeholder="optional"
|
||||
/>
|
||||
{fieldErrors.email ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="setup-password">
|
||||
Passwort *
|
||||
</label>
|
||||
<input
|
||||
id="setup-password"
|
||||
className="field"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
Mindestens {PASSWORD_MIN_LENGTH} Zeichen. Einen Passkey kannst du danach unter
|
||||
„Mein Konto“ hinzufuegen.
|
||||
</p>
|
||||
{fieldErrors.password ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="setup-repeat">
|
||||
Passwort wiederholen *
|
||||
</label>
|
||||
<input
|
||||
id="setup-repeat"
|
||||
className="field"
|
||||
type="password"
|
||||
value={repeat}
|
||||
onChange={(event) => setRepeat(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
{fieldErrors.repeat ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.repeat}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Rocket className="h-4 w-4" />}
|
||||
Einrichten und loslegen
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user