v1.5: Selbst gehostete Auth (Passwort + Passkeys), Postgres statt Supabase

Fundament: db/migrations/0001_init.sql (users/sessions/passkey_credentials/
webauthn_challenges + Inhaltstabellen ohne RLS), db/migrate.mjs
(idempotenter Migrationsrunner), lib/db (pg-Pool + query-Wrapper),
lib/auth/password.ts (Node-crypto.scrypt, keine neue Abhängigkeit),
lib/auth/session.ts (opake DB-Session-Tokens, Sliding-Refresh in proxy.ts).

Passkeys: @simplewebauthn/server + /browser ersetzen Supabase's
experimentelles WebAuthn-API (dafür gab es bisher kein eigenes
Credential-Schema); RP-Konfiguration kommt jetzt direkt aus APP_URL statt
über scripts/configure-passkeys.mjs und die Supabase-Management-API.

scripts/bootstrap-admin.mjs legt bei leerer users-Tabelle automatisch
einen Admin mit zufälligem Passwort an und hängt die Zugangsdaten an
ZUGANGSDATEN.md an (idempotent, via predev/prestart-Hooks aufgerufen).

Bugfix im Zuge des Umbaus: "mind. 1 Passkey nötig, um Passwort-Login zu
deaktivieren" wurde bisher nur clientseitig geprüft – jetzt zusätzlich
serverseitig in PATCH /api/account.

E-Mail-Änderung im eigenen Konto läuft jetzt direkt nach
Passwort-Bestätigung statt über eine Supabase-Bestätigungsmail (kein
SMTP-Server vorhanden).

