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
+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);