feat: Mehrbenutzer mit Rollen, E-Mail-Einladungen und generischem OIDC-Login

This commit is contained in:
opencode committed 2026-10-18 16:00:41 +02:00
1 parent fc752e0041
commit 1744120503
41 files changed
+1245 -235

No files matched your search

+3
View File
@@ -1,6 +1,7 @@
DATABASE_URL=file:/data/gutscheine.db
APP_USER=haushalt
APP_PASSWORD_HASH=$2b$10$REPLACE_ME
ADMIN_EMAIL=admin@beispiel.de
SESSION_SECRET=REPLACE_WITH_32_CHARS_MIN
SETTINGS_ENCRYPTION_KEY=REPLACE_WITH_OWN_32_CHARS_MIN
SMTP_HOST=
@@ -13,4 +14,6 @@ LOGO_DEV_SECRET_KEY=
LOGO_DEV_PUBLISHABLE_KEY=
CRON_SECRET=REPLACE_ME
APP_URL=http://localhost:3000
ALLOW_PASSWORD_LOGIN=true
TZ=Europe/Berlin
# Optional: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET (stattdessen UI-Einstellungen nutzen)
+9 -3
View File
@@ -1,18 +1,24 @@
# Gutscheintracker
Private Gutscheinverwaltung (Next.js + SQLite + Prisma). Ein Shared-Login für den Haushalt, Erinnerungen per Mail/Telegram.
Private Gutscheinverwaltung für einen Haushalt (Next.js + SQLite + Prisma). Gemeinsamer Bestand, Anmeldung per Passwort oder SSO (OIDC), Erinnerungen per Mail/Telegram.
Rollen: Der erste Nutzer ist Admin (wird beim Start aus `APP_USER`/`APP_PASSWORD_HASH` angelegt). Admins verwalten Nutzer, Einladungen und Einstellungen; alle Mitglieder verwalten gemeinsam Gutscheine.
## Start (Docker Compose)
```bash
cp .env.example .env # Werte ausfüllen (v. a. APP_PASSWORD_HASH, SESSION_SECRET, CRON_SECRET, APP_URL, LOGO_DEV_* für Logos)
cp .env.example .env # Werte ausfüllen (v. a. APP_PASSWORD_HASH bzw. ADMIN_EMAIL, SESSION_SECRET, SETTINGS_ENCRYPTION_KEY, CRON_SECRET, APP_URL)
docker compose up --build -d
```
App: http://localhost:3000 — Login mit `APP_USER` und dem Passwort zu `APP_PASSWORD_HASH`.
App: http://localhost:3000 — Anmeldung mit Benutzername oder E-Mail plus Passwort, oder per SSO (falls in den Einstellungen konfiguriert).
Der Erinnerungs-Scheduler startet automatisch im Production-Prozess (`instrumentation.ts` → `lib/scheduler.ts`, alle 15 Minuten, `APP_URL` als Basis).
## Mitglieder einladen
Admins laden unter Einstellungen → Nutzer per E-Mail ein (Link 7 Tage gültig). Eingeladene richten ein Passwort ein oder melden sich per SSO an, sofern die OIDC-E-Mail zur Einladung passt. Mit `ALLOW_PASSWORD_LOGIN=false` brauchen Mitglieder SSO; der Admin behält zusätzlich den Passwort-Notfallzugang.
## Update
```bash
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { Suspense, useEffect, useState, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";
function AcceptForm() {
const router = useRouter();
const params = useSearchParams();
const token = params.get("token") ?? "";
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!token) {
setError("Einladungslink ungültig.");
setLoading(false);
return;
}
fetch(`/api/users/accept?token=${encodeURIComponent(token)}`)
.then(async (r) => {
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error("ungueltig");
setEmail(data.email ?? "");
})
.catch(() => setError("Diese Einladung ist ungültig oder abgelaufen."))
.finally(() => setLoading(false));
}, [token]);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setSaving(true);
setError("");
try {
const res = await fetch("/api/users/accept", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, name, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errs: string[] = data.errors ?? [];
if (errs.includes("password")) setError("Das Passwort braucht mindestens 8 Zeichen.");
else if (errs.includes("name")) setError("Bitte einen Namen eingeben.");
else setError(data.message ?? "Einladung konnte nicht eingelöst werden.");
return;
}
router.push("/login");
} catch {
setError("Einladung konnte nicht eingelöst werden.");
} finally {
setSaving(false);
}
}
return (
<main className="grid min-h-dvh place-items-center bg-wash px-5 py-9">
<div className="w-full max-w-[420px]">
<p className="mb-6 flex items-center gap-2.5 text-base font-semibold tracking-tight">
<span
aria-hidden="true"
className="grid h-9 w-9 place-items-center rounded-[11px] bg-brand-100 text-[17px] font-bold text-brand-800"
>
G
</span>
Gutscheintracker
</p>
<section
aria-labelledby="accept-title"
className="rounded-card border border-line bg-white p-8 shadow-[0_16px_38px_rgba(32,55,39,0.07)]"
>
<h1 id="accept-title" className="text-[27px] font-semibold leading-snug tracking-tight">
Einladung annehmen
</h1>
{loading ? (
<p className="mt-2 text-sm text-muted">Wird geprüft …</p>
) : error && !email ? (
<p role="alert" className="mt-4 rounded-field bg-danger/10 px-3.5 py-2.5 text-sm font-medium text-danger">
{error}
</p>
) : (
<>
<p className="mb-7 mt-2 text-sm leading-relaxed text-muted">
Konto für <span className="font-semibold text-ink">{email}</span> einrichten.
</p>
<form onSubmit={onSubmit} className="grid gap-[18px]" noValidate>
<div className="grid gap-[7px]">
<label htmlFor="name" className="text-[13px] font-semibold">
Dein Name
</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
placeholder="z. B. Julia"
className="h-[46px] rounded-field border border-line bg-white px-3.5 text-sm text-ink"
/>
</div>
<div className="grid gap-[7px]">
<label htmlFor="password" className="text-[13px] font-semibold">
Passwort (min. 8 Zeichen)
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
placeholder="Sicheres Passwort wählen"
className="h-[46px] rounded-field border border-line bg-white px-3.5 text-sm text-ink"
/>
</div>
{error && (
<p role="alert" className="rounded-field bg-danger/10 px-3.5 py-2.5 text-sm font-medium text-danger">
{error}
</p>
)}
<button
type="submit"
disabled={saving}
className="h-[47px] rounded-field bg-brand-600 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99] disabled:opacity-60"
>
{saving ? "Wird eingerichtet …" : "Konto erstellen"}
</button>
</form>
<p className="mt-[19px] text-center text-xs text-muted">
Alternativ nachträglich per SSO anmelden, sobald die E-Mail-Adresse verknüpft ist.
</p>
</>
)}
</section>
</div>
</main>
);
}
export default function AcceptInvitePage() {
return (
<Suspense>
<AcceptForm />
</Suspense>
);
}
+24 -15
View File
@@ -1,5 +1,8 @@
import { NextResponse } from "next/server";
import { verifyPassword, createSession, SESSION_COOKIE } from "../../../../lib/auth";
import { getDb } from "@/lib/db";
import { verifyPassword, createSession } from "../../../../lib/auth";
import { setSessionCookie } from "../../../../lib/set-session-cookie";
import { canLoginWithPassword, isPasswordLoginAllowed } from "../../../../lib/authz";
const WINDOW_MS = 60_000;
const MAX_ATTEMPTS = 10;
@@ -27,24 +30,30 @@ export async function POST(req: Request) {
} catch {
return NextResponse.json({ error: "Zugangsdaten ungültig" }, { status: 401 });
}
const appUser = process.env.APP_USER ?? "";
const appHash = process.env.APP_PASSWORD_HASH ?? "";
const identifier = typeof body.user === "string" ? body.user.trim() : "";
const password = typeof body.password === "string" ? body.password : "";
if (!identifier || !password) {
return NextResponse.json({ error: "Zugangsdaten ungültig" }, { status: 401 });
}
const db = getDb();
const user = await db.user.findFirst({
where: { OR: [{ name: identifier }, { email: identifier.toLowerCase() }] },
});
const ok =
typeof body.user === "string" &&
typeof body.password === "string" &&
body.user === appUser &&
(await verifyPassword(body.password, appHash));
user &&
typeof user.passwordHash === "string" &&
canLoginWithPassword(user.role, isPasswordLoginAllowed()) &&
(await verifyPassword(password, user.passwordHash));
if (!ok) {
return NextResponse.json({ error: "Zugangsdaten ungültig" }, { status: 401 });
}
const token = await createSession(appUser);
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
const token = await createSession({
uid: user.id,
name: user.name,
role: user.role,
tokenVersion: user.tokenVersion,
});
const res = NextResponse.json({ ok: true });
setSessionCookie(res, token);
return res;
}
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/lib/db";
import { createSession } from "@/lib/session";
import { setSessionCookie } from "@/lib/set-session-cookie";
import {
discover,
verifyCallbackCode,
STATE_COOKIE,
NONCE_COOKIE,
VERIFIER_COOKIE,
} from "@/lib/oidc";
import { getOidcSettings } from "@/lib/integration-settings";
function clearCookie(res: NextResponse, name: string) {
res.cookies.set(name, "", { path: "/", maxAge: 0 });
}
/** OIDC-Rücksprung: prüft State/Code, verknüpft oder erstellt das Konto, meldet an. Öffentlich. */
export async function GET(req: NextRequest) {
const appUrl = (process.env.APP_URL ?? "").replace(/\/$/, "") || req.nextUrl.origin;
const params = req.nextUrl.searchParams;
const code = params.get("code");
const state = params.get("state");
const expectedState = req.cookies.get(STATE_COOKIE)?.value;
const nonce = req.cookies.get(NONCE_COOKIE)?.value;
const verifier = req.cookies.get(VERIFIER_COOKIE)?.value;
const deny = (reason: string) => {
const res = NextResponse.redirect(new URL(`/login?sso=${reason}`, appUrl));
clearCookie(res, STATE_COOKIE);
clearCookie(res, NONCE_COOKIE);
clearCookie(res, VERIFIER_COOKIE);
return res;
};
if (!code || !state || !expectedState || state !== expectedState || !nonce || !verifier) {
return deny("ungueltig");
}
const { issuer, clientId, clientSecret } = await getOidcSettings();
if (!issuer || !clientId || !clientSecret) return deny("nicht-konfiguriert");
let verified;
try {
const doc = await discover(issuer);
verified = await verifyCallbackCode(
doc,
clientId,
clientSecret,
`${appUrl}/api/auth/oidc/callback`,
code,
verifier,
nonce
);
} catch {
return deny("pruefung-fehlgeschlagen");
}
const db = getDb();
let user = await db.user.findUnique({ where: { oidcSubject: verified.subject } });
if (!user) {
const byMail = await db.user.findUnique({ where: { email: verified.email } });
if (byMail) {
user = await db.user.update({ where: { id: byMail.id }, data: { oidcSubject: verified.subject } });
} else {
const invite = await db.invite.findUnique({ where: { email: verified.email } });
if (!invite || invite.acceptedAt !== null || invite.expiresAt.getTime() <= Date.now()) {
return deny("keine-einladung");
}
user = await db.user.create({
data: { name: verified.name, email: verified.email, role: invite.role, oidcSubject: verified.subject },
});
await db.invite.update({ where: { id: invite.id }, data: { acceptedAt: new Date() } });
}
}
const token = await createSession({ uid: user.id, name: user.name, role: user.role, tokenVersion: user.tokenVersion });
const res = NextResponse.redirect(new URL("/", appUrl));
setSessionCookie(res, token);
clearCookie(res, STATE_COOKIE);
clearCookie(res, NONCE_COOKIE);
clearCookie(res, VERIFIER_COOKIE);
return res;
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { buildLoginUrl, discover, randomToken, STATE_COOKIE, NONCE_COOKIE, VERIFIER_COOKIE } from "@/lib/oidc";
import { getOidcSettings } from "@/lib/integration-settings";
function cookie(res: NextResponse, name: string, value: string) {
res.cookies.set(name, value, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 10 * 60,
});
}
/** Startet den OIDC-Login (Authorization Code + PKCE). Öffentlich. */
export async function GET(req: NextRequest) {
const { issuer, clientId } = await getOidcSettings();
if (!issuer || !clientId) {
return NextResponse.json({ error: "OIDC nicht konfiguriert" }, { status: 503 });
}
let doc;
try {
doc = await discover(issuer);
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : "Discovery fehlgeschlagen" }, { status: 502 });
}
const appUrl = (process.env.APP_URL ?? "").replace(/\/$/, "");
if (!appUrl) return NextResponse.json({ error: "APP_URL fehlt" }, { status: 500 });
const state = randomToken();
const nonce = randomToken();
const verifier = randomToken(48);
const url = buildLoginUrl(doc, clientId, `${appUrl}/api/auth/oidc/callback`, { state, nonce, verifier });
const res = NextResponse.redirect(url);
cookie(res, STATE_COOKIE, state);
cookie(res, NONCE_COOKIE, nonce);
cookie(res, VERIFIER_COOKIE, verifier);
return res;
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { isOidcConfigured } from "@/lib/oidc";
import { isPasswordLoginAllowed } from "@/lib/authz";
/** Öffentlich: zeigt an, ob SSO angeboten wird und ob Passwort-Login erlaubt ist. */
export async function GET() {
return NextResponse.json({
oidcEnabled: await isOidcConfigured(),
passwordAllowed: isPasswordLoginAllowed(),
});
}
+2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/lib/db";
import { validateContact } from "@/lib/contacts";
import { requireUser } from "@/lib/authz";
export async function GET() {
const db = getDb();
@@ -9,6 +10,7 @@ export async function GET() {
}
export async function POST(req: NextRequest) {
if (!(await requireUser())) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const body = await req.json().catch(() => ({}));
const input = {
name: String(body.name ?? ""),
-30
View File
@@ -1,30 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/lib/db";
export async function GET() {
const db = getDb();
const members = await db.householdMember.findMany({ orderBy: { name: "asc" } });
return NextResponse.json(members);
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const name = String(body.name ?? "").trim();
if (!name) return NextResponse.json({ errors: ["name"] }, { status: 400 });
if (name.length > 100) return NextResponse.json({ errors: ["name"] }, { status: 400 });
const db = getDb();
const existing = await db.householdMember.findUnique({ where: { name } });
if (existing) return NextResponse.json({ errors: ["name"] }, { status: 409 });
const created = await db.householdMember.create({ data: { name } });
return NextResponse.json(created, { status: 201 });
}
export async function DELETE(req: NextRequest) {
const id = Number(req.nextUrl.searchParams.get("id"));
if (!Number.isInteger(id)) return NextResponse.json({ error: "not found" }, { status: 404 });
const db = getDb();
const existing = await db.householdMember.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 });
await db.householdMember.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+2
View File
@@ -4,6 +4,7 @@ import { unlink } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { getDb } from "@/lib/db";
import { requireAdmin } from "@/lib/authz";
/**
* Lädt einen konsistenten SQLite-Snapshot herunter (VACUUM INTO läuft
@@ -13,6 +14,7 @@ import { getDb } from "@/lib/db";
* SETTINGS_ENCRYPTION_KEY erforderlich).
*/
export async function GET() {
if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 });
const db = getDb();
const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "");
const tmpPath = join(tmpdir(), `gutscheintracker-backup-${stamp}-${randomBytes(4).toString("hex")}.db`);
+5 -3
View File
@@ -1,17 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import {
getIntegrationStatus,
isSecretKey,
saveIntegrationSection,
validateIntegrationSection,
type IntegrationSection,
} from "@/lib/integration-settings";
import { canEncrypt } from "@/lib/settings-crypto";
import { requireAdmin } from "@/lib/authz";
export async function GET() {
if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 });
return NextResponse.json(await getIntegrationStatus());
}
export async function POST(req: NextRequest) {
if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 });
const body = await req.json().catch(() => ({}));
const section = String(body.section ?? "");
const values = (body.values ?? {}) as Record<string, unknown>;
@@ -21,9 +25,7 @@ export async function POST(req: NextRequest) {
for (const [k, v] of Object.entries(values)) {
if (typeof v === "string") stringValues[k] = v;
}
const needsEncryption = Object.entries(stringValues).some(
([k, v]) => v !== "" && (k === "smtp_pass" || k === "telegram_bot_token" || k === "logo_dev_secret_key")
);
const needsEncryption = Object.entries(stringValues).some(([k, v]) => v !== "" && isSecretKey(k));
if (needsEncryption && !canEncrypt()) {
return NextResponse.json(
{ errors: ["verschluesselung"], message: "SETTINGS_ENCRYPTION_KEY fehlt (min. 32 Zeichen in .env)" },
+2
View File
@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { sendMail } from "@/lib/mail";
import { requireAdmin } from "@/lib/authz";
export async function POST(req: NextRequest) {
if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 });
const body = await req.json().catch(() => ({}));
const to = String(body.to ?? process.env.MAIL_FROM ?? "");
if (!to) return NextResponse.json({ error: "Empfänger fehlt" }, { status: 400 });
+2
View File
@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { sendTelegram } from "@/lib/telegram";
import { requireAdmin } from "@/lib/authz";
export async function POST(req: NextRequest) {
if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 });
const body = await req.json().catch(() => ({}));
const chatId = String(body.chatId ?? "");
if (!chatId) return NextResponse.json({ error: "Chat-ID fehlt" }, { status: 400 });
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/lib/db";
import { requireAdmin } from "@/lib/authz";
/** Nutzer löschen (nicht sich selbst, nicht den letzten Admin). Nur Admin. */
export async function DELETE(req: NextRequest) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: "forbidden" }, { status: 403 });
const id = Number(req.nextUrl.searchParams.get("id"));
if (!Number.isInteger(id)) return NextResponse.json({ error: "not found" }, { status: 404 });
if (id === admin.id) {
return NextResponse.json({ errors: ["id"], message: "Das eigene Konto kann nicht gelöscht werden." }, { status: 400 });
}
const db = getDb();
const target = await db.user.findUnique({ where: { id } });
if (!target) return NextResponse.json({ error: "not found" }, { status: 404 });
if (target.role === "admin") {
const admins = await db.user.count({ where: { role: "admin" } });
if (admins <= 1) {
return NextResponse.json({ errors: ["id"], message: "Der letzte Admin kann nicht gelöscht werden." }, { status: 400 });
}
}
await db.user.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { getDb } from "@/lib/db";
import { hashInviteToken, isInviteValid } from "@/lib/invites";
/** Prüft einen Einladungslink. Öffentlich (für die Anmeldeseite). */
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get("token") ?? "";
const db = getDb();
const invites = await db.invite.findMany({ where: { acceptedAt: null } });
const invite = invites.find((i) => isInviteValid(i, token));
if (!invite) return NextResponse.json({ error: "Einladung ungültig oder abgelaufen" }, { status: 404 });
return NextResponse.json({ email: invite.email });
}
/** Löst eine Einladung mit eigenem Namen und Passwort ein. Öffentlich. */
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const token = typeof body.token === "string" ? body.token : "";
const name = typeof body.name === "string" ? body.name.trim() : "";
const password = typeof body.password === "string" ? body.password : "";
const errs: string[] = [];
if (!name) errs.push("name");
if (name.length > 100) errs.push("name");
if (password.length < 8) errs.push("password");
if (!token) errs.push("token");
if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 });
const db = getDb();
const invites = await db.invite.findMany({ where: { acceptedAt: null } });
const invite = invites.find((i) => isInviteValid(i, token));
if (!invite) return NextResponse.json({ errors: ["token"] }, { status: 404 });
if (await db.user.findUnique({ where: { email: invite.email } })) {
return NextResponse.json({ errors: ["email"], message: "Für diese E-Mail existiert bereits ein Konto." }, { status: 409 });
}
const passwordHash = await bcrypt.hash(password, 10);
const created = await db.user.create({
data: { name, email: invite.email, passwordHash, role: invite.role },
});
await db.invite.update({ where: { id: invite.id }, data: { acceptedAt: new Date(), tokenHash: `${hashInviteToken(token)}:verbraucht` } });
return NextResponse.json({ ok: true, id: created.id });
}
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/lib/db";
import { requireAdmin } from "@/lib/authz";
import { createInviteToken, hashInviteToken, normalizeEmail, INVITE_VALID_DAYS } from "@/lib/invites";
import { sendMail } from "@/lib/mail";
function inviteUrl(req: NextRequest, rawToken: string): string {
const appUrl = (process.env.APP_URL ?? "").replace(/\/$/, "") || req.nextUrl.origin;
return `${appUrl}/accept-invite?token=${rawToken}`;
}
/** Offene Einladungen. Nur Admin. */
export async function GET() {
if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 });
const db = getDb();
const invites = await db.invite.findMany({
where: { acceptedAt: null },
orderBy: { createdAt: "desc" },
select: { id: true, email: true, role: true, expiresAt: true, createdAt: true },
});
return NextResponse.json(invites);
}
/** Neue Einladung per E-Mail. Nur Admin. Gibt den Link einmalig zurück (zum Kopieren). */
export async function POST(req: NextRequest) {
if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 });
const body = await req.json().catch(() => ({}));
const email = normalizeEmail(typeof body.email === "string" ? body.email : "");
if (!email) return NextResponse.json({ errors: ["email"] }, { status: 400 });
const db = getDb();
if (await db.user.findUnique({ where: { email } })) {
return NextResponse.json({ errors: ["email"], message: "Für diese E-Mail existiert bereits ein Konto." }, { status: 409 });
}
const rawToken = createInviteToken();
const expiresAt = new Date(Date.now() + INVITE_VALID_DAYS * 24 * 3600 * 1000);
await db.invite.upsert({
where: { email },
create: { email, tokenHash: hashInviteToken(rawToken), role: "member", expiresAt },
update: { tokenHash: hashInviteToken(rawToken), expiresAt, acceptedAt: null },
});
const url = inviteUrl(req, rawToken);
let mailError: string | null = null;
try {
await sendMail(email, "Einladung zum Gutschein-Tracker", `Du wurdest eingeladen. Lege hier dein Konto an (Link 7 Tage gültig): ${url}`);
} catch (e) {
mailError = e instanceof Error ? e.message : String(e);
}
return NextResponse.json({ ok: true, url, mailError }, { status: 201 });
}
/** Einladung zurückziehen. Nur Admin. */
export async function DELETE(req: NextRequest) {
if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 });
const id = Number(req.nextUrl.searchParams.get("id"));
if (!Number.isInteger(id)) return NextResponse.json({ error: "not found" }, { status: 404 });
const db = getDb();
const existing = await db.invite.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 });
await db.invite.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/lib/db";
import { getSessionUser } from "@/lib/authz";
import { normalizeEmail } from "@/lib/invites";
/** Eigene E-Mail-Adresse setzen/ändern (für OIDC-Verknüpfung nötig). */
export async function PUT(req: NextRequest) {
const me = await getSessionUser();
if (!me) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const body = await req.json().catch(() => ({}));
const email = normalizeEmail(typeof body.email === "string" ? body.email : "");
if (!email) return NextResponse.json({ errors: ["email"] }, { status: 400 });
const db = getDb();
const taken = await db.user.findUnique({ where: { email } });
if (taken && taken.id !== me.id) {
return NextResponse.json({ errors: ["email"], message: "Diese E-Mail nutzt bereits ein anderes Konto." }, { status: 409 });
}
const updated = await db.user.update({ where: { id: me.id }, data: { email } });
return NextResponse.json({ ok: true, email: updated.email });
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getDb } from "@/lib/db";
import { requireAdmin } from "@/lib/authz";
/** Alle Nutzer (ohne Geheimnisse). Nur Admin. */
export async function GET() {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: "forbidden" }, { status: 403 });
const db = getDb();
const users = await db.user.findMany({
orderBy: { createdAt: "asc" },
select: { id: true, name: true, email: true, role: true, oidcSubject: true, createdAt: true },
});
return NextResponse.json(
users.map((u) => ({ ...u, oidcVerknuepft: u.oidcSubject !== null, oidcSubject: undefined }))
);
}
+4
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/lib/db";
import { withLogoUrl } from "@/lib/logo";
import { validateVoucher, parseShopUrl, parseVoucherFields } from "@/lib/vouchers";
import { requireUser } from "@/lib/authz";
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -29,6 +30,7 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await requireUser())) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const { id } = await params;
const numId = Number(id);
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
@@ -42,6 +44,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await requireUser())) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const { id } = await params;
const numId = Number(id);
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
@@ -88,6 +91,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await requireUser())) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const { id } = await params;
const numId = Number(id);
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
+4 -8
View File
@@ -3,6 +3,7 @@ import { getDb } from "@/lib/db";
import { getVoucherStatus } from "@/lib/voucher-status";
import { validateVoucher, isExpiringSoon, parseVoucherFields } from "@/lib/vouchers";
import { withLogoUrl } from "@/lib/logo";
import { requireUser } from "@/lib/authz";
export async function GET(req: NextRequest) {
const filter = req.nextUrl.searchParams.get("filter");
@@ -20,6 +21,8 @@ export async function GET(req: NextRequest) {
}
export async function POST(req: NextRequest) {
const me = await requireUser();
if (!me) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const body = await req.json();
const { fields, errors: parseErrors } = parseVoucherFields(body);
const errs = [...parseErrors, ...validateVoucher(fields)];
@@ -31,14 +34,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ errors: ["contactIds"] }, { status: 400 });
}
}
let createdBy: string | null = null;
if (body.memberId !== undefined && body.memberId !== null && String(body.memberId).trim() !== "") {
const memberId = Number(body.memberId);
if (!Number.isInteger(memberId)) return NextResponse.json({ errors: ["memberId"] }, { status: 400 });
const member = await db.householdMember.findUnique({ where: { id: memberId } });
if (!member) return NextResponse.json({ errors: ["memberId"] }, { status: 400 });
createdBy = member.name;
}
const createdBy: string | null = me.name;
const created = await db.voucher.create({
data: {
name: fields.name.trim(),
+1 -38
View File
@@ -37,7 +37,6 @@ export type VoucherFormInitial = {
providerName: string;
contactIds: number[];
reminderDays: number[];
memberId: string;
};
export type VoucherFormPayload = {
@@ -56,7 +55,6 @@ export type VoucherFormPayload = {
providerDomain: string | null;
contactIds: number[];
reminderDays: number[];
memberId: number | null;
};
export const EMPTY_FORM: VoucherFormInitial = {
@@ -76,12 +74,10 @@ export const EMPTY_FORM: VoucherFormInitial = {
providerName: "",
contactIds: [],
reminderDays: [...DEFAULT_REMINDER_DAYS],
memberId: "",
};
export default function VoucherForm({
initial,
showMemberSelect,
showShopLink = true,
creatorName,
submitLabel,
@@ -89,7 +85,6 @@ export default function VoucherForm({
onSuccess,
}: {
initial: VoucherFormInitial;
showMemberSelect: boolean;
showShopLink?: boolean;
creatorName?: string | null;
submitLabel: string;
@@ -113,8 +108,6 @@ export default function VoucherForm({
const [serverError, setServerError] = useState("");
const [contacts, setContacts] = useState<{ id: number; name: string }[]>([]);
const [contactIds, setContactIds] = useState<number[]>(() => [...initial.contactIds]);
const [members, setMembers] = useState<{ id: number; name: string }[]>([]);
const [memberId, setMemberId] = useState(initial.memberId);
const [wertTyp, setWertTyp] = useState<"euro" | "prozent">(initial.wertTyp);
const [reminderDays, setReminderDays] = useState<number[]>(() => [...initial.reminderDays]);
const [candidates, setCandidates] = useState<LogoCandidate[]>([]);
@@ -132,10 +125,6 @@ export default function VoucherForm({
.then((r) => (r.ok ? r.json() : []))
.then(setContacts)
.catch(() => {});
fetch("/api/members")
.then((r) => (r.ok ? r.json() : []))
.then(setMembers)
.catch(() => {});
}, []);
function set(name: string, value: string) {
@@ -195,7 +184,6 @@ export default function VoucherForm({
providerDomain: selectedLogo?.domain ?? null,
contactIds,
reminderDays,
memberId: memberId === "" ? null : Number(memberId),
};
const errs = validateVoucher(payload);
setErrors(errs);
@@ -211,32 +199,7 @@ export default function VoucherForm({
return (
<form onSubmit={handleSubmit} className="mt-5 rounded-card border border-line bg-white p-5 sm:p-6" noValidate>
{showMemberSelect ? (
<div className="mb-4">
<label htmlFor="memberId" className="block text-[13px] font-semibold">
Angelegt von
</label>
{members.length === 0 ? (
<p className="mt-1.5 text-[13px] text-muted">
Noch keine Haushaltsmitglieder. Lege sie unter <Link href="/settings" className="font-semibold text-brand-700 underline">Einstellungen</Link> an.
</p>
) : (
<select
id="memberId"
value={memberId}
onChange={(e) => setMemberId(e.target.value)}
className={`${inputClass} appearance-none`}
>
<option value="">Nicht erfasst</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
)}
</div>
) : (
{creatorName !== undefined && (
<p className="mb-4 text-[13px] text-muted">
Angelegt von {creatorName ? <span className="font-semibold text-ink">{creatorName}</span> : "nicht erfasst"} (bleibt unverändert).
</p>
+42 -2
View File
@@ -1,5 +1,5 @@
"use client";
import { useState, type FormEvent } from "react";
import { useEffect, useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
@@ -8,6 +8,27 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [oidcEnabled, setOidcEnabled] = useState(false);
const [passwordAllowed, setPasswordAllowed] = useState(true);
const [ssoError, setSsoError] = useState("");
useEffect(() => {
try {
const params = new URLSearchParams(window.location.search);
if (params.get("sso") === "keine-einladung") setSsoError("Für diese E-Mail-Adresse liegt keine Einladung vor.");
else if (params.get("sso")) setSsoError("SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.");
} catch {
/* ignore */
}
fetch("/api/auth/oidc/status")
.then((r) => (r.ok ? r.json() : null))
.then((s) => {
if (!s) return;
setOidcEnabled(!!s.oidcEnabled);
setPasswordAllowed(s.passwordAllowed !== false);
})
.catch(() => {});
}, []);
async function onSubmit(e: FormEvent) {
e.preventDefault();
@@ -52,8 +73,22 @@ export default function LoginPage() {
Willkommen zurück
</h1>
<p className="mb-7 mt-2 text-sm leading-relaxed text-muted">
Melde dich mit eurem gemeinsamen Haushaltszugang an.
Melde dich mit deinem Haushaltszugang an.
</p>
{oidcEnabled && (
<a
href="/api/auth/oidc/login"
className="mb-5 flex h-[47px] items-center justify-center rounded-field border border-line bg-white text-sm font-semibold transition-colors hover:border-brand-600 hover:text-brand-700"
>
Mit SSO anmelden
</a>
)}
{ssoError && (
<p role="alert" className="mb-5 rounded-field bg-danger/10 px-3.5 py-2.5 text-sm font-medium text-danger">
{ssoError}
</p>
)}
{passwordAllowed ? (
<form onSubmit={onSubmit} className="grid gap-[18px]" noValidate>
<div className="grid gap-[7px]">
<label htmlFor="user" className="text-[13px] font-semibold">
@@ -95,6 +130,11 @@ export default function LoginPage() {
{loading ? "Wird geprüft …" : "Anmelden"}
</button>
</form>
) : (
<p className="rounded-field bg-wash px-3.5 py-2.5 text-sm text-muted">
Passwort-Login ist deaktiviert. Bitte melde dich per SSO an.
</p>
)}
<p className="mt-[19px] text-center text-xs text-muted">
Privater Zugang für euren Haushalt
</p>
+60 -1
View File
@@ -6,6 +6,7 @@ type Status = {
smtp: { host: string; port: number; user: string; from: string; passGesetzt: boolean };
telegram: { botTokenGesetzt: boolean };
logo: { secretGesetzt: boolean; publishableKey: string };
oidc: { issuer: string; clientId: string; secretGesetzt: boolean };
verschluesselungBereit: boolean;
};
@@ -54,6 +55,9 @@ export default function IntegrationsForm() {
const [telegramToken, setTelegramToken] = useState("");
const [logoSecret, setLogoSecret] = useState("");
const [logoPublic, setLogoPublic] = useState("");
const [oidcIssuer, setOidcIssuer] = useState("");
const [oidcClientId, setOidcClientId] = useState("");
const [oidcSecret, setOidcSecret] = useState("");
useEffect(() => {
fetch("/api/settings/integrations")
@@ -62,11 +66,13 @@ export default function IntegrationsForm() {
setStatus(s);
setSmtp({ host: s.smtp.host, port: String(s.smtp.port), user: s.smtp.user, pass: "", from: s.smtp.from });
setLogoPublic(s.logo.publishableKey);
setOidcIssuer(s.oidc.issuer);
setOidcClientId(s.oidc.clientId);
})
.catch(() => setLoadError("Einstellungen konnten nicht geladen werden."));
}, []);
async function onSave(section: "smtp" | "telegram" | "logo", values: Record<string, string>, okText: string) {
async function onSave(section: "smtp" | "telegram" | "logo" | "oidc", values: Record<string, string>, okText: string) {
setNotice("");
try {
const s = await saveSection(section, values);
@@ -77,6 +83,11 @@ export default function IntegrationsForm() {
setLogoSecret("");
setLogoPublic(s.logo.publishableKey);
}
if (section === "oidc") {
setOidcSecret("");
setOidcIssuer(s.oidc.issuer);
setOidcClientId(s.oidc.clientId);
}
setNotice(okText);
} catch (e) {
setNotice(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
@@ -178,6 +189,54 @@ export default function IntegrationsForm() {
</button>
</Section>
<Section title="SSO (OIDC)">
<p className="text-[13px] text-muted">Generischer OIDC-Anbieter, z. B. Authentik oder Keycloak. Nach dem Speichern erscheint auf der Anmeldeseite „Mit SSO anmelden“.</p>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="oidc-issuer" className="block text-[13px] font-semibold">Issuer-URL</label>
<input
id="oidc-issuer"
value={oidcIssuer}
onChange={(e) => setOidcIssuer(e.target.value)}
autoComplete="off"
placeholder="https://login.beispiel.de/realms/haushalt"
className={inputClass}
/>
</div>
<div>
<label htmlFor="oidc-client" className="block text-[13px] font-semibold">Client-ID</label>
<input
id="oidc-client"
value={oidcClientId}
onChange={(e) => setOidcClientId(e.target.value)}
autoComplete="off"
placeholder="gutscheintracker"
className={inputClass}
/>
</div>
<div>
<label htmlFor="oidc-secret" className="block text-[13px] font-semibold">Client-Secret</label>
<input
id="oidc-secret"
type="password"
value={oidcSecret}
onChange={(e) => setOidcSecret(e.target.value)}
autoComplete="off"
placeholder={status.oidc.secretGesetzt ? "••••••••" : ""}
className={inputClass}
/>
<SecretHint gesetzt={status.oidc.secretGesetzt} />
</div>
</div>
<button
type="button"
onClick={() => onSave("oidc", { oidc_issuer: oidcIssuer, oidc_client_id: oidcClientId, oidc_client_secret: oidcSecret }, "OIDC-Einstellungen gespeichert.")}
className="mt-4 h-11 rounded-field bg-brand-600 px-6 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99]"
>
Speichern
</button>
</Section>
{notice && <p role="status" className="text-sm font-medium text-muted">{notice}</p>}
</div>
);
-96
View File
@@ -1,96 +0,0 @@
"use client";
import { useEffect, useState } from "react";
type Member = { id: number; name: string };
export default function MembersSection() {
const [members, setMembers] = useState<Member[]>([]);
const [name, setName] = useState("");
const [error, setError] = useState("");
async function load() {
const res = await fetch("/api/members");
if (res.ok) setMembers(await res.json());
}
useEffect(() => {
load();
}, []);
async function add() {
setError("");
const value = name.trim();
if (!value) {
setError("Bitte einen Namen eingeben.");
return;
}
const res = await fetch("/api/members", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: value }),
});
if (!res.ok) {
setError(res.status === 409 ? "Dieser Name existiert bereits." : "Speichern fehlgeschlagen.");
return;
}
setName("");
load();
}
async function remove(id: number, memberName: string) {
if (!window.confirm(`„${memberName}“ aus der Haushaltsliste entfernen? Bereits erfasste Gutscheine behalten den Namen.`)) return;
const res = await fetch(`/api/members?id=${id}`, { method: "DELETE" });
if (res.ok) load();
}
return (
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Haushaltsmitglieder</h2>
<p className="mt-1 text-sm text-muted">Wer einen Gutschein anlegt, wählt hier seinen Namen aus.</p>
<div className="mt-3 flex gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
placeholder="Name hinzufügen"
aria-label="Name hinzufügen"
maxLength={100}
className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink"
/>
<button
type="button"
onClick={add}
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99]"
>
Hinzufügen
</button>
</div>
{error && (
<p role="alert" className="mt-2 text-[13px] font-medium text-danger">
{error}
</p>
)}
{members.length > 0 && (
<ul className="mt-3 divide-y divide-line">
{members.map((m) => (
<li key={m.id} className="flex items-center justify-between py-2 text-sm">
<span className="font-medium">{m.name}</span>
<button
type="button"
onClick={() => remove(m.id, m.name)}
className="rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
>
Entfernen
</button>
</li>
))}
</ul>
)}
</section>
);
}
+30 -20
View File
@@ -1,10 +1,11 @@
import Link from "next/link";
import Shell from "../components/shell";
import { getDb } from "@/lib/db";
import { getSessionUser } from "@/lib/authz";
import { DEFAULT_TAGE_VORHER } from "@/lib/settings";
import { TestButtons } from "./test-buttons";
import IntegrationsForm from "./integrations-form";
import MembersSection from "./members";
import UsersSection from "./users";
export const dynamic = "force-dynamic";
@@ -19,6 +20,8 @@ function Card({ title, children }: { title: string; children: React.ReactNode })
export default async function SettingsPage() {
const db = getDb();
const me = await getSessionUser();
const isAdmin = me?.role === "admin";
const last = await db.notificationLog.findFirst({ orderBy: { sentAt: "desc" } });
return (
<Shell>
@@ -26,7 +29,10 @@ export default async function SettingsPage() {
← Zurück
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Einstellungen</h1>
<p className="mt-1 text-sm text-muted">Erinnerungen, Benachrichtigungen und Empfänger.</p>
<p className="mt-1 text-sm text-muted">
Erinnerungen, Benachrichtigungen und Empfänger.
{!isAdmin && " Verwaltung und Integrationen sind dem Admin vorbehalten."}
</p>
<div className="mt-5 grid gap-4">
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Kontakte</h2>
@@ -38,12 +44,12 @@ export default async function SettingsPage() {
Kontakte verwalten
</Link>
</section>
<MembersSection />
{isAdmin && me && <UsersSection ownId={me.id} ownEmail={me.email} />}
<Card title="Standard-Erinnerungstage">
<p>Neue Gutscheine erhalten diese Erinnerungsregeln: {DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.</p>
</Card>
<IntegrationsForm />
<TestButtons />
{isAdmin && <IntegrationsForm />}
{isAdmin && <TestButtons />}
<Card title="Letzter Cron-Lauf">
{last ? (
<p>
@@ -54,21 +60,25 @@ export default async function SettingsPage() {
<p>Noch keine Benachrichtigung versendet.</p>
)}
</Card>
<Card title="Backup">
<p>Gesamte Datenbank als Datei sichern, inklusive Gutscheine, Kontakte, Mitglieder und verschlüsselte Einstellungen.</p>
<a
href="/api/settings/backup"
download
className="mt-3 inline-block rounded-field bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Datenbank-Backup herunterladen
</a>
<p className="mt-2">Wiederherstellen am Server: Datei als <code>/data/gutscheine.db</code> ablegen. Derselbe <code>SETTINGS_ENCRYPTION_KEY</code> muss erhalten bleiben.</p>
</Card>
<Card title="Konfiguration (.env)">
<p>APP_USER, APP_PASSWORD_HASH, SESSION_SECRET, SETTINGS_ENCRYPTION_KEY, DATABASE_URL, CRON_SECRET, APP_URL. SMTP, Telegram und Logo.dev lassen sich oben in der App verwalten; <code>.env</code> dient als Rückfall.</p>
<p className="mt-2">Zusätzlich manuell: Volume <code>gutschein-data</code> bzw. Datei <code>/data/gutscheine.db</code> regelmäßig kopieren.</p>
</Card>
{isAdmin && (
<Card title="Backup">
<p>Gesamte Datenbank als Datei sichern, inklusive Gutscheine, Kontakte, Mitglieder und verschlüsselte Einstellungen.</p>
<a
href="/api/settings/backup"
download
className="mt-3 inline-block rounded-field bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Datenbank-Backup herunterladen
</a>
<p className="mt-2">Wiederherstellen am Server: Datei als <code>/data/gutscheine.db</code> ablegen. Derselbe <code>SETTINGS_ENCRYPTION_KEY</code> muss erhalten bleiben.</p>
</Card>
)}
{isAdmin && (
<Card title="Konfiguration (.env)">
<p>APP_USER, APP_PASSWORD_HASH, ADMIN_EMAIL, SESSION_SECRET, SETTINGS_ENCRYPTION_KEY, DATABASE_URL, CRON_SECRET, APP_URL, ALLOW_PASSWORD_LOGIN. SMTP, Telegram, Logo.dev und OIDC lassen sich oben in der App verwalten; <code>.env</code> dient als Rückfall.</p>
<p className="mt-2">Zusätzlich manuell: Volume <code>gutschein-data</code> bzw. Datei <code>/data/gutscheine.db</code> regelmäßig kopieren.</p>
</Card>
)}
</div>
</Shell>
);
+200
View File
@@ -0,0 +1,200 @@
"use client";
import { useEffect, useState } from "react";
type UserRow = {
id: number;
name: string;
email: string | null;
role: string;
oidcVerknuepft: boolean;
createdAt: string;
};
type InviteRow = { id: number; email: string; role: string; expiresAt: string; createdAt: string };
const inputClass =
"mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/60";
export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEmail: string | null }) {
const [users, setUsers] = useState<UserRow[]>([]);
const [invites, setInvites] = useState<InviteRow[]>([]);
const [inviteMail, setInviteMail] = useState("");
const [inviteLink, setInviteLink] = useState("");
const [notice, setNotice] = useState("");
const [myEmail, setMyEmail] = useState(ownEmail ?? "");
async function load() {
const [u, inv] = await Promise.all([fetch("/api/users").then((r) => (r.ok ? r.json() : [])), fetch("/api/users/invites").then((r) => (r.ok ? r.json() : []))]);
setUsers(Array.isArray(u) ? u : []);
setInvites(Array.isArray(inv) ? inv : []);
}
useEffect(() => {
load();
}, []);
async function invite() {
setNotice("");
setInviteLink("");
const res = await fetch("/api/users/invites", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: inviteMail }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setNotice(data.message ?? "Einladung fehlgeschlagen.");
return;
}
setInviteMail("");
setInviteLink(data.url ?? "");
if (data.mailError) setNotice(`Einladung angelegt, aber E-Mail-Versand schlug fehl: ${data.mailError}`);
else setNotice("Einladung erstellt (7 Tage gültig).");
load();
}
async function revoke(id: number) {
await fetch(`/api/users/invites?id=${id}`, { method: "DELETE" });
load();
}
async function removeUser(id: number, name: string) {
if (!window.confirm(`„${name}“ wirklich entfernen? Der Zugang ist danach sofort gesperrt.`)) return;
const res = await fetch(`/api/users/${id}`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok) setNotice(data.message ?? "Löschen fehlgeschlagen.");
else setNotice("");
load();
}
async function saveMyEmail() {
setNotice("");
const res = await fetch("/api/users/me", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: myEmail }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) setNotice(data.message ?? "E-Mail konnte nicht gespeichert werden.");
else {
setNotice("Eigene E-Mail gespeichert.");
load();
}
}
return (
<div className="grid gap-4">
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Nutzer</h2>
{users.length === 0 ? (
<p className="mt-2 text-sm text-muted">Noch keine Nutzer.</p>
) : (
<ul className="mt-3 divide-y divide-line">
{users.map((u) => (
<li key={u.id} className="flex items-center justify-between gap-3 py-2.5 text-sm">
<div className="min-w-0">
<p className="font-semibold">
{u.name}
{u.id === ownId && <span className="ml-2 font-normal text-muted">(du)</span>}
</p>
<p className="truncate text-[13px] text-muted">
{u.email ?? "keine E-Mail"} · {u.role === "admin" ? "Admin" : "Mitglied"}
{u.oidcVerknuepft ? " · SSO verknüpft" : ""}
</p>
</div>
{u.id !== ownId && (
<button
type="button"
onClick={() => removeUser(u.id, u.name)}
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
>
Entfernen
</button>
)}
</li>
))}
</ul>
)}
</section>
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Eigene E-Mail-Adresse</h2>
<p className="mt-1 text-[13px] text-muted">Für die SSO-Verknüpfung nötig. Muss mit der Einladung bzw. dem OIDC-Konto übereinstimmen.</p>
<div className="mt-3 flex gap-2">
<input
value={myEmail}
onChange={(e) => setMyEmail(e.target.value)}
type="email"
autoComplete="email"
placeholder="name@beispiel.de"
aria-label="Eigene E-Mail-Adresse"
className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink"
/>
<button
type="button"
onClick={saveMyEmail}
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Speichern
</button>
</div>
</section>
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Per E-Mail einladen</h2>
<p className="mt-1 text-[13px] text-muted">Einladung ist 7 Tage gültig und einmalig einlösbar.</p>
<div className="mt-3 flex gap-2">
<input
value={inviteMail}
onChange={(e) => setInviteMail(e.target.value)}
type="email"
autoComplete="off"
placeholder="name@beispiel.de"
aria-label="E-Mail-Adresse für Einladung"
className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink"
/>
<button
type="button"
onClick={invite}
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Einladen
</button>
</div>
{inviteLink && (
<p className="mt-2 break-all text-[13px]">
Einladungslink:{" "}
<a href={inviteLink} className="font-medium text-brand-700 underline">
{inviteLink}
</a>
</p>
)}
{invites.length > 0 && (
<ul className="mt-3 divide-y divide-line">
{invites.map((inv) => (
<li key={inv.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{inv.email}</p>
<p className="text-[13px] text-muted">gültig bis {new Date(inv.expiresAt).toLocaleDateString("de-DE")}</p>
</div>
<button
type="button"
onClick={() => revoke(inv.id)}
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
>
Zurückziehen
</button>
</li>
))}
</ul>
)}
{notice && (
<p role="status" className="mt-3 text-sm text-muted">
{notice}
</p>
)}
</section>
</div>
);
}
+1 -5
View File
@@ -59,7 +59,6 @@ export default function EditVoucherPage({ params }: { params: Promise<{ id: stri
providerName: v.anbieter ?? "",
contactIds: (v.contacts ?? []).map((c: { contactId: number }) => c.contactId),
reminderDays: rules.map((r) => r.tageVorher),
memberId: "",
});
})
.catch(() => setError("Gutschein nicht gefunden"))
@@ -67,12 +66,10 @@ export default function EditVoucherPage({ params }: { params: Promise<{ id: stri
}, [id]);
async function onSubmit(payload: VoucherFormPayload) {
const { memberId: _memberId, ...rest } = payload;
void _memberId;
const res = await fetch(`/api/vouchers/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(rest),
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -113,7 +110,6 @@ export default function EditVoucherPage({ params }: { params: Promise<{ id: stri
<p className="mt-1 text-sm text-muted">Alle Angaben lassen sich ändern. Der Ersteller bleibt erhalten.</p>
<VoucherForm
initial={initial}
showMemberSelect={false}
creatorName={creatorName}
submitLabel="Änderungen speichern"
onSubmit={onSubmit}
-1
View File
@@ -31,7 +31,6 @@ export default function NewVoucherPage() {
<p className="mt-1 text-sm text-muted">Alle Angaben lassen sich später noch ändern.</p>
<VoucherForm
initial={EMPTY_FORM}
showMemberSelect
showShopLink={false}
submitLabel="Speichern"
onSubmit={onSubmit}
+2
View File
@@ -2,6 +2,8 @@ export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { ensureSchema } = await import("./lib/db-migrate");
await ensureSchema();
const { ensureFirstAdmin } = await import("./lib/ensure-admin");
await ensureFirstAdmin();
const { ensureSchedulerStarted } = await import("./lib/scheduler");
ensureSchedulerStarted();
}
+46
View File
@@ -0,0 +1,46 @@
import { cookies } from "next/headers";
import { getDb } from "./db";
import { verifySession, SESSION_COOKIE, type SessionUser } from "./session";
export type AuthUser = { id: number; name: string; email: string | null; role: string };
/** Aktueller Nutzer aus dem Session-Cookie, gegen die DB geprüft (existiert noch, Token gültig). */
export async function getSessionUser(): Promise<(AuthUser & { tokenVersion: number }) | null> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return null;
let sess: SessionUser | null = null;
try {
sess = await verifySession(token);
} catch {
return null;
}
if (!sess) return null;
const db = getDb();
const user = await db.user.findUnique({ where: { id: sess.uid } });
if (!user || user.tokenVersion !== sess.tokenVersion) return null;
return { id: user.id, name: user.name, email: user.email, role: user.role, tokenVersion: user.tokenVersion };
}
/** Nur für Admins. Gibt den Nutzer zurück oder null. */
export async function requireAdmin(): Promise<AuthUser | null> {
const user = await getSessionUser();
if (!user || user.role !== "admin") return null;
return user;
}
/** Nur für angemeldete (nicht gelöschte, nicht abgemeldete) Nutzer. */
export async function requireUser(): Promise<AuthUser | null> {
return getSessionUser();
}
/** Darf dieses Konto sich per Passwort anmelden? Admin behält immer den Notfallzugang. */
export function canLoginWithPassword(role: string, passwordLoginAllowed: boolean): boolean {
if (role === "admin") return true;
return passwordLoginAllowed;
}
/** Passwort-Login für Mitglieder global steuerbar. Standard: an. */
export function isPasswordLoginAllowed(): boolean {
return process.env.ALLOW_PASSWORD_LOGIN !== "false";
}
+10
View File
@@ -19,6 +19,16 @@ export async function ensureSchema(): Promise<void> {
await db.$executeRaw`CREATE TABLE "HouseholdMember" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
await db.$executeRaw`CREATE UNIQUE INDEX "HouseholdMember_name_key" ON "HouseholdMember"("name")`;
}
if (!tables.includes("User")) {
await db.$executeRaw`CREATE TABLE "User" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL, "email" TEXT, "passwordHash" TEXT, "role" TEXT NOT NULL DEFAULT 'member', "oidcSubject" TEXT, "tokenVersion" INTEGER NOT NULL DEFAULT 0, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
await db.$executeRaw`CREATE UNIQUE INDEX "User_email_key" ON "User"("email")`;
await db.$executeRaw`CREATE UNIQUE INDEX "User_oidcSubject_key" ON "User"("oidcSubject")`;
}
if (!tables.includes("Invite")) {
await db.$executeRaw`CREATE TABLE "Invite" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "email" TEXT NOT NULL, "tokenHash" TEXT NOT NULL, "role" TEXT NOT NULL DEFAULT 'member', "expiresAt" DATETIME NOT NULL, "acceptedAt" DATETIME, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
await db.$executeRaw`CREATE UNIQUE INDEX "Invite_email_key" ON "Invite"("email")`;
await db.$executeRaw`CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash")`;
}
const cols = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM pragma_table_info('Voucher')`).map(
(c) => c.name
);
+23
View File
@@ -0,0 +1,23 @@
import { getDb } from "./db";
/**
* Legt beim allerersten Start das Admin-Konto aus dem bisherigen
* .env-Login an (APP_USER/APP_PASSWORD_HASH, optionale ADMIN_EMAIL).
* Läuft nur, wenn noch kein Nutzer existiert.
*/
export async function ensureFirstAdmin(): Promise<void> {
try {
const db = getDb();
const count = await db.user.count();
if (count > 0) return;
const name = (process.env.APP_USER ?? "").trim();
const hash = process.env.APP_PASSWORD_HASH ?? "";
if (!name || !hash) return;
const rawMail = (process.env.ADMIN_EMAIL ?? "").trim().toLowerCase();
const email = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(rawMail) ? rawMail : null;
await db.user.create({ data: { name, email, passwordHash: hash, role: "admin" } });
console.log(`Erster Admin angelegt (${name}).`);
} catch (err) {
console.error("ensureFirstAdmin fehlgeschlagen (Start läuft weiter):", err);
}
}
+37 -3
View File
@@ -1,10 +1,15 @@
import { getDb } from "./db";
import { canEncrypt, decryptSecret, encryptSecret } from "./settings-crypto";
export const INTEGRATION_SECTIONS = ["smtp", "telegram", "logo"] as const;
export const INTEGRATION_SECTIONS = ["smtp", "telegram", "logo", "oidc"] as const;
export type IntegrationSection = (typeof INTEGRATION_SECTIONS)[number];
const SECRET_KEYS = new Set(["smtp_pass", "telegram_bot_token", "logo_dev_secret_key"]);
const SECRET_KEYS = new Set(["smtp_pass", "telegram_bot_token", "logo_dev_secret_key", "oidc_client_secret"]);
/** Geheimnis-Felder werden verschlüsselt gespeichert und nie im Klartext zurückgegeben. */
export function isSecretKey(key: string): boolean {
return SECRET_KEYS.has(key);
}
const ENV_FALLBACK: Record<string, string> = {
smtp_host: "SMTP_HOST",
@@ -15,11 +20,24 @@ const ENV_FALLBACK: Record<string, string> = {
telegram_bot_token: "TELEGRAM_BOT_TOKEN",
logo_dev_secret_key: "LOGO_DEV_SECRET_KEY",
logo_dev_publishable_key: "LOGO_DEV_PUBLISHABLE_KEY",
oidc_issuer: "OIDC_ISSUER",
oidc_client_id: "OIDC_CLIENT_ID",
oidc_client_secret: "OIDC_CLIENT_SECRET",
};
export type SmtpSettings = { host: string; port: number; user: string; pass: string; from: string };
export type TelegramSettings = { botToken: string };
export type LogoSettings = { secretKey: string; publishableKey: string };
export type OidcSettings = { issuer: string; clientId: string; clientSecret: string };
export async function getOidcSettings(): Promise<OidcSettings> {
const [issuer, clientId, clientSecret] = await Promise.all([
resolveValue("oidc_issuer"),
resolveValue("oidc_client_id"),
resolveValue("oidc_client_secret"),
]);
return { issuer: issuer.replace(/\/$/, ""), clientId, clientSecret };
}
async function readStored(key: string): Promise<string | null> {
const db = getDb();
@@ -67,19 +85,35 @@ export async function getLogoSettings(): Promise<LogoSettings> {
/** Status für die UI: Klartextwerte plus je Geheimnis nur ob gesetzt, nie der Wert. */
export async function getIntegrationStatus() {
const [smtp, telegram, logo] = await Promise.all([getSmtpSettings(), getTelegramSettings(), getLogoSettings()]);
const [smtp, telegram, logo, oidc] = await Promise.all([
getSmtpSettings(),
getTelegramSettings(),
getLogoSettings(),
getOidcStatus(),
]);
return {
smtp: { host: smtp.host, port: smtp.port, user: smtp.user, from: smtp.from, passGesetzt: smtp.pass !== "" },
telegram: { botTokenGesetzt: telegram.botToken !== "" },
logo: { secretGesetzt: logo.secretKey !== "", publishableKey: logo.publishableKey },
oidc,
verschluesselungBereit: canEncrypt(),
};
}
async function getOidcStatus() {
const [issuer, clientId, clientSecret] = await Promise.all([
resolveValue("oidc_issuer"),
resolveValue("oidc_client_id"),
resolveValue("oidc_client_secret"),
]);
return { issuer: issuer.replace(/\/$/, ""), clientId, secretGesetzt: clientSecret !== "" };
}
const SECTION_FIELDS: Record<IntegrationSection, string[]> = {
smtp: ["smtp_host", "smtp_port", "smtp_user", "smtp_pass", "mail_from"],
telegram: ["telegram_bot_token"],
logo: ["logo_dev_secret_key", "logo_dev_publishable_key"],
oidc: ["oidc_issuer", "oidc_client_id", "oidc_client_secret"],
};
export function validateIntegrationSection(section: string, values: Record<string, unknown>): string[] {
+39
View File
@@ -0,0 +1,39 @@
import { createHash, randomBytes, timingSafeEqual } from "crypto";
export const INVITE_VALID_DAYS = 7;
export function createInviteToken(): string {
return randomBytes(32).toString("base64url");
}
export function hashInviteToken(raw: string): string {
return createHash("sha256").update(raw, "utf8").digest("hex");
}
export function tokensEqual(a: string, b: string): boolean {
const ba = Buffer.from(a, "utf8");
const bb = Buffer.from(b, "utf8");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
export type InviteLike = { tokenHash: string; expiresAt: Date; acceptedAt: Date | null };
/** Prüft Einladung anhand des übergebenen Roh-Tokens und Ablaufdatum. */
export function isInviteValid(
invite: InviteLike | null,
rawToken: string,
now: Date = new Date()
): boolean {
if (!invite || !rawToken) return false;
if (invite.acceptedAt !== null) return false;
if (invite.expiresAt.getTime() <= now.getTime()) return false;
return tokensEqual(invite.tokenHash, hashInviteToken(rawToken));
}
export function normalizeEmail(email: string | null | undefined): string | null {
if (!email) return null;
const e = email.trim().toLowerCase();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)) return null;
return e;
}
+124
View File
@@ -0,0 +1,124 @@
import { createHash, randomBytes } from "crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { getOidcSettings } from "./integration-settings";
export type OidcDiscovery = {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
};
let discoveryCache: { issuer: string; doc: OidcDiscovery; at: number } | null = null;
function base64url(buf: Buffer): string {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** PKCE S256: Challenge aus Verifier berechnen (RFC 7636-Testvektor geprüft). */
export function pkceChallenge(verifier: string): string {
return base64url(createHash("sha256").update(verifier, "ascii").digest());
}
export function randomToken(bytes = 32): string {
return base64url(randomBytes(bytes));
}
export async function discover(issuer: string): Promise<OidcDiscovery> {
const normalized = issuer.replace(/\/$/, "");
if (discoveryCache && discoveryCache.issuer === normalized && Date.now() - discoveryCache.at < 5 * 60 * 1000) {
return discoveryCache.doc;
}
const res = await fetch(`${normalized}/.well-known/openid-configuration`);
if (!res.ok) throw new Error(`OIDC-Discovery ${res.status}`);
const doc = (await res.json()) as Record<string, unknown>;
if (
typeof doc.issuer !== "string" ||
typeof doc.authorization_endpoint !== "string" ||
typeof doc.token_endpoint !== "string" ||
typeof doc.jwks_uri !== "string"
) {
throw new Error("OIDC-Discovery unvollständig");
}
const out: OidcDiscovery = {
issuer: (doc.issuer as string).replace(/\/$/, ""),
authorization_endpoint: doc.authorization_endpoint as string,
token_endpoint: doc.token_endpoint as string,
jwks_uri: doc.jwks_uri as string,
};
discoveryCache = { issuer: normalized, doc: out, at: Date.now() };
return out;
}
export type OidcLoginState = { state: string; nonce: string; verifier: string };
export function buildLoginUrl(
doc: OidcDiscovery,
clientId: string,
redirectUri: string,
st: OidcLoginState
): string {
const q = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
scope: "openid email profile",
state: st.state,
nonce: st.nonce,
code_challenge: pkceChallenge(st.verifier),
code_challenge_method: "S256",
});
return `${doc.authorization_endpoint}?${q.toString()}`;
}
export type VerifiedOidcUser = { subject: string; email: string; name: string };
/** Code einlösen und ID-Token prüfen (Signatur, Issuer, Audience, Nonce, verifizierte E-Mail). */
export async function verifyCallbackCode(
doc: OidcDiscovery,
clientId: string,
clientSecret: string,
redirectUri: string,
code: string,
verifier: string,
expectedNonce: string
): Promise<VerifiedOidcUser> {
const tokenRes = await fetch(doc.token_endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
code_verifier: verifier,
}),
});
if (!tokenRes.ok) throw new Error(`OIDC-Tokenaustausch ${tokenRes.status}`);
const tokens = (await tokenRes.json()) as Record<string, unknown>;
if (typeof tokens.id_token !== "string") throw new Error("OIDC ohne ID-Token");
const jwks = createRemoteJWKSet(new URL(doc.jwks_uri));
const { payload } = await jwtVerify(tokens.id_token as string, jwks, { issuer: doc.issuer, audience: clientId });
if (payload.nonce !== expectedNonce) throw new Error("OIDC-Nonce stimmt nicht");
if (typeof payload.sub !== "string") throw new Error("OIDC ohne Subject");
if (typeof payload.email !== "string" || payload.email_verified !== true) {
throw new Error("OIDC-E-Mail nicht verifiziert");
}
const name =
typeof payload.name === "string" && payload.name.trim()
? payload.name.trim()
: typeof payload.preferred_username === "string" && payload.preferred_username.trim()
? payload.preferred_username.trim()
: payload.email.split("@")[0];
return { subject: payload.sub, email: payload.email.toLowerCase(), name };
}
export const STATE_COOKIE = "oidc_state";
export const NONCE_COOKIE = "oidc_nonce";
export const VERIFIER_COOKIE = "oidc_verifier";
export async function isOidcConfigured(): Promise<boolean> {
const { issuer, clientId, clientSecret } = await getOidcSettings();
return issuer !== "" && clientId !== "" && clientSecret !== "";
}
+14 -5
View File
@@ -2,21 +2,30 @@ import { SignJWT, jwtVerify } from "jose";
export const SESSION_COOKIE = "gt_session";
export type SessionUser = { uid: number; name: string; role: string; tokenVersion: number };
function secret(): Uint8Array {
const s = process.env.SESSION_SECRET ?? "";
if (s.length < 32) throw new Error("SESSION_SECRET zu kurz");
return new TextEncoder().encode(s);
}
export async function createSession(user: string): Promise<string> {
return new SignJWT({ user }).setProtectedHeader({ alg: "HS256" }).setExpirationTime("30d").sign(secret());
export async function createSession(user: SessionUser): Promise<string> {
return new SignJWT({ ...user }).setProtectedHeader({ alg: "HS256" }).setExpirationTime("30d").sign(secret());
}
export async function verifySession(token: string): Promise<{ user: string } | null> {
export async function verifySession(token: string): Promise<SessionUser | null> {
try {
const { payload } = await jwtVerify(token, secret());
if (typeof payload.user !== "string") return null;
return { user: payload.user };
if (
typeof payload.uid !== "number" ||
typeof payload.name !== "string" ||
typeof payload.role !== "string" ||
typeof payload.tokenVersion !== "number"
) {
return null;
}
return { uid: payload.uid, name: payload.name, role: payload.role, tokenVersion: payload.tokenVersion };
} catch {
return null;
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "./session";
/** Setzt das Session-Cookie (30 Tage, httpOnly, Secure in Production). */
export function setSessionCookie(res: NextResponse, token: string): void {
res.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
}
+6 -1
View File
@@ -1,9 +1,14 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifySession, SESSION_COOKIE } from "./lib/session";
const PUBLIC_PREFIXES = ["/login", "/api/auth", "/accept-invite", "/api/users/accept"];
export async function middleware(req: NextRequest) {
const p = req.nextUrl.pathname;
if (p.startsWith("/login") || p.startsWith("/api/auth")) return NextResponse.next();
if (PUBLIC_PREFIXES.some((prefix) => p === prefix || p.startsWith(`${prefix}/`))) {
return NextResponse.next();
}
const token = req.cookies.get(SESSION_COOKIE)?.value;
if (token && (await verifySession(token))) return NextResponse.next();
return NextResponse.redirect(new URL("/login", req.url));
+22
View File
@@ -73,3 +73,25 @@ model HouseholdMember {
name String @unique
createdAt DateTime @default(now())
}
model User {
id Int @id @default(autoincrement())
name String
email String? @unique
passwordHash String?
role String @default("member")
oidcSubject String? @unique
tokenVersion Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Invite {
id Int @id @default(autoincrement())
email String @unique
tokenHash String @unique
role String @default("member")
expiresAt DateTime
acceptedAt DateTime?
createdAt DateTime @default(now())
}
+28 -4
View File
@@ -1,18 +1,42 @@
import { describe, it, expect } from "vitest";
import bcrypt from "bcryptjs";
import { verifyPassword, createSession, verifySession } from "../lib/auth";
import { canLoginWithPassword, isPasswordLoginAllowed } from "../lib/authz";
const SESSION = { uid: 7, name: "julia", role: "member" as const, tokenVersion: 3 };
describe("auth", () => {
it("rejects wrong password", async () => {
const hash = await bcrypt.hash("richtig-geheim", 10);
expect(await verifyPassword("falsch", hash)).toBe(false);
});
it("roundtrips session", async () => {
it("roundtrips session with uid, role and token version", async () => {
process.env.SESSION_SECRET = "test-secret-mindestens-32-zeichen-lang!";
const token = await createSession("haushalt");
expect(await verifySession(token)).toEqual({ user: "haushalt" });
const token = await createSession(SESSION);
expect(await verifySession(token)).toEqual(SESSION);
});
it("rejects tampered token", async () => {
it("rejects tampered token and legacy shape", async () => {
process.env.SESSION_SECRET = "test-secret-mindestens-32-zeichen-lang!";
expect(await verifySession("kaputt.token.hier")).toBeNull();
});
});
describe("password login gate", () => {
it("admin behält immer den Notfallzugang", () => {
expect(canLoginWithPassword("admin", false)).toBe(true);
expect(canLoginWithPassword("admin", true)).toBe(true);
});
it("mitglieder brauchen passwordAllowed", () => {
expect(canLoginWithPassword("member", true)).toBe(true);
expect(canLoginWithPassword("member", false)).toBe(false);
});
it("isPasswordLoginAllowed liest ALLOW_PASSWORD_LOGIN (Standard an)", () => {
delete process.env.ALLOW_PASSWORD_LOGIN;
expect(isPasswordLoginAllowed()).toBe(true);
process.env.ALLOW_PASSWORD_LOGIN = "false";
expect(isPasswordLoginAllowed()).toBe(false);
process.env.ALLOW_PASSWORD_LOGIN = "true";
expect(isPasswordLoginAllowed()).toBe(true);
delete process.env.ALLOW_PASSWORD_LOGIN;
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import {
createInviteToken,
hashInviteToken,
isInviteValid,
normalizeEmail,
INVITE_VALID_DAYS,
} from "../lib/invites";
import { pkceChallenge } from "../lib/oidc";
import { validateIntegrationSection } from "../lib/integration-settings";
describe("invites", () => {
it("Ablauf: 7 Tage, einmalig, Token-Bindung", () => {
expect(INVITE_VALID_DAYS).toBe(7);
const raw = createInviteToken();
const invite = { tokenHash: hashInviteToken(raw), expiresAt: new Date(Date.now() + 3600_000), acceptedAt: null };
expect(isInviteValid(invite, raw)).toBe(true);
expect(isInviteValid(invite, "falsch")).toBe(false);
expect(isInviteValid({ ...invite, acceptedAt: new Date() }, raw)).toBe(false);
expect(isInviteValid({ ...invite, expiresAt: new Date(Date.now() - 1000) }, raw)).toBe(false);
expect(isInviteValid(null, raw)).toBe(false);
});
it("normalizeEmail prüft und klebt", () => {
expect(normalizeEmail("Mama@Beispiel.DE ")).toBe("mama@beispiel.de");
expect(normalizeEmail("keine-mail")).toBeNull();
expect(normalizeEmail(null)).toBeNull();
});
});
describe("pkce", () => {
it("S256-Testvektor aus RFC 7636", () => {
expect(pkceChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")).toBe(
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
});
});
describe("validateIntegrationSection oidc", () => {
it("akzeptiert gültige OIDC-Werte", () => {
expect(
validateIntegrationSection("oidc", {
oidc_issuer: "https://login.beispiel.de",
oidc_client_id: "abc",
oidc_client_secret: "geheim",
})
).toEqual([]);
});
it("lehnt unbekannte Bereiche und Typen ab", () => {
expect(validateIntegrationSection("sso", {})).toEqual(["section"]);
expect(validateIntegrationSection("oidc", { oidc_issuer: 5 })).toEqual(["oidc_issuer"]);
});
});