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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user