diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..77fde27 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password"; +import { createSession, setSessionCookie } from "@/lib/auth/session"; +import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users"; +import { prisma } from "@/lib/db"; +import { LIMITS, rateLimit, resetRateLimit } from "@/lib/security/rateLimit"; +import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; +import { loginSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +/** Immer dieselbe Meldung – sonst verraet sie, welche Konten existieren. */ +const GENERIC = "Benutzername oder Passwort ist falsch."; + +export async function POST(request: Request) { + const ip = clientIp(request); + + const byIp = rateLimit(`login:ip:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs); + if (!byIp.ok) return tooManyRequests(byIp.retryAfter); + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = loginSchema.safeParse(body.data); + if (!parsed.success) return jsonError(GENERIC, 401); + + const { username, password } = parsed.data; + + const byUser = rateLimit( + `login:user:${username}`, + LIMITS.loginUser.limit, + LIMITS.loginUser.windowMs, + ); + if (!byUser.ok) return tooManyRequests(byUser.retryAfter); + + const user = await prisma.user.findUnique({ where: { username } }); + + if (!user) { + // Gleiche Rechenzeit verbrennen wie bei einem echten Konto. + await fakeVerifyDelay(); + return jsonError(GENERIC, 401); + } + + const locked = lockRemainingSeconds(user.lockedUntil); + if (locked > 0) { + return jsonError( + `Das Konto ist nach mehreren Fehlversuchen noch ${Math.ceil(locked / 60)} Minuten gesperrt.`, + 423, + ); + } + + if (!user.isActive) { + await fakeVerifyDelay(); + return jsonError("Dieses Konto ist deaktiviert.", 403); + } + + if (!user.passwordHash) { + await fakeVerifyDelay(); + return jsonError( + "Fuer dieses Konto ist kein Passwort hinterlegt – bitte mit Passkey anmelden.", + 401, + ); + } + + const valid = await verifyPassword(password, user.passwordHash); + if (!valid) { + await noteFailedLogin(user.id); + return jsonError(GENERIC, 401); + } + + await noteSuccessfulLogin(user.id); + resetRateLimit(`login:user:${username}`); + resetRateLimit(`login:ip:${ip}`); + + const session = await createSession(user.id, { + userAgent: request.headers.get("user-agent"), + ip, + }); + + const response = NextResponse.json({ + ok: true, + user: { id: user.id, username: user.username, role: user.role }, + }); + setSessionCookie(response, session.token, session.expiresAt); + return response; +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..3326235 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { clearSessionCookie, destroyCurrentSession } from "@/lib/auth/session"; + +export const dynamic = "force-dynamic"; + +export async function POST() { + await destroyCurrentSession(); + const response = NextResponse.json({ ok: true }); + clearSessionCookie(response); + return response; +} diff --git a/src/app/api/auth/passkey/login/options/route.ts b/src/app/api/auth/passkey/login/options/route.ts new file mode 100644 index 0000000..f511750 --- /dev/null +++ b/src/app/api/auth/passkey/login/options/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { WebAuthnUnavailableError, buildAuthenticationOptions } from "@/lib/auth/webauthn"; +import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; +import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request"; + +export const dynamic = "force-dynamic"; + +/** + * Optionen fuer navigator.credentials.get(). Ohne allowCredentials – der + * Browser bietet alle passenden Passkeys an, ein Benutzername ist nicht + * noetig. Der Endpunkt verraet dadurch auch nichts ueber vorhandene Konten. + */ +export async function POST(request: Request) { + const limited = rateLimit( + `webauthn:auth:${clientIp(request)}`, + LIMITS.webauthn.limit, + LIMITS.webauthn.windowMs, + ); + if (!limited.ok) return tooManyRequests(limited.retryAfter); + + try { + const options = await buildAuthenticationOptions(); + return NextResponse.json({ options }); + } catch (error) { + if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501); + return jsonError(error instanceof Error ? error.message : String(error), 500); + } +} diff --git a/src/app/api/auth/passkey/login/verify/route.ts b/src/app/api/auth/passkey/login/verify/route.ts new file mode 100644 index 0000000..1d46af6 --- /dev/null +++ b/src/app/api/auth/passkey/login/verify/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import type { AuthenticationResponseJSON } from "@simplewebauthn/server"; +import { createSession, setSessionCookie } from "@/lib/auth/session"; +import { noteSuccessfulLogin } from "@/lib/auth/users"; +import { WebAuthnUnavailableError, confirmAuthentication } from "@/lib/auth/webauthn"; +import { prisma } from "@/lib/db"; +import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; +import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; + +export const dynamic = "force-dynamic"; + +interface Payload { + response?: AuthenticationResponseJSON; +} + +export async function POST(request: Request) { + const ip = clientIp(request); + const limited = rateLimit(`passkey:login:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs); + if (!limited.ok) return tooManyRequests(limited.retryAfter); + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const assertion = body.data?.response; + if (!assertion || typeof assertion !== "object") { + return jsonError("Die Antwort des Authenticators fehlt.", 400); + } + + try { + const result = await confirmAuthentication(assertion); + + const user = await prisma.user.findUnique({ where: { id: result.userId } }); + if (!user || !user.isActive) { + return jsonError("Dieses Konto ist deaktiviert.", 403); + } + + await noteSuccessfulLogin(user.id); + + const session = await createSession(user.id, { + userAgent: request.headers.get("user-agent"), + ip, + }); + + const response = NextResponse.json({ + ok: true, + user: { id: user.id, username: user.username, role: user.role }, + }); + setSessionCookie(response, session.token, session.expiresAt); + return response; + } catch (error) { + if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501); + return jsonError(error instanceof Error ? error.message : String(error), 401); + } +} diff --git a/src/app/api/auth/passkey/register/options/route.ts b/src/app/api/auth/passkey/register/options/route.ts new file mode 100644 index 0000000..abc3a47 --- /dev/null +++ b/src/app/api/auth/passkey/register/options/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { WebAuthnUnavailableError, buildRegistrationOptions } from "@/lib/auth/webauthn"; +import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; +import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request"; + +export const dynamic = "force-dynamic"; + +/** Liefert die Optionen fuer navigator.credentials.create(). */ +export async function POST(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const limited = rateLimit( + `webauthn:reg:${clientIp(request)}`, + LIMITS.webauthn.limit, + LIMITS.webauthn.windowMs, + ); + if (!limited.ok) return tooManyRequests(limited.retryAfter); + + try { + const options = await buildRegistrationOptions({ + id: auth.user.id, + username: auth.user.username, + displayName: auth.user.displayName, + }); + return NextResponse.json({ options }); + } catch (error) { + if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501); + return jsonError(error instanceof Error ? error.message : String(error), 500); + } +} diff --git a/src/app/api/auth/passkey/register/verify/route.ts b/src/app/api/auth/passkey/register/verify/route.ts new file mode 100644 index 0000000..cdd02da --- /dev/null +++ b/src/app/api/auth/passkey/register/verify/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import type { RegistrationResponseJSON } from "@simplewebauthn/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { WebAuthnUnavailableError, confirmRegistration } from "@/lib/auth/webauthn"; +import { jsonError, readJsonBody } from "@/lib/security/request"; + +export const dynamic = "force-dynamic"; + +interface Payload { + response?: RegistrationResponseJSON; + name?: string | null; +} + +/** Nimmt die Antwort des Authenticators entgegen und speichert den Passkey. */ +export async function POST(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const attestation = body.data?.response; + if (!attestation || typeof attestation !== "object") { + return jsonError("Die Antwort des Authenticators fehlt.", 400); + } + + try { + const credential = await confirmRegistration( + auth.user.id, + attestation, + typeof body.data?.name === "string" ? body.data.name : null, + ); + return NextResponse.json({ + ok: true, + credential: { + id: credential.id, + name: credential.name, + deviceType: credential.deviceType, + backedUp: credential.backedUp, + createdAt: credential.createdAt, + }, + }); + } catch (error) { + if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501); + return jsonError(error instanceof Error ? error.message : String(error), 400); + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..912d72e --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { hashPassword, validatePasswordStrength } from "@/lib/auth/password"; +import { createSession, setSessionCookie } from "@/lib/auth/session"; +import { getInstanceState } from "@/lib/auth/settings"; +import { prisma } from "@/lib/db"; +import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; +import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; +import { formatZodError, registerSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +/** + * Selbstregistrierung. Nur moeglich, wenn die Instanz im Mehrbenutzerbetrieb + * laeuft und ein Administrator die Registrierung freigeschaltet hat. + */ +export async function POST(request: Request) { + const ip = clientIp(request); + const limited = rateLimit(`register:${ip}`, 5, 60 * 60 * 1000); + if (!limited.ok) return tooManyRequests(limited.retryAfter); + + const state = await getInstanceState(); + if (state.needsSetup) { + return jsonError("Die Instanz ist noch nicht eingerichtet.", 409); + } + if (!state.registrationOpen) { + return jsonError("Die Registrierung ist auf dieser Instanz deaktiviert.", 403); + } + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = registerSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const { username, displayName, email, password } = parsed.data; + + const weak = validatePasswordStrength(password); + if (weak) { + return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 }); + } + + const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } }); + if (taken) { + return NextResponse.json( + { error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } }, + { status: 409 }, + ); + } + + if (email) { + const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } }); + if (emailTaken) { + return NextResponse.json( + { error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } }, + { status: 409 }, + ); + } + } + + const user = await prisma.user.create({ + data: { + username, + displayName: displayName ?? null, + email: email ?? null, + passwordHash: await hashPassword(password), + role: "USER", + }, + }); + + const session = await createSession(user.id, { + userAgent: request.headers.get("user-agent"), + ip, + }); + + const response = NextResponse.json({ + ok: true, + user: { id: user.id, username: user.username, role: user.role }, + }); + setSessionCookie(response, session.token, session.expiresAt); + return response; +} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..edba72c --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { getSessionUser } from "@/lib/auth/session"; +import { getInstanceState } from "@/lib/auth/settings"; +import { WEBAUTHN_AVAILABLE } from "@/lib/config"; + +export const dynamic = "force-dynamic"; + +/** Zustand fuer die Anmeldeseite und zum Pruefen der eigenen Sitzung. */ +export async function GET() { + const [user, state] = await Promise.all([getSessionUser(), getInstanceState()]); + + return NextResponse.json({ + user, + needsSetup: state.needsSetup, + registrationOpen: state.registrationOpen, + multiUser: state.multiUser, + passkeysAvailable: WEBAUTHN_AVAILABLE, + }); +} diff --git a/src/app/api/auth/setup/route.ts b/src/app/api/auth/setup/route.ts new file mode 100644 index 0000000..b3fe52c --- /dev/null +++ b/src/app/api/auth/setup/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { hashPassword, validatePasswordStrength } from "@/lib/auth/password"; +import { createSession, setSessionCookie } from "@/lib/auth/session"; +import { claimOrphanedData } from "@/lib/auth/users"; +import { prisma } from "@/lib/db"; +import { clientIp, jsonError, readJsonBody } from "@/lib/security/request"; +import { formatZodError, setupSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +/** + * Erstmalige Einrichtung: legt das Administratorkonto an. + * + * Funktioniert nur, solange es noch keinen Benutzer gibt – danach ist der + * Endpunkt tot. Damit kann sich niemand nachtraeglich ein Adminkonto + * erschleichen. + */ +export async function POST(request: Request) { + const existing = await prisma.user.count(); + if (existing > 0) { + return jsonError("Die Einrichtung ist bereits abgeschlossen.", 409); + } + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = setupSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const { username, displayName, email, password, mode } = parsed.data; + + const weak = validatePasswordStrength(password); + if (weak) { + return NextResponse.json( + { error: weak, fields: { password: weak } }, + { status: 422 }, + ); + } + + const user = await prisma.user.create({ + data: { + username, + displayName: displayName ?? null, + email: email ?? null, + passwordHash: await hashPassword(password), + role: "ADMIN", + }, + }); + + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", mode }, + update: { mode }, + }); + + const claimed = await claimOrphanedData(user.id); + + const session = await createSession(user.id, { + userAgent: request.headers.get("user-agent"), + ip: clientIp(request), + }); + + const response = NextResponse.json({ + ok: true, + user: { id: user.id, username: user.username, role: user.role }, + claimed, + }); + setSessionCookie(response, session.token, session.expiresAt); + return response; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cfe5be9..d5a6b0d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,16 +1,26 @@ import type { Metadata, Viewport } from "next"; import { TopNav } from "@/components/TopNav"; +import { getSessionUser } from "@/lib/auth/session"; +import { APP_NAME, APP_TAGLINE } from "@/lib/config"; import "./globals.css"; -export const metadata: Metadata = { - title: { - default: "Nebula – Vibe Coding Dashboard", - template: "%s · Nebula", - }, - description: - "Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.", - applicationName: "Nebula", -}; +/** + * generateMetadata statt eines konstanten metadata-Objekts: nur so wird + * APP_NAME zur Laufzeit gelesen. Ein konstanter Export waere beim Build + * eingefroren und liesse sich im fertigen Docker-Image nicht mehr aendern. + */ +export async function generateMetadata(): Promise { + return { + title: { + default: `${APP_NAME} – Vibe Coding Dashboard`, + template: `%s · ${APP_NAME}`, + }, + description: + "Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.", + applicationName: APP_NAME, + robots: { index: false, follow: false }, + }; +} export const viewport: Viewport = { themeColor: "#05060f", @@ -18,9 +28,11 @@ export const viewport: Viewport = { initialScale: 1, }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { + const user = await getSessionUser(); + return ( @@ -31,7 +43,7 @@ export default function RootLayout({ ); } + +function UserMenu({ user }: { user: SessionUser }) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const containerRef = useRef(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 ( +
+ + + {open ? ( +
+
+

{label}

+

+ @{user.username} + {user.role === "ADMIN" ? " · Administrator" : ""} +

+
+ + 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" + > + + Mein Konto + + + {user.role === "ADMIN" ? ( + 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" + > + + Admin-Einstellungen + + ) : null} + + +
+ ) : null} +
+ ); +} diff --git a/src/components/auth/AuthShell.tsx b/src/components/auth/AuthShell.tsx new file mode 100644 index 0000000..20ac4aa --- /dev/null +++ b/src/components/auth/AuthShell.tsx @@ -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 ( +
+
+
+ ); +} diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..adcafb0 --- /dev/null +++ b/src/components/auth/LoginForm.tsx @@ -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(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 ( +
+ {passkeysAvailable ? ( + <> + + +
+ + oder + +
+ + ) : null} + +
+
+ + setUsername(event.target.value)} + autoComplete="username webauthn" + autoCapitalize="none" + spellCheck={false} + required + autoFocus + /> +
+ +
+ + setPassword(event.target.value)} + autoComplete="current-password" + required + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} + + +
+ + {registrationOpen ? ( +

+ Noch kein Konto?{" "} + + Registrieren + +

+ ) : null} + + {!passkeysAvailable ? ( +

+ + + Passkeys sind deaktiviert: Sie brauchen einen sicheren Kontext. Dafuer muss + APP_URL + auf eine https-Adresse oder auf http://localhost zeigen. + +

+ ) : null} +
+ ); +} diff --git a/src/components/auth/RegisterForm.tsx b/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..1d282ae --- /dev/null +++ b/src/components/auth/RegisterForm.tsx @@ -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(null); + const [fieldErrors, setFieldErrors] = useState>({}); + + 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 ( +
+
+ + setUsername(event.target.value)} + autoComplete="username" + autoCapitalize="none" + spellCheck={false} + required + autoFocus + /> + {fieldErrors.username ? ( +

{fieldErrors.username}

+ ) : null} +
+ +
+
+ + setDisplayName(event.target.value)} + /> +
+
+ + setEmail(event.target.value)} + autoComplete="email" + placeholder="optional" + /> + {fieldErrors.email ? ( +

{fieldErrors.email}

+ ) : null} +
+
+ +
+ + setPassword(event.target.value)} + autoComplete="new-password" + required + minLength={PASSWORD_MIN_LENGTH} + /> + {fieldErrors.password ? ( +

{fieldErrors.password}

+ ) : null} +
+ +
+ + setRepeat(event.target.value)} + autoComplete="new-password" + required + /> + {fieldErrors.repeat ? ( +

{fieldErrors.repeat}

+ ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + + +

+ Schon ein Konto?{" "} + + Anmelden + +

+
+ ); +} diff --git a/src/components/auth/SetupForm.tsx b/src/components/auth/SetupForm.tsx new file mode 100644 index 0000000..bae8bf0 --- /dev/null +++ b/src/components/auth/SetupForm.tsx @@ -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("SINGLE_USER"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + + 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 ( +
+
+ Betriebsart +
+ {MODES.map((option) => { + const Icon = option.icon; + const active = mode === option.value; + return ( + + ); + })} +
+

+ Laesst sich spaeter in den Admin-Einstellungen umstellen. +

+
+ +
+ + setUsername(event.target.value)} + autoComplete="username" + autoCapitalize="none" + spellCheck={false} + placeholder="florian" + required + autoFocus + /> + {fieldErrors.username ? ( +

{fieldErrors.username}

+ ) : null} +
+ +
+
+ + setDisplayName(event.target.value)} + placeholder="Florian" + /> +
+
+ + setEmail(event.target.value)} + autoComplete="email" + placeholder="optional" + /> + {fieldErrors.email ? ( +

{fieldErrors.email}

+ ) : null} +
+
+ +
+ + setPassword(event.target.value)} + autoComplete="new-password" + required + minLength={PASSWORD_MIN_LENGTH} + /> +

