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:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+21
-11
@@ -1,16 +1,26 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { TopNav } from "@/components/TopNav";
|
import { TopNav } from "@/components/TopNav";
|
||||||
|
import { getSessionUser } from "@/lib/auth/session";
|
||||||
|
import { APP_NAME, APP_TAGLINE } from "@/lib/config";
|
||||||
import "./globals.css";
|
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: {
|
title: {
|
||||||
default: "Nebula – Vibe Coding Dashboard",
|
default: `${APP_NAME} – Vibe Coding Dashboard`,
|
||||||
template: "%s · Nebula",
|
template: `%s · ${APP_NAME}`,
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
|
"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 = {
|
export const viewport: Viewport = {
|
||||||
themeColor: "#05060f",
|
themeColor: "#05060f",
|
||||||
@@ -18,9 +28,11 @@ export const viewport: Viewport = {
|
|||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="de" suppressHydrationWarning>
|
<html lang="de" suppressHydrationWarning>
|
||||||
<body className="min-h-screen antialiased">
|
<body className="min-h-screen antialiased">
|
||||||
@@ -31,7 +43,7 @@ export default function RootLayout({
|
|||||||
</div>
|
</div>
|
||||||
<div className="aurora-grid" aria-hidden="true" />
|
<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">
|
<main className="mx-auto w-full max-w-[1400px] px-4 pb-24 pt-6 sm:px-6 lg:px-8">
|
||||||
{children}
|
{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">
|
<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">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/5 pt-6">
|
||||||
<span>
|
<span>{APP_NAME} · lokal betrieben, Daten bleiben in deiner PostgreSQL.</span>
|
||||||
Nebula · lokal betrieben, Daten bleiben in deiner PostgreSQL.
|
<span className="font-mono text-slate-600">v0.2.0</span>
|
||||||
</span>
|
|
||||||
<span className="font-mono text-slate-600">v0.1.0</span>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -1,8 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { GitBranch, LayoutDashboard, Sparkles } from "lucide-react";
|
import {
|
||||||
|
GitBranch,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
UserRound,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { SessionUser } from "@/lib/auth/session";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const LINKS = [
|
const LINKS = [
|
||||||
@@ -10,7 +20,15 @@ const LINKS = [
|
|||||||
{ href: "/connections", label: "Git-Verbindungen", icon: GitBranch },
|
{ 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();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,14 +40,16 @@ export function TopNav() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex flex-col leading-none">
|
<span className="flex flex-col leading-none">
|
||||||
<span className="text-gradient text-lg font-semibold tracking-tight">
|
<span className="text-gradient text-lg font-semibold tracking-tight">
|
||||||
Nebula
|
{appName}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 text-[10px] uppercase tracking-[0.2em] text-slate-500">
|
<span className="mt-0.5 text-[10px] uppercase tracking-[0.2em] text-slate-500">
|
||||||
Vibe Coding
|
{tagline}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
{LINKS.map(({ href, label, icon: Icon }) => {
|
{LINKS.map(({ href, label, icon: Icon }) => {
|
||||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
@@ -50,7 +70,115 @@ export function TopNav() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div className="ml-auto">
|
||||||
|
<UserMenu user={user} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,6 +48,88 @@ export const connectionCreateSchema = z.object({
|
|||||||
|
|
||||||
export const connectionUpdateSchema = connectionCreateSchema.partial();
|
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 ProjectCreateInput = z.infer<typeof projectCreateSchema>;
|
||||||
export type ProjectUpdateInput = z.infer<typeof projectUpdateSchema>;
|
export type ProjectUpdateInput = z.infer<typeof projectUpdateSchema>;
|
||||||
export type NoteCreateInput = z.infer<typeof noteCreateSchema>;
|
export type NoteCreateInput = z.infer<typeof noteCreateSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user