Rest der Anwendung (Gerichte/Essenszeiten/Kennzeichnung/Vorschläge/
Benutzerverwaltung/Branding) nutzt noch den alten Supabase-Client –
folgt in weiteren Commits.
This commit is contained in:
2026-07-18 10:42:21 +02:00
parent 4c5376a8c7
commit 28c9ec15c8
28 changed files with 1618 additions and 153 deletions
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Ändert die E-Mail-Adresse direkt nach Passwort-Bestätigung – ohne
// Bestätigungsmail, da kein SMTP-Server betrieben wird.
export async function PATCH(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const { email, password } = (await request.json().catch(() => ({}))) as {
email?: string;
password?: string;
};
if (!email || !password) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const user = await queryOne<{ password_hash: string }>(
"select password_hash from users where id = $1",
[session.user.id]
);
if (!user || !verifyPassword(password, user.password_hash)) {
return NextResponse.json({ error: "wrong_password" }, { status: 401 });
}
try {
await query("update users set email = $1 where id = $2", [
email.trim().toLowerCase(),
session.user.id,
]);
} catch (err) {
if (err instanceof Error && /unique/i.test(err.message)) {
return NextResponse.json({ error: "email_used" }, { status: 409 });
}
throw err;
}
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function PATCH(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const { currentPassword, newPassword } = (await request.json().catch(() => ({}))) as {
currentPassword?: string;
newPassword?: string;
};
if (!currentPassword || !newPassword || newPassword.length < 8) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const user = await queryOne<{ password_hash: string }>(
"select password_hash from users where id = $1",
[session.user.id]
);
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
return NextResponse.json({ error: "wrong_password" }, { status: 401 });
}
await query("update users set password_hash = $1 where id = $2", [
hashPassword(newPassword),
session.user.id,
]);
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Nimmt bewusst NUR full_name/password_auth_enabled aus dem Body an –
// ersetzt den früheren Postgres-Column-Grant, der self-service-Updates
// auf "role"/"email" verhindert hat. Ein generisches "update the whole
// body"-Endpoint würde diese Absicherung wieder aufheben.
export async function PATCH(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const body = (await request.json().catch(() => ({}))) as {
full_name?: string;
password_auth_enabled?: boolean;
};
if (body.password_auth_enabled === false) {
const passkeys = await queryOne<{ count: string }>(
"select count(*)::int as count from passkey_credentials where user_id = $1",
[session.user.id]
);
if (!passkeys || Number(passkeys.count) === 0) {
return NextResponse.json({ error: "needs_passkey" }, { status: 400 });
}
}
if (body.full_name !== undefined) {
const name = body.full_name.trim();
if (!name) return NextResponse.json({ error: "name_required" }, { status: 400 });
await query("update users set full_name = $1 where id = $2", [name, session.user.id]);
}
if (body.password_auth_enabled !== undefined) {
await query("update users set password_auth_enabled = $1 where id = $2", [
body.password_auth_enabled,
session.user.id,
]);
}
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { queryOne } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Role } from "@/lib/types";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const { email, password } = (await request.json().catch(() => ({}))) as {
email?: string;
password?: string;
};
if (!email || !password) {
return NextResponse.json({ error: "invalid_credentials" }, { status: 400 });
}
const user = await queryOne<{
id: string;
password_hash: string;
role: Role;
password_auth_enabled: boolean;
}>(
"select id, password_hash, role, password_auth_enabled from users where email = $1",
[email.trim().toLowerCase()]
);
if (!user || !verifyPassword(password, user.password_hash)) {
return NextResponse.json({ error: "invalid_credentials" }, { status: 401 });
}
if (!user.password_auth_enabled) {
return NextResponse.json({ error: "password_disabled" }, { status: 403 });
}
await createSession(user.id);
return NextResponse.json({ role: user.role });
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const { id } = await params;
// user_id-Filter stellt sicher, dass nur eigene Credentials gelöscht
// werden können.
await query("delete from passkey_credentials where id = $1 and user_id = $2", [
id,
session.user.id,
]);
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { rpID } from "@/lib/auth/webauthn-config";
import { storeChallenge } from "@/lib/auth/webauthn-challenges";
// Passwortloser "usernameless"-Login: kein allowCredentials, der Browser
// fragt den Nutzer, welchen registrierten Passkey er verwenden möchte.
// user_id ist beim Speichern der Challenge noch unbekannt.
export async function POST() {
const options = await generateAuthenticationOptions({
rpID: rpID(),
userVerification: "preferred",
});
await storeChallenge(options.challenge, "authentication", null);
return NextResponse.json(options);
}
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
} from "@simplewebauthn/server";
import { query, queryOne } from "@/lib/db";
import { createSession } from "@/lib/auth/session";
import { origin, rpID } from "@/lib/auth/webauthn-config";
import { consumeChallenge } from "@/lib/auth/webauthn-challenges";
import { isSameOrigin } from "@/lib/origin";
import type { Role } from "@/lib/types";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const body = (await request.json()) as { response: AuthenticationResponseJSON };
const challenge = extractChallengeFromClientData(body.response);
const pending = await consumeChallenge(challenge, "authentication");
if (!pending) {
return NextResponse.json({ error: "challenge_expired" }, { status: 400 });
}
// Passkey-Login ist unabhängig von password_auth_enabled – dieses Flag
// betrifft ausschließlich den Passwort-Login.
const stored = await queryOne<{
user_id: string;
public_key: Buffer;
counter: string;
transports: AuthenticatorTransportFuture[];
role: Role;
}>(
`select pc.user_id, pc.public_key, pc.counter, pc.transports, u.role
from passkey_credentials pc join users u on u.id = pc.user_id
where pc.credential_id = $1`,
[body.response.id]
);
if (!stored) {
return NextResponse.json({ error: "unknown_credential" }, { status: 400 });
}
const verification = await verifyAuthenticationResponse({
response: body.response,
expectedChallenge: challenge,
expectedOrigin: origin(),
expectedRPID: rpID(),
credential: {
id: body.response.id,
publicKey: new Uint8Array(stored.public_key),
counter: Number(stored.counter),
transports: stored.transports,
},
});
if (!verification.verified) {
return NextResponse.json({ error: "verification_failed" }, { status: 400 });
}
await query(
"update passkey_credentials set counter = $1, last_used_at = now() where credential_id = $2",
[verification.authenticationInfo.newCounter, body.response.id]
);
await createSession(stored.user_id);
return NextResponse.json({ role: stored.role });
}
function extractChallengeFromClientData(response: AuthenticationResponseJSON): string {
const json = JSON.parse(
Buffer.from(response.response.clientDataJSON, "base64url").toString("utf8")
) as { challenge: string };
return json.challenge;
}
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
import { query } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { rpID, rpName } from "@/lib/auth/webauthn-config";
import { storeChallenge } from "@/lib/auth/webauthn-challenges";
export async function POST() {
try {
const session = await requireSession();
const existing = await query<{
credential_id: string;
transports: AuthenticatorTransportFuture[];
}>("select credential_id, transports from passkey_credentials where user_id = $1", [
session.user.id,
]);
const options = await generateRegistrationOptions({
rpName: rpName(),
rpID: rpID(),
userID: new TextEncoder().encode(session.user.id),
userName: session.user.email,
userDisplayName: session.user.full_name || session.user.email,
attestationType: "none",
excludeCredentials: existing.map((c) => ({
id: c.credential_id,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
await storeChallenge(options.challenge, "registration", session.user.id);
return NextResponse.json(options);
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
import { query } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { origin, rpID } from "@/lib/auth/webauthn-config";
import { consumeChallenge } from "@/lib/auth/webauthn-challenges";
import { isSameOrigin } from "@/lib/origin";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const body = (await request.json()) as {
response: RegistrationResponseJSON;
name?: string;
};
const challenge = extractChallengeFromClientData(body.response);
const pending = await consumeChallenge(challenge, "registration");
if (!pending || pending.userId !== session.user.id) {
return NextResponse.json({ error: "challenge_expired" }, { status: 400 });
}
const verification = await verifyRegistrationResponse({
response: body.response,
expectedChallenge: challenge,
expectedOrigin: origin(),
expectedRPID: rpID(),
});
if (!verification.verified || !verification.registrationInfo) {
return NextResponse.json({ error: "verification_failed" }, { status: 400 });
}
const { credential } = verification.registrationInfo;
await query(
`insert into passkey_credentials (user_id, credential_id, public_key, counter, transports, name)
values ($1, $2, $3, $4, $5, $6)`,
[
session.user.id,
credential.id,
Buffer.from(credential.publicKey),
credential.counter,
credential.transports ?? [],
body.name ?? null,
]
);
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
// clientDataJSON ist base64url-kodiertes JSON mit dem Feld "challenge" –
// darüber wird die zugehörige, serverseitig gespeicherte Challenge
// gefunden, ohne sie zusätzlich im Request-Body mitschicken zu müssen.
function extractChallengeFromClientData(response: RegistrationResponseJSON): string {
const json = JSON.parse(
Buffer.from(response.response.clientDataJSON, "base64url").toString("utf8")
) as { challenge: string };
return json.challenge;
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
export async function GET() {
try {
const session = await requireSession();
const rows = await query<{
id: string;
name: string | null;
created_at: string;
last_used_at: string | null;
}>(
`select id, name, created_at, last_used_at from passkey_credentials
where user_id = $1 order by created_at`,
[session.user.id]
);
return NextResponse.json(rows);
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
+32 -43
View File
@@ -3,67 +3,48 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { startAuthentication } from "@simplewebauthn/browser";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
import type { Dict } from "@/lib/i18n/shared";
function authErrorMessage(dict: Dict, message: string): string {
if (/invalid login credentials/i.test(message))
return dict.login.errorInvalidCredentials;
if (/email not confirmed/i.test(message))
return dict.login.errorEmailNotConfirmed;
if (/cancelled|aborted|NotAllowedError/i.test(message))
function authErrorMessage(dict: Dict, code: string): string {
if (code === "invalid_credentials") return dict.login.errorInvalidCredentials;
if (code === "password_disabled") return dict.login.errorPasswordDisabled;
if (/cancelled|aborted|NotAllowedError/i.test(code))
return dict.login.errorPasskeyCancelled;
return formatMessage(dict.login.errorGeneric, { message });
return formatMessage(dict.login.errorGeneric, { message: code });
}
function redirectPath(role: string): string {
return role === "admin" ? "/admin" : "/intern";
}
export default function LoginPage() {
const router = useRouter();
const supabase = createClient();
const dict = useDict();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function redirectByRole(userId: string) {
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", userId)
.single();
router.replace(profile?.role === "admin" ? "/admin" : "/intern");
router.refresh();
}
async function signInWithPassword(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (error || !data.user) {
setError(authErrorMessage(dict, error?.message ?? "Unbekannter Fehler"));
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false);
return;
}
const { data: profile } = await supabase
.from("profiles")
.select("role, password_auth_enabled")
.eq("id", data.user.id)
.single();
if (profile?.password_auth_enabled === false) {
await supabase.auth.signOut();
setError(dict.login.errorPasswordDisabled);
setBusy(false);
return;
}
router.replace(profile?.role === "admin" ? "/admin" : "/intern");
router.replace(redirectPath(data.role));
router.refresh();
}
@@ -71,15 +52,23 @@ export default function LoginPage() {
setError(null);
setBusy(true);
try {
const { data, error } = await supabase.auth.signInWithPasskey();
if (error || !data?.user) {
setError(
authErrorMessage(dict, error?.message ?? "Passkey-Anmeldung fehlgeschlagen")
);
const optionsRes = await fetch("/api/auth/passkeys/login-options", { method: "POST" });
const options = await optionsRes.json();
const response = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch("/api/auth/passkeys/login-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response }),
});
const data = await verifyRes.json().catch(() => ({}));
if (!verifyRes.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false);
return;
}
await redirectByRole(data.user.id);
router.replace(redirectPath(data.role));
router.refresh();
} catch (err) {
setError(authErrorMessage(dict, err instanceof Error ? err.message : String(err)));
setBusy(false);
+2 -3
View File
@@ -1,13 +1,12 @@
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { destroySession } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const supabase = await createClient();
await supabase.auth.signOut();
await destroySession();
// Relative Weiterleitung: bei einer absoluten URL aus request.url landet
// sonst die Server-Bindung (0.0.0.0) statt des aufgerufenen Hosts im
// Location-Header.
+72 -45
View File
@@ -1,20 +1,20 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { formatMessage } from "@/lib/i18n/shared";
import type { Dict } from "@/lib/i18n/shared";
import PasskeyManager from "@/components/PasskeyManager";
import { useDict } from "@/components/i18n/LocaleProvider";
function authErrorMessage(dict: Dict, message: string): string {
if (/invalid login credentials/i.test(message))
return dict.account.wrongCurrentPassword;
if (/should be at least/i.test(message)) return dict.account.passwordTooShort;
if (/email.*already|already.*registered/i.test(message))
return dict.account.emailAlreadyUsed;
return message;
function errorMessage(
dict: ReturnType<typeof useDict>,
code: string,
fallback: string
): string {
if (code === "wrong_password") return dict.account.wrongCurrentPassword;
if (code === "email_used") return dict.account.emailAlreadyUsed;
if (code === "needs_passkey") return dict.account.passwordAuthNeedsPasskey;
return fallback;
}
export default function AccountManager({
@@ -28,7 +28,6 @@ export default function AccountManager({
initialEmail: string;
initialPasswordAuthEnabled: boolean;
}) {
const supabase = useMemo(() => createClient(), []);
const router = useRouter();
const dict = useDict();
const roleLabel = role === "admin" ? dict.common.roleAdmin : dict.common.roleStaff;
@@ -41,6 +40,7 @@ export default function AccountManager({
// E-Mail-Adresse
const [email, setEmail] = useState(initialEmail);
const [emailPassword, setEmailPassword] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
const [emailInfo, setEmailInfo] = useState<string | null>(null);
const [emailBusy, setEmailBusy] = useState(false);
@@ -63,10 +63,10 @@ export default function AccountManager({
const [authBusy, setAuthBusy] = useState(false);
useEffect(() => {
supabase.auth.passkey
.list()
.then(({ data }) => setPasskeyCount((data ?? []).length));
}, [supabase]);
fetch("/api/auth/passkeys")
.then((res) => (res.ok ? res.json() : []))
.then((data) => setPasskeyCount(data.length));
}, []);
async function saveName(e: React.FormEvent) {
e.preventDefault();
@@ -78,13 +78,19 @@ export default function AccountManager({
return;
}
setNameBusy(true);
const { error } = await supabase
.from("profiles")
.update({ full_name: name })
.eq("id", (await supabase.auth.getUser()).data.user?.id ?? "");
if (error)
setNameError(formatMessage(dict.account.saveError, { message: error.message }));
else {
const res = await fetch("/api/account", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_name: name }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setNameError(
formatMessage(dict.account.saveError, {
message: errorMessage(dict, data.error, data.error ?? String(res.status)),
})
);
} else {
setNameInfo(dict.account.nameSaved);
router.refresh();
}
@@ -98,15 +104,22 @@ export default function AccountManager({
const next = email.trim();
if (!next || next === initialEmail) return;
setEmailBusy(true);
const { error } = await supabase.auth.updateUser({ email: next });
if (error) {
const res = await fetch("/api/account/email", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: next, password: emailPassword }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setEmailError(
formatMessage(dict.account.changeError, {
message: authErrorMessage(dict, error.message),
message: errorMessage(dict, data.error, data.error ?? String(res.status)),
})
);
} else {
setEmailInfo(dict.account.emailSent);
setEmailInfo(dict.account.emailChanged);
setEmailPassword("");
router.refresh();
}
setEmailBusy(false);
}
@@ -125,22 +138,16 @@ export default function AccountManager({
}
setPwBusy(true);
// Zur Verifizierung: aktuelles Passwort gegenprüfen, bevor geändert wird.
const { error: verifyError } = await supabase.auth.signInWithPassword({
email: initialEmail,
password: currentPassword,
const res = await fetch("/api/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
if (verifyError) {
setPwError(authErrorMessage(dict, verifyError.message));
setPwBusy(false);
return;
}
const { error } = await supabase.auth.updateUser({ password: newPassword });
if (error) {
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setPwError(
formatMessage(dict.account.changeError, {
message: authErrorMessage(dict, error.message),
message: errorMessage(dict, data.error, data.error ?? String(res.status)),
})
);
} else {
@@ -163,12 +170,18 @@ export default function AccountManager({
if (!next && !window.confirm(dict.account.confirmDisablePasswordAuth)) return;
setAuthBusy(true);
const { error } = await supabase
.from("profiles")
.update({ password_auth_enabled: next })
.eq("id", (await supabase.auth.getUser()).data.user?.id ?? "");
if (error) {
setAuthError(formatMessage(dict.account.saveError, { message: error.message }));
const res = await fetch("/api/account", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password_auth_enabled: next }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setAuthError(
formatMessage(dict.account.saveError, {
message: errorMessage(dict, data.error, data.error ?? String(res.status)),
})
);
} else {
setPasswordAuthEnabled(next);
setAuthInfo(
@@ -232,6 +245,20 @@ export default function AccountManager({
required
/>
</div>
<div className="max-w-80">
<label className="label" htmlFor="account-email-password">
{dict.account.currentPassword}
</label>
<input
id="account-email-password"
type="password"
className="input"
value={emailPassword}
onChange={(e) => setEmailPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
{emailError && (
<p className="text-sm text-[var(--c-del)]" role="alert">
{emailError}
+29 -21
View File
@@ -1,19 +1,18 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { useCallback, useEffect, useState } from "react";
import { startRegistration } from "@simplewebauthn/browser";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
interface PasskeyItem {
id: string;
friendly_name?: string;
name: string | null;
created_at: string;
last_used_at?: string;
last_used_at: string | null;
}
export default function PasskeyManager() {
const supabase = useMemo(() => createClient(), []);
const dict = useDict();
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
const [error, setError] = useState<string | null>(null);
@@ -21,11 +20,13 @@ export default function PasskeyManager() {
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
const { data, error } = await supabase.auth.passkey.list();
if (error)
setError(formatMessage(dict.passkeys.loadError, { message: error.message }));
else setPasskeys((data ?? []) as PasskeyItem[]);
}, [supabase, dict]);
const res = await fetch("/api/auth/passkeys");
if (!res.ok) {
setError(formatMessage(dict.passkeys.loadError, { message: String(res.status) }));
return;
}
setPasskeys(await res.json());
}, [dict]);
useEffect(() => {
load();
@@ -36,13 +37,22 @@ export default function PasskeyManager() {
setInfo(null);
setBusy(true);
try {
const { error } = await supabase.auth.registerPasskey();
if (error) {
setError(formatMessage(dict.passkeys.registerError, { message: error.message }));
} else {
const optionsRes = await fetch("/api/auth/passkeys/register-options", { method: "POST" });
if (!optionsRes.ok) throw new Error(String(optionsRes.status));
const options = await optionsRes.json();
const response = await startRegistration({ optionsJSON: options });
const verifyRes = await fetch("/api/auth/passkeys/register-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response }),
});
if (!verifyRes.ok) {
const data = await verifyRes.json().catch(() => ({}));
throw new Error(data.error ?? String(verifyRes.status));
}
setInfo(dict.passkeys.registered);
await load();
}
} catch (err) {
setError(
/NotAllowedError|abort/i.test(String(err))
@@ -57,9 +67,9 @@ export default function PasskeyManager() {
async function remove(pk: PasskeyItem) {
if (!window.confirm(dict.passkeys.confirmDelete)) return;
const { error } = await supabase.auth.passkey.delete({ passkeyId: pk.id });
if (error)
setError(formatMessage(dict.passkeys.deleteError, { message: error.message }));
const res = await fetch(`/api/auth/passkeys/${pk.id}`, { method: "DELETE" });
if (!res.ok)
setError(formatMessage(dict.passkeys.deleteError, { message: String(res.status) }));
await load();
}
@@ -88,9 +98,7 @@ export default function PasskeyManager() {
{passkeys.map((pk) => (
<li key={pk.id} className="flex items-center gap-3 py-1.5 text-sm">
<span className="mr-auto">
<span className="font-semibold">
{pk.friendly_name || "Passkey"}
</span>{" "}
<span className="font-semibold">{pk.name || "Passkey"}</span>{" "}
<span className="text-gray-500">
{formatMessage(dict.passkeys.createdOn, {
date: new Date(pk.created_at).toLocaleDateString(dict.dateLocale),
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
// Wendet alle noch nicht angewendeten Dateien aus db/migrations/*.sql auf
// die per DATABASE_URL erreichbare Postgres-Datenbank an. Idempotent –
// kann bei jedem App-Start gefahrlos erneut laufen (Docker-Entrypoint,
// npm predev/prestart).
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), "migrations");
async function main() {
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
try {
await client.query(`
create table if not exists schema_migrations (
filename text primary key,
applied_at timestamptz not null default now()
)
`);
const { rows } = await client.query("select filename from schema_migrations");
const applied = new Set(rows.map((r) => r.filename));
const files = readdirSync(migrationsDir)
.filter((f) => f.endsWith(".sql"))
.sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = readFileSync(join(migrationsDir, file), "utf8");
console.log(`db:migrate – wende ${file} an…`);
await client.query("begin");
try {
await client.query(sql);
await client.query("insert into schema_migrations (filename) values ($1)", [file]);
await client.query("commit");
} catch (err) {
await client.query("rollback");
throw new Error(`Migration ${file} fehlgeschlagen: ${err.message}`, { cause: err });
}
}
console.log("db:migrate – Datenbank ist aktuell.");
} finally {
await client.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+156
View File
@@ -0,0 +1,156 @@
-- =============================================================
-- Canteen Menu – Initiales Schema (selbst gehostet, ohne Supabase)
-- Rollen: admin (Benutzerverwaltung, Branding) / staff (Kantine)
-- Öffentliche Speisekarte: lesbar ohne Login
-- =============================================================
-- ---------- Benutzer & Authentifizierung ----------
create table users (
id uuid primary key default gen_random_uuid(),
email text not null unique,
password_hash text not null,
full_name text not null default '',
role text not null default 'staff' check (role in ('admin', 'staff')),
password_auth_enabled boolean not null default true,
created_at timestamptz not null default now()
);
-- Angemeldete Sitzungen. Es wird nur der Hash des Tokens gespeichert –
-- der Rohwert im Cookie ist damit auch bei einem DB-Zugriff allein nicht
-- verwendbar.
create table sessions (
token_hash text primary key,
user_id uuid not null references users (id) on delete cascade,
created_at timestamptz not null default now(),
expires_at timestamptz not null,
last_seen_at timestamptz not null default now()
);
create index sessions_user_id_idx on sessions (user_id);
-- WebAuthn-Credentials (ersetzt Supabase's internes, für die App bisher
-- unsichtbares Passkey-Schema).
create table passkey_credentials (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users (id) on delete cascade,
credential_id text not null unique,
public_key bytea not null,
counter bigint not null default 0,
transports text[] not null default '{}',
name text,
created_at timestamptz not null default now(),
last_used_at timestamptz
);
create index passkey_credentials_user_id_idx on passkey_credentials (user_id);
-- Kurzlebige Challenges für laufende WebAuthn-Zeremonien (Registrierung
-- oder Login). user_id ist beim Login-Flow anfangs null (Discoverable-
-- Credential-Ansatz: die Identität steht erst nach der Antwort fest).
create table webauthn_challenges (
id uuid primary key default gen_random_uuid(),
user_id uuid references users (id) on delete cascade,
challenge text not null,
type text not null check (type in ('registration', 'authentication')),
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
-- ---------- Essenszeiten der Speisekarte ----------
create table sections (
key text primary key default (gen_random_uuid())::text,
label text not null,
label_en text,
label_pl text,
time_range text not null,
sort int not null
);
-- ---------- Allergene & Zusatzstoffe ----------
create table allergens (
code text primary key,
name text not null,
name_en text,
name_pl text,
sort int not null default 0
);
create table additives (
code text primary key,
name text not null,
name_en text,
name_pl text,
sort int not null default 0
);
-- ---------- Gerichte ----------
create table dishes (
id uuid primary key default gen_random_uuid(),
name text not null,
name_en text,
name_pl text,
price numeric(6, 2) not null default 0,
allergens text[] not null default '{}',
additives text[] not null default '{}',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create or replace function touch_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at := now();
return new;
end;
$$;
create trigger dishes_touch_updated_at
before update on dishes
for each row execute function touch_updated_at();
-- ---------- Zuweisung Gericht -> Tag/Essenszeit ----------
create table menu_entries (
id uuid primary key default gen_random_uuid(),
menu_date date not null,
section_key text not null references sections (key) on delete cascade,
dish_id uuid not null references dishes (id) on delete cascade,
position int not null default 0,
created_at timestamptz not null default now(),
unique (menu_date, section_key, dish_id)
);
create index menu_entries_date_idx on menu_entries (menu_date);
-- ---------- Einstellungen (Farben, Logo, Favicon) – genau eine Zeile ----------
create table settings (
id int primary key check (id = 1),
colors jsonb not null default '{}'::jsonb,
logo_path text,
favicon_path text,
updated_at timestamptz not null default now()
);
insert into settings (id, colors) values (
1,
'{
"primary": "#003388",
"primaryDark": "#0d315c",
"accent": "#ff6900",
"headerText": "#ffffff",
"rowBg": "#e3e4f1",
"rowBgAlt": "#f4f4fa",
"addBtn": "#6da54c",
"deleteBtn": "#e2371d"
}'::jsonb
);
create trigger settings_touch_updated_at
before update on settings
for each row execute function touch_updated_at();
-- ---------- Rezeptvorschläge ----------
create table dish_suggestions (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
created_at timestamptz not null default now()
);
+35
View File
@@ -0,0 +1,35 @@
import "server-only";
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
// scrypt-Parameter fest verankert statt "der aktuelle Node-Default" zu
// nutzen, damit ein künftiges Node-Upgrade die Hash-Stärke nicht
// stillschweigend ändert. Speicherbedarf = 128 * N * r = 16 MiB.
const N = 16384;
const r = 8;
const p = 1;
const KEY_LEN = 64;
const MAX_MEM = 32 * 1024 * 1024;
// Selbstbeschreibendes Format "scrypt$N$r$p$salt$hash" (hex), damit die
// Parameter später erhöht werden können, ohne bestehende Hashes zu
// invalidieren.
export function hashPassword(plain: string): string {
const salt = randomBytes(16);
const hash = scryptSync(plain, salt, KEY_LEN, { N, r, p, maxmem: MAX_MEM });
return `scrypt$${N}$${r}$${p}$${salt.toString("hex")}$${hash.toString("hex")}`;
}
export function verifyPassword(plain: string, stored: string): boolean {
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
const [, nStr, rStr, pStr, saltHex, hashHex] = parts;
const salt = Buffer.from(saltHex, "hex");
const expected = Buffer.from(hashHex, "hex");
const actual = scryptSync(plain, salt, expected.length, {
N: Number(nStr),
r: Number(rStr),
p: Number(pStr),
maxmem: MAX_MEM,
});
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
+110
View File
@@ -0,0 +1,110 @@
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { query, queryOne } from "@/lib/db";
import type { Role } from "@/lib/types";
export const SESSION_COOKIE = "session";
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage
const REFRESH_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // unter 7 Tage Restlaufzeit verlängern
export interface SessionUser {
id: string;
email: string;
full_name: string;
role: Role;
password_auth_enabled: boolean;
}
export interface Session {
user: SessionUser;
expiresAt: Date;
}
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
function isSecureCookie(): boolean {
return (process.env.APP_URL ?? "").startsWith("https://");
}
/** Nur lesend – sicher in Server Components, Route Handlern und Server Actions. */
export async function getSession(): Promise<Session | null> {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
const row = await queryOne<{
expires_at: Date;
id: string;
email: string;
full_name: string;
role: Role;
password_auth_enabled: boolean;
}>(
`select s.expires_at, u.id, u.email, u.full_name, u.role, u.password_auth_enabled
from sessions s join users u on u.id = s.user_id
where s.token_hash = $1 and s.expires_at > now()`,
[hashToken(token)]
);
if (!row) return null;
return {
expiresAt: row.expires_at,
user: {
id: row.id,
email: row.email,
full_name: row.full_name,
role: row.role,
password_auth_enabled: row.password_auth_enabled,
},
};
}
export class UnauthorizedError extends Error {}
export class ForbiddenError extends Error {}
export async function requireSession(): Promise<Session> {
const session = await getSession();
if (!session) throw new UnauthorizedError();
return session;
}
export async function requireRole(roles: Role[]): Promise<Session> {
const session = await requireSession();
if (!roles.includes(session.user.role)) throw new ForbiddenError();
return session;
}
/** Legt eine neue Sitzung an und setzt das Session-Cookie. Nur in Route
* Handlern/Server Actions aufrufbar (dort ist cookies().set() erlaubt). */
export async function createSession(userId: string): Promise<void> {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS);
await query(
"insert into sessions (token_hash, user_id, expires_at) values ($1, $2, $3)",
[hashToken(token), userId, expiresAt]
);
(await cookies()).set(SESSION_COOKIE, token, {
httpOnly: true,
secure: isSecureCookie(),
sameSite: "lax",
path: "/",
expires: expiresAt,
});
}
/** Löscht die Sitzung serverseitig (nicht nur das Cookie), damit ein
* abgefangener Token nicht mehr verwendbar ist. */
export async function destroySession(): Promise<void> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (token) {
await query("delete from sessions where token_hash = $1", [hashToken(token)]);
}
store.delete(SESSION_COOKIE);
}
export { REFRESH_THRESHOLD_MS, hashToken, isSecureCookie };
+34
View File
@@ -0,0 +1,34 @@
import "server-only";
import { query, queryOne } from "@/lib/db";
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
export type ChallengeType = "registration" | "authentication";
export async function storeChallenge(
challenge: string,
type: ChallengeType,
userId: string | null
): Promise<void> {
// Kein Cron nötig: abgelaufene Zeilen bei jeder neuen Challenge
// gleich mit aufräumen.
await query("delete from webauthn_challenges where expires_at < now()");
await query(
"insert into webauthn_challenges (user_id, challenge, type, expires_at) values ($1, $2, $3, $4)",
[userId, challenge, type, new Date(Date.now() + CHALLENGE_TTL_MS)]
);
}
/** Einmalig verwendbar: die Zeile wird beim Abruf sofort gelöscht. */
export async function consumeChallenge(
challenge: string,
type: ChallengeType
): Promise<{ userId: string | null } | null> {
const row = await queryOne<{ user_id: string | null }>(
`delete from webauthn_challenges
where challenge = $1 and type = $2 and expires_at > now()
returning user_id`,
[challenge, type]
);
return row ? { userId: row.user_id } : null;
}
+21
View File
@@ -0,0 +1,21 @@
import "server-only";
// RP-Konfiguration wird direkt aus APP_URL/SITE_TITLE abgeleitet – kein
// externer Konfigurationsschritt mehr nötig (ersetzt das bisherige
// scripts/configure-passkeys.mjs, das die Supabase-Management-API
// aufrief).
function appUrl(): URL {
return new URL(process.env.APP_URL ?? "http://localhost:3000");
}
export function rpID(): string {
return appUrl().hostname;
}
export function origin(): string {
return appUrl().origin;
}
export function rpName(): string {
return process.env.SITE_TITLE?.trim() || "Canteen Menu";
}
+36
View File
@@ -0,0 +1,36 @@
import "server-only";
import type { PoolClient, QueryResultRow } from "pg";
import { pool } from "./pool";
export async function query<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<T[]> {
const result = await pool.query<T>(text, params);
return result.rows;
}
export async function queryOne<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<T | null> {
const rows = await query<T>(text, params);
return rows[0] ?? null;
}
export async function withTransaction<T>(
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query("begin");
const result = await fn(client);
await client.query("commit");
return result;
} catch (err) {
await client.query("rollback");
throw err;
} finally {
client.release();
}
}
+17
View File
@@ -0,0 +1,17 @@
import "server-only";
import { Pool } from "pg";
// Next.js' Dev-Server lädt Module bei Hot-Reloads neu; ohne den
// globalThis-Cache würde bei jedem Speichern ein neuer Pool (und damit
// neue offene Verbindungen) entstehen.
declare global {
var __pgPool: Pool | undefined;
}
export const pool =
globalThis.__pgPool ??
new Pool({ connectionString: process.env.DATABASE_URL });
if (process.env.NODE_ENV !== "production") {
globalThis.__pgPool = pool;
}
+3 -9
View File
@@ -47,7 +47,6 @@ const de = {
passkeyButton: "Mit Passkey anmelden",
backToMenu: "← Zur Speisekarte",
errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.",
errorEmailNotConfirmed: "Die E-Mail-Adresse wurde noch nicht bestätigt.",
errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.",
errorGeneric: "Anmeldung fehlgeschlagen: {message}",
errorPasswordDisabled:
@@ -205,8 +204,7 @@ const de = {
nameRequired: "Bitte einen Namen angeben.",
emailTitle: "E-Mail-Adresse",
changeEmail: "E-Mail-Adresse ändern",
emailSent:
"Bestätigungs-E-Mails wurden verschickt. Die neue Adresse wird erst nach Bestätigung des Links aktiv.",
emailChanged: "E-Mail-Adresse geändert.",
passwordTitle: "Passwort ändern",
currentPassword: "Aktuelles Passwort (zur Verifizierung)",
newPassword: "Neues Passwort",
@@ -303,7 +301,6 @@ const en: Dict = {
passkeyButton: "Sign in with passkey",
backToMenu: "← Back to menu",
errorInvalidCredentials: "Email address or password is incorrect.",
errorEmailNotConfirmed: "This email address has not been confirmed yet.",
errorPasskeyCancelled: "Passkey sign-in was cancelled.",
errorGeneric: "Sign-in failed: {message}",
errorPasswordDisabled:
@@ -461,8 +458,7 @@ const en: Dict = {
nameRequired: "Please enter a name.",
emailTitle: "Email address",
changeEmail: "Change email address",
emailSent:
"Confirmation emails have been sent. The new address becomes active only after confirming the link.",
emailChanged: "Email address changed.",
passwordTitle: "Change password",
currentPassword: "Current password (for verification)",
newPassword: "New password",
@@ -557,7 +553,6 @@ const pl: Dict = {
passkeyButton: "Zaloguj się kluczem dostępu",
backToMenu: "← Powrót do jadłospisu",
errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.",
errorEmailNotConfirmed: "Adres e-mail nie został jeszcze potwierdzony.",
errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.",
errorGeneric: "Logowanie nie powiodło się: {message}",
errorPasswordDisabled:
@@ -715,8 +710,7 @@ const pl: Dict = {
nameRequired: "Podaj nazwę.",
emailTitle: "Adres e-mail",
changeEmail: "Zmień adres e-mail",
emailSent:
"Wysłano e-maile potwierdzające. Nowy adres zacznie obowiązywać dopiero po potwierdzeniu linku.",
emailChanged: "Adres e-mail zmieniony.",
passwordTitle: "Zmień hasło",
currentPassword: "Obecne hasło (do weryfikacji)",
newPassword: "Nowe hasło",
+2 -2
View File
@@ -1,8 +1,8 @@
// CSRF-Schutz (Defense-in-Depth) für schreibende Route-Handler:
// Browser senden bei Cross-Site-POSTs immer einen Origin-Header –
// stimmt er nicht mit dem Host überein, wird die Anfrage abgelehnt.
// (Erste Verteidigungslinie sind bereits die SameSite=Lax-Session-
// Cookies von @supabase/ssr sowie der JSON-Content-Type.)
// (Erste Verteidigungslinie ist bereits das SameSite=Lax-Session-Cookie,
// siehe lib/auth/session.ts, sowie der JSON-Content-Type.)
export function isSameOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
// Kein Origin-Header: kein Browser-Cross-Site-Kontext (z. B. curl);
+423
View File
@@ -8,9 +8,12 @@
"name": "canteen-menu",
"version": "1.4.0",
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@supabase/ssr": "^0.12.3",
"@supabase/supabase-js": "^2.110.7",
"next": "16.2.10",
"pg": "^8.22.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"server-only": "^0.0.1"
@@ -18,6 +21,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
@@ -456,6 +460,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1086,6 +1096,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1309,6 +1325,174 @@
"node": ">=12.4.0"
}
},
"node_modules/@peculiar/asn1-android": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
"integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-rsa": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pfx": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1316,6 +1500,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@simplewebauthn/server": {
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
"integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-rsa": "^2.6.1",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"@peculiar/x509": "^1.14.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.110.7",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.7.tgz",
@@ -1746,6 +1955,18 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -2648,6 +2869,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -5661,6 +5896,95 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5719,6 +6043,45 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5751,6 +6114,24 @@
"node": ">=6"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5800,6 +6181,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6215,6 +6602,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6549,6 +6945,24 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -6898,6 +7312,15 @@
"node": ">=0.10.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+8 -1
View File
@@ -3,15 +3,21 @@
"version": "1.4.0",
"private": true,
"scripts": {
"predev": "node db/migrate.mjs && node scripts/bootstrap-admin.mjs",
"dev": "next dev -H 0.0.0.0",
"build": "next build",
"prestart": "node db/migrate.mjs && node scripts/bootstrap-admin.mjs",
"start": "next start -H 0.0.0.0",
"lint": "eslint"
"lint": "eslint",
"db:migrate": "node db/migrate.mjs"
},
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@supabase/ssr": "^0.12.3",
"@supabase/supabase-js": "^2.110.7",
"next": "16.2.10",
"pg": "^8.22.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"server-only": "^0.0.1"
@@ -19,6 +25,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
+58 -28
View File
@@ -1,36 +1,36 @@
import { createServerClient } from "@supabase/ssr";
import { createHash } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";
import { Pool } from "pg";
// Session-Refresh + Zugriffsschutz für die internen Bereiche.
// Next 16 führt Proxy standardmäßig im Node.js-Runtime aus, direkter
// pg-Zugriff ist hier also möglich (kein Edge-Workaround nötig).
//
// Der Cookie-Name ist hier bewusst dupliziert statt aus lib/auth/session
// importiert: dieses Modul zieht next/headers (cookies()) nach, das
// außerhalb von Server Components/Route Handlern/Server Actions nicht
// nutzbar ist – Proxy läuft in einem eigenen Ausführungskontext.
const SESSION_COOKIE = "session";
declare global {
var __pgPoolProxy: Pool | undefined;
}
const pool =
globalThis.__pgPoolProxy ??
new Pool({ connectionString: process.env.DATABASE_URL });
if (process.env.NODE_ENV !== "production") globalThis.__pgPoolProxy = pool;
const REFRESH_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const token = request.cookies.get(SESSION_COOKIE)?.value;
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
function redirectToLogin() {
// Absolute URL aus dem Host-Header des Browsers bauen: request.nextUrl
// trägt bei "-H 0.0.0.0" die Server-Bindung statt des aufgerufenen
// Hosts, und relative Location-Header lehnt die Middleware ab.
@@ -41,6 +41,36 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(`${proto}://${host}/login`, 303);
}
if (!token) return redirectToLogin();
const tokenHash = hashToken(token);
const { rows } = await pool.query<{ expires_at: Date }>(
"select expires_at from sessions where token_hash = $1 and expires_at > now()",
[tokenHash]
);
const session = rows[0];
if (!session) return redirectToLogin();
const response = NextResponse.next();
// Sliding-Refresh: bei weniger als 7 Tagen Restlaufzeit Sitzung und
// Cookie verlängern, statt bei jedem Request zu schreiben.
const msRemaining = session.expires_at.getTime() - Date.now();
if (msRemaining < REFRESH_THRESHOLD_MS) {
const newExpiry = new Date(Date.now() + SESSION_LIFETIME_MS);
await pool.query("update sessions set expires_at = $1, last_seen_at = now() where token_hash = $2", [
newExpiry,
tokenHash,
]);
response.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: (process.env.APP_URL ?? "").startsWith("https://"),
sameSite: "lax",
path: "/",
expires: newExpiry,
});
}
return response;
}
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
// Legt beim allerersten Start (leere users-Tabelle) genau einen
// Admin-Benutzer mit zufällig generiertem Passwort an und hängt die
// Zugangsdaten an ZUGANGSDATEN.md an. Idempotent: bei bereits
// vorhandenem Benutzer passiert nichts.
//
// Eigenständiges Skript statt Import aus lib/auth/password.ts, damit
// dieses Vor-Build-Skript nicht an Next's Build-Output gekoppelt ist.
// Die Hash-Logik ist bewusst identisch zur kanonischen Implementierung
// in lib/auth/password.ts gehalten – Änderungen dort auch hier nachziehen.
import { appendFileSync, existsSync } from "node:fs";
import { randomBytes, scryptSync } from "node:crypto";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import pg from "pg";
const N = 16384;
const r = 8;
const p = 1;
const KEY_LEN = 64;
const MAX_MEM = 32 * 1024 * 1024;
function hashPassword(plain) {
const salt = randomBytes(16);
const hash = scryptSync(plain, salt, KEY_LEN, { N, r, p, maxmem: MAX_MEM });
return `scrypt$${N}$${r}$${p}$${salt.toString("hex")}$${hash.toString("hex")}`;
}
function defaultAdminEmail() {
if (process.env.ADMIN_EMAIL) return process.env.ADMIN_EMAIL;
try {
const host = new URL(process.env.APP_URL ?? "http://localhost").hostname;
return `admin@${host}`;
} catch {
return "admin@localhost";
}
}
async function main() {
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
try {
const { rows } = await client.query("select count(*)::int as count from users");
if (rows[0].count > 0) {
console.log("bootstrap-admin – Admin bereits vorhanden, überspringe.");
return;
}
const email = defaultAdminEmail();
const password = randomBytes(18).toString("base64url");
const passwordHash = hashPassword(password);
await client.query(
`insert into users (email, password_hash, full_name, role, password_auth_enabled)
values ($1, $2, '', 'admin', true)`,
[email, passwordHash]
);
const zugangsdatenPath = join(
dirname(dirname(fileURLToPath(import.meta.url))),
"ZUGANGSDATEN.md"
);
const entry = `\n## Admin-Zugang (automatisch angelegt, ${new Date().toISOString().slice(0, 10)})\n\n- E-Mail: \`${email}\`\n- Passwort: \`${password}\`\n\nBitte nach dem ersten Login ändern.\n`;
if (existsSync(zugangsdatenPath)) {
appendFileSync(zugangsdatenPath, entry);
} else {
console.warn(
`bootstrap-admin – ${zugangsdatenPath} existiert nicht, Zugangsdaten werden nur geloggt:`
);
}
console.log(`bootstrap-admin – Admin angelegt: ${email} (Passwort siehe ZUGANGSDATEN.md)`);
} finally {
await client.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});