feat: Mehrbenutzer mit Rollen, E-Mail-Einladungen und generischem OIDC-Login
This commit is contained in:
1 parent
fc752e0041
commit
1744120503
41 files changed
+1245
-235
No files matched your search
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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 ?? ""),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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)" },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }))
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user