+ Mindestens {PASSWORD_MIN_LENGTH} Zeichen. Einen Passkey kannst du danach unter + „Mein Konto“ hinzufuegen. +

+ {fieldErrors.password ? ( +

{fieldErrors.password}

+ ) : null} +
+ +
+ + setRepeat(event.target.value)} + autoComplete="new-password" + required + /> + {fieldErrors.repeat ? ( +

{fieldErrors.repeat}

+ ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + +
+ ); +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 1dd9934..4e6e89b 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -48,6 +48,88 @@ export const connectionCreateSchema = z.object({ export const connectionUpdateSchema = connectionCreateSchema.partial(); +// ── Benutzer, Anmeldung, Verwaltung ────────────────────────── + +/** Benutzernamen werden immer kleingeschrieben gespeichert und verglichen. */ +export const usernameSchema = z + .string() + .trim() + .toLowerCase() + .min(3, "Mindestens 3 Zeichen") + .max(32, "Hoechstens 32 Zeichen") + .regex( + /^[a-z0-9][a-z0-9._-]*$/, + "Erlaubt sind Kleinbuchstaben, Ziffern, Punkt, Bindestrich und Unterstrich", + ); + +const passwordField = z.string().min(1, "Passwort fehlt").max(200); +const emailField = z.preprocess( + emptyToNull, + z.string().trim().toLowerCase().email("Keine gueltige E-Mail-Adresse").nullable().optional(), +); +const displayNameField = z.preprocess( + emptyToNull, + z.string().trim().max(80).nullable().optional(), +); + +export const setupSchema = z.object({ + username: usernameSchema, + displayName: displayNameField, + email: emailField, + password: passwordField, + /** Betriebsart, die beim Einrichten gewaehlt wurde. */ + mode: z.enum(["SINGLE_USER", "MULTI_USER"]).default("SINGLE_USER"), +}); + +export const loginSchema = z.object({ + username: z.string().trim().toLowerCase().min(1, "Benutzername fehlt").max(64), + password: passwordField, +}); + +export const registerSchema = z.object({ + username: usernameSchema, + displayName: displayNameField, + email: emailField, + password: passwordField, +}); + +export const changePasswordSchema = z.object({ + /** Entfaellt bei Konten, die bisher nur einen Passkey haben. */ + currentPassword: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()), + newPassword: passwordField, +}); + +export const profileUpdateSchema = z.object({ + displayName: displayNameField, + email: emailField, +}); + +export const adminSettingsSchema = z.object({ + mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(), + allowRegistration: z.boolean().optional(), +}); + +export const adminUserCreateSchema = z.object({ + username: usernameSchema, + displayName: displayNameField, + email: emailField, + password: passwordField, + role: z.enum(["ADMIN", "USER"]).default("USER"), +}); + +export const adminUserUpdateSchema = z.object({ + displayName: displayNameField, + email: emailField, + role: z.enum(["ADMIN", "USER"]).optional(), + isActive: z.boolean().optional(), + /** Neues Passwort setzen; leer lassen heisst unveraendert. */ + password: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()), +}); + +export const passkeyNameSchema = z.object({ + name: z.preprocess(emptyToNull, z.string().trim().max(60).nullable().optional()), +}); + export type ProjectCreateInput = z.infer; export type ProjectUpdateInput = z.infer; export type NoteCreateInput = z.infer;