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;
|
||||
}
|
||||
+25
-15
@@ -1,16 +1,26 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { TopNav } from "@/components/TopNav";
|
||||
import { getSessionUser } from "@/lib/auth/session";
|
||||
import { APP_NAME, APP_TAGLINE } from "@/lib/config";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Nebula – Vibe Coding Dashboard",
|
||||
template: "%s · Nebula",
|
||||
},
|
||||
description:
|
||||
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
|
||||
applicationName: "Nebula",
|
||||
};
|
||||
/**
|
||||
* generateMetadata statt eines konstanten metadata-Objekts: nur so wird
|
||||
* APP_NAME zur Laufzeit gelesen. Ein konstanter Export waere beim Build
|
||||
* eingefroren und liesse sich im fertigen Docker-Image nicht mehr aendern.
|
||||
*/
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: {
|
||||
default: `${APP_NAME} – Vibe Coding Dashboard`,
|
||||
template: `%s · ${APP_NAME}`,
|
||||
},
|
||||
description:
|
||||
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
|
||||
applicationName: APP_NAME,
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#05060f",
|
||||
@@ -18,9 +28,11 @@ export const viewport: Viewport = {
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const user = await getSessionUser();
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user