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:
2026-07-25 21:30:33 +02:00
co-authored by Claude Opus 5
parent 13bc5aa0b8
commit 4f85e00016
19 changed files with 1415 additions and 40 deletions
+86
View File
@@ -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;
}
+11
View File
@@ -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;
}
@@ -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);
}
}
@@ -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<Payload>(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);
}
}
@@ -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);
}
}
@@ -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<Payload>(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);
}
}
+86
View File
@@ -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;
}
+19
View File
@@ -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,
});
}
+75
View File
@@ -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;
}
+20 -10
View File
@@ -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 = {
/**
* 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<Metadata> {
return {
title: {
default: "Nebula – Vibe Coding Dashboard",
template: "%s · Nebula",
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: "Nebula",
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 (
<html lang="de" suppressHydrationWarning>
<body className="min-h-screen antialiased">
@@ -31,7 +43,7 @@ export default function RootLayout({
</div>
<div className="aurora-grid" aria-hidden="true" />
<TopNav />
<TopNav user={user} appName={APP_NAME} tagline={APP_TAGLINE} />
<main className="mx-auto w-full max-w-[1400px] px-4 pb-24 pt-6 sm:px-6 lg:px-8">
{children}
@@ -39,10 +51,8 @@ export default function RootLayout({
<footer className="mx-auto w-full max-w-[1400px] px-4 pb-10 text-xs text-slate-500 sm:px-6 lg:px-8">
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/5 pt-6">
<span>
Nebula · lokal betrieben, Daten bleiben in deiner PostgreSQL.
</span>
<span className="font-mono text-slate-600">v0.1.0</span>
<span>{APP_NAME} · lokal betrieben, Daten bleiben in deiner PostgreSQL.</span>
<span className="font-mono text-slate-600">v0.2.0</span>
</div>
</footer>
</body>
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { AuthShell } from "@/components/auth/AuthShell";
import { LoginForm } from "@/components/auth/LoginForm";
import { getSessionUser } from "@/lib/auth/session";
import { getInstanceState } from "@/lib/auth/settings";
import { APP_NAME, WEBAUTHN_AVAILABLE } from "@/lib/config";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Anmelden" };
}
type PageProps = { searchParams: Promise<{ weiter?: string }> };
export default async function LoginPage({ searchParams }: PageProps) {
const [user, state, params] = await Promise.all([
getSessionUser(),
getInstanceState(),
searchParams,
]);
if (state.needsSetup) redirect("/setup");
if (user) redirect("/");
// Nur relative Pfade zulassen – sonst waere das ein offener Redirect.
const raw = params.weiter ?? "/";
const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
return (
<AuthShell
appName={APP_NAME}
title="Willkommen zurueck"
subtitle="Melde dich an, um deine Projekte zu sehen."
>
<LoginForm
passkeysAvailable={WEBAUTHN_AVAILABLE}
registrationOpen={state.registrationOpen}
next={next}
/>
</AuthShell>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { AuthShell } from "@/components/auth/AuthShell";
import { RegisterForm } from "@/components/auth/RegisterForm";
import { getSessionUser } from "@/lib/auth/session";
import { getInstanceState } from "@/lib/auth/settings";
import { APP_NAME } from "@/lib/config";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Registrieren" };
}
export default async function RegisterPage() {
const [user, state] = await Promise.all([getSessionUser(), getInstanceState()]);
if (state.needsSetup) redirect("/setup");
if (user) redirect("/");
if (!state.registrationOpen) redirect("/login");
return (
<AuthShell
appName={APP_NAME}
title="Konto anlegen"
subtitle="Deine Projekte, Notizen und Git-Verbindungen sind nur fuer dich sichtbar."
>
<RegisterForm />
</AuthShell>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { AuthShell } from "@/components/auth/AuthShell";
import { SetupForm } from "@/components/auth/SetupForm";
import { getInstanceState } from "@/lib/auth/settings";
import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Einrichtung" };
}
export default async function SetupPage() {
const state = await getInstanceState();
// Nach der Einrichtung ist diese Seite verschlossen.
if (!state.needsSetup) redirect("/login");
// Bestandsdaten anzeigen, damit klar ist, was das neue Konto uebernimmt.
const [orphanProjects, orphanConnections] = await Promise.all([
prisma.project.count({ where: { ownerId: null } }),
prisma.gitConnection.count({ where: { ownerId: null } }),
]);
return (
<AuthShell
appName={APP_NAME}
title="Erste Einrichtung"
subtitle="Lege das Administratorkonto an. Danach ist diese Seite gesperrt."
footer={
orphanProjects + orphanConnections > 0 ? (
<>
{orphanProjects} vorhandene Projekte und {orphanConnections} Git-Verbindungen
werden diesem Konto zugeordnet.
</>
) : null
}
>
<SetupForm />
</AuthShell>
);
}
+133 -5
View File
@@ -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,14 +40,16 @@ 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>
{user ? (
<>
<nav className="flex items-center gap-1">
{LINKS.map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
@@ -50,7 +70,115 @@ export function TopNav() {
);
})}
</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>
);
}
+48
View File
@@ -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>
);
}
+181
View File
@@ -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>
);
}
+159
View File
@@ -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>
);
}
+210
View File
@@ -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>
);
}
+82
View File
@@ -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<typeof projectCreateSchema>;
export type ProjectUpdateInput = z.infer<typeof projectUpdateSchema>;
export type NoteCreateInput = z.infer<typeof noteCreateSchema>;