From 36c54cfa1c438b626b2ffaebf86c2c7fad5298cb Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 18 Oct 2026 19:08:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Mehrhaushalt-Modell=20mit=20privaten=20?= =?UTF-8?q?und=20gemeinsamen=20Best=C3=A4nden,=20Server-/Haushalts-Admins,?= =?UTF-8?q?=20OIDC-Selbstregistrierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/oidc/callback/route.ts | 13 +- app/api/contacts/route.ts | 30 ++- app/api/households/route.ts | 80 ++++++++ app/api/users/[id]/route.ts | 65 ++++++- app/api/users/accept/route.ts | 3 + app/api/users/invites/route.ts | 55 ++++-- app/api/users/me/route.ts | 26 ++- app/api/users/route.ts | 52 ++++- app/api/vouchers/[id]/route.ts | 56 ++++-- app/api/vouchers/route.ts | 28 ++- app/components/overview-toolbar.tsx | 31 ++- app/components/voucher-form.tsx | 59 +++++- app/contacts/page.tsx | 56 +++++- app/page.tsx | 31 ++- app/settings/household-panel.tsx | 197 +++++++++++++++++++ app/settings/page.tsx | 17 +- app/settings/server-households.tsx | 289 ++++++++++++++++++++++++++++ app/settings/tabs.tsx | 29 +++ app/settings/users.tsx | 75 ++++++-- app/vouchers/[id]/edit/page.tsx | 7 + app/vouchers/[id]/page.tsx | 2 + app/vouchers/new/page.tsx | 12 +- instrumentation.ts | 3 +- lib/access.ts | 61 ++++++ lib/db-migrate.ts | 89 ++++++++- lib/viewer.ts | 40 ++++ prisma/schema.prisma | 30 ++- tests/access.test.ts | 53 +++++ 28 files changed, 1354 insertions(+), 135 deletions(-) create mode 100644 app/api/households/route.ts create mode 100644 app/settings/household-panel.tsx create mode 100644 app/settings/server-households.tsx create mode 100644 lib/access.ts create mode 100644 lib/viewer.ts create mode 100644 tests/access.test.ts diff --git a/app/api/auth/oidc/callback/route.ts b/app/api/auth/oidc/callback/route.ts index b581e03..b3d1d4e 100644 --- a/app/api/auth/oidc/callback/route.ts +++ b/app/api/auth/oidc/callback/route.ts @@ -60,13 +60,16 @@ export async function GET(req: NextRequest) { 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"); - } + const invited = invite && invite.acceptedAt === null && invite.expiresAt.getTime() > Date.now() ? invite : null; user = await db.user.create({ - data: { name: verified.name, email: verified.email, role: invite.role, oidcSubject: verified.subject }, + data: { name: verified.name, email: verified.email, role: invited?.role ?? "member", oidcSubject: verified.subject }, }); - await db.invite.update({ where: { id: invite.id }, data: { acceptedAt: new Date() } }); + if (invited) { + if (invited.householdId !== null) { + await db.householdMember.create({ data: { householdId: invited.householdId, userId: user.id, role: "member" } }); + } + await db.invite.update({ where: { id: invited.id }, data: { acceptedAt: new Date() } }); + } } } const token = await createSession({ uid: user.id, name: user.name, role: user.role, tokenVersion: user.tokenVersion }); diff --git a/app/api/contacts/route.ts b/app/api/contacts/route.ts index da0b64d..c4631c5 100644 --- a/app/api/contacts/route.ts +++ b/app/api/contacts/route.ts @@ -1,17 +1,31 @@ import { NextRequest, NextResponse } from "next/server"; import { getDb } from "@/lib/db"; import { validateContact } from "@/lib/contacts"; -import { requireUser } from "@/lib/authz"; +import { getViewer } from "@/lib/viewer"; +import { resolveTarget, scopeWhere } from "@/lib/access"; -export async function GET() { +export async function GET(req: NextRequest) { + const viewer = await getViewer(); + if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + const scopeParam = req.nextUrl.searchParams.get("scope"); const db = getDb(); - const all = await db.contact.findMany({ orderBy: { name: "asc" } }); + const all = await db.contact.findMany({ where: scopeWhere(viewer), orderBy: { name: "asc" } }); + if (!scopeParam) return NextResponse.json(all); + if (scopeParam === "household" && viewer.householdId !== null) { + return NextResponse.json(all.filter((c) => c.householdId === viewer.householdId)); + } + if (scopeParam === "private") { + return NextResponse.json(all.filter((c) => c.householdId === null)); + } return NextResponse.json(all); } export async function POST(req: NextRequest) { - if (!(await requireUser())) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + const viewer = await getViewer(); + if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); const body = await req.json().catch(() => ({})); + const target = resolveTarget(viewer, body.scope); + if (!target.ok) return NextResponse.json({ errors: ["scope"], message: target.error }, { status: 400 }); const input = { name: String(body.name ?? ""), email: body.email ? String(body.email) : null, @@ -21,7 +35,13 @@ export async function POST(req: NextRequest) { if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 }); const db = getDb(); const created = await db.contact.create({ - data: { name: input.name.trim(), email: input.email?.trim() || null, telegramChatId: input.telegramChatId?.trim() || null }, + data: { + name: input.name.trim(), + email: input.email?.trim() || null, + telegramChatId: input.telegramChatId?.trim() || null, + ownerId: viewer.id, + householdId: target.householdId, + }, }); return NextResponse.json(created, { status: 201 }); } diff --git a/app/api/households/route.ts b/app/api/households/route.ts new file mode 100644 index 0000000..8f22723 --- /dev/null +++ b/app/api/households/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; +import { getViewer } from "@/lib/viewer"; + +function forbidden() { + return NextResponse.json({ error: "forbidden" }, { status: 403 }); +} + +/** Alle Haushalte mit Mitgliederzahl (nur Server-Admin). */ +export async function GET() { + const viewer = await getViewer(); + if (!viewer || viewer.role !== "admin") return forbidden(); + const db = getDb(); + const households = await db.household.findMany({ + orderBy: { name: "asc" }, + include: { _count: { select: { members: true, vouchers: true } }, members: { include: { user: true } } }, + }); + return NextResponse.json( + households.map((h) => ({ + id: h.id, + name: h.name, + createdAt: h.createdAt, + mitgliederAnzahl: h._count.members, + gutscheineAnzahl: h._count.vouchers, + mitglieder: h.members.map((m) => ({ userId: m.userId, name: m.user.name, email: m.user.email, rolle: m.role })), + })) + ); +} + +/** Haushalt anlegen, optional mit erstem Haushalts-Admin (muss unzugeordnet sein). */ +export async function POST(req: NextRequest) { + const viewer = await getViewer(); + if (!viewer || viewer.role !== "admin") return forbidden(); + const body = await req.json().catch(() => ({})); + const name = typeof body.name === "string" ? body.name.trim() : ""; + if (!name || name.length > 100) return NextResponse.json({ errors: ["name"] }, { status: 400 }); + const db = getDb(); + if (await db.household.findUnique({ where: { name } })) { + return NextResponse.json({ errors: ["name"], message: "Haushalt existiert bereits." }, { status: 409 }); + } + const household = await db.household.create({ data: { name } }); + if (body.adminUserId !== undefined && body.adminUserId !== null) { + const adminId = Number(body.adminUserId); + if (!Number.isInteger(adminId)) { + await db.household.delete({ where: { id: household.id } }); + return NextResponse.json({ errors: ["adminUserId"] }, { status: 400 }); + } + const candidate = await db.user.findUnique({ where: { id: adminId }, include: { membership: true } }); + if (!candidate || candidate.membership) { + await db.household.delete({ where: { id: household.id } }); + return NextResponse.json({ errors: ["adminUserId"], message: "Nutzer existiert nicht oder hat bereits einen Haushalt." }, { status: 400 }); + } + await db.householdMember.create({ data: { householdId: household.id, userId: adminId, role: "admin" } }); + } + return NextResponse.json({ ok: true, id: household.id }, { status: 201 }); +} + +/** Haushalt samt Bestand auflösen (nur Server-Admin). Private Daten bleiben erhalten. */ +export async function DELETE(req: NextRequest) { + const viewer = await getViewer(); + if (!viewer || viewer.role !== "admin") return forbidden(); + const id = Number(req.nextUrl.searchParams.get("id")); + if (!Number.isInteger(id)) return NextResponse.json({ error: "not found" }, { status: 404 }); + const db = getDb(); + const household = await db.household.findUnique({ where: { id } }); + if (!household) return NextResponse.json({ error: "not found" }, { status: 404 }); + const voucherIds = (await db.voucher.findMany({ where: { householdId: id }, select: { id: true } })).map((v) => v.id); + const contactIds = (await db.contact.findMany({ where: { householdId: id }, select: { id: true } })).map((c) => c.id); + await db.$transaction([ + db.notificationLog.deleteMany({ where: { rule: { voucher: { id: { in: voucherIds } } } } }), + db.reminderRule.deleteMany({ where: { voucherId: { in: voucherIds } } }), + db.voucherContact.deleteMany({ where: { OR: [{ voucherId: { in: voucherIds } }, { contactId: { in: contactIds } }] } }), + db.voucher.deleteMany({ where: { id: { in: voucherIds } } }), + db.contact.deleteMany({ where: { id: { in: contactIds } } }), + db.invite.deleteMany({ where: { householdId: id } }), + db.householdMember.deleteMany({ where: { householdId: id } }), + db.household.delete({ where: { id } }), + ]); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts index 3cf609d..730b1bf 100644 --- a/app/api/users/[id]/route.ts +++ b/app/api/users/[id]/route.ts @@ -1,18 +1,51 @@ import { NextRequest, NextResponse } from "next/server"; import { getDb } from "@/lib/db"; -import { requireAdmin } from "@/lib/authz"; +import { getViewer } from "@/lib/viewer"; -/** 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) { +/** Nutzer zuordnen/entfernen (nur Server-Admin). */ +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const viewer = await getViewer(); + if (!viewer || viewer.role !== "admin") return NextResponse.json({ error: "forbidden" }, { status: 403 }); + const { id } = await params; + const numId = Number(id); + if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 }); + const db = getDb(); + const target = await db.user.findUnique({ where: { id: numId }, include: { membership: true } }); + if (!target) return NextResponse.json({ error: "not found" }, { status: 404 }); + const body = await req.json().catch(() => ({})); + const hasHousehold = body.householdId !== undefined && body.householdId !== null; + if (!hasHousehold) { + if (target.membership) await db.householdMember.delete({ where: { userId: numId } }); + return NextResponse.json({ ok: true, householdId: null }); + } + const hid = Number(body.householdId); + if (!Number.isInteger(hid) || !(await db.household.findUnique({ where: { id: hid } }))) { + return NextResponse.json({ errors: ["householdId"] }, { status: 400 }); + } + if (target.membership && target.membership.householdId !== hid) { + return NextResponse.json({ errors: ["householdId"], message: "Nutzer ist bereits einem anderen Haushalt zugeordnet." }, { status: 409 }); + } + const householdRole = body.householdRole === "admin" ? "admin" : "member"; + await db.householdMember.upsert({ + where: { userId: numId }, + create: { householdId: hid, userId: numId, role: householdRole }, + update: { householdId: hid, role: householdRole }, + }); + return NextResponse.json({ ok: true, householdId: hid }); +} + +/** Nutzer löschen (nur Server-Admin). Private Daten werden entfernt, gemeinsame Bestände bleiben erhalten. */ +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const viewer = await getViewer(); + if (!viewer || viewer.role !== "admin") return NextResponse.json({ error: "forbidden" }, { status: 403 }); + const { id } = await params; + const numId = Number(id); + if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 }); + if (numId === viewer.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 } }); + const target = await db.user.findUnique({ where: { id: numId } }); if (!target) return NextResponse.json({ error: "not found" }, { status: 404 }); if (target.role === "admin") { const admins = await db.user.count({ where: { role: "admin" } }); @@ -20,6 +53,18 @@ export async function DELETE(req: NextRequest) { return NextResponse.json({ errors: ["id"], message: "Der letzte Admin kann nicht gelöscht werden." }, { status: 400 }); } } - await db.user.delete({ where: { id } }); + const privateVoucherIds = (await db.voucher.findMany({ where: { ownerId: numId, householdId: null }, select: { id: true } })).map((v) => v.id); + const privateContactIds = (await db.contact.findMany({ where: { ownerId: numId, householdId: null }, select: { id: true } })).map((c) => c.id); + await db.$transaction([ + db.notificationLog.deleteMany({ where: { rule: { voucher: { id: { in: privateVoucherIds } } } } }), + db.reminderRule.deleteMany({ where: { voucherId: { in: privateVoucherIds } } }), + db.voucherContact.deleteMany({ where: { OR: [{ voucherId: { in: privateVoucherIds } }, { contactId: { in: privateContactIds } }] } }), + db.voucher.deleteMany({ where: { id: { in: privateVoucherIds } } }), + db.contact.deleteMany({ where: { id: { in: privateContactIds } } }), + db.voucher.updateMany({ where: { ownerId: numId }, data: { ownerId: viewer.id } }), + db.contact.updateMany({ where: { ownerId: numId }, data: { ownerId: viewer.id } }), + db.householdMember.deleteMany({ where: { userId: numId } }), + db.user.delete({ where: { id: numId } }), + ]); return NextResponse.json({ ok: true }); } diff --git a/app/api/users/accept/route.ts b/app/api/users/accept/route.ts index 3d81cab..cb29cd0 100644 --- a/app/api/users/accept/route.ts +++ b/app/api/users/accept/route.ts @@ -36,6 +36,9 @@ export async function POST(req: NextRequest) { const created = await db.user.create({ data: { name, email: invite.email, passwordHash, role: invite.role }, }); + if (invite.householdId !== null) { + await db.householdMember.create({ data: { householdId: invite.householdId, userId: created.id, role: "member" } }); + } await db.invite.update({ where: { id: invite.id }, data: { acceptedAt: new Date(), tokenHash: `${hashInviteToken(token)}:verbraucht` } }); return NextResponse.json({ ok: true, id: created.id }); } diff --git a/app/api/users/invites/route.ts b/app/api/users/invites/route.ts index cccc1de..2de5603 100644 --- a/app/api/users/invites/route.ts +++ b/app/api/users/invites/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getDb } from "@/lib/db"; -import { requireAdmin } from "@/lib/authz"; +import { getViewer } from "@/lib/viewer"; import { createInviteToken, hashInviteToken, normalizeEmail, INVITE_VALID_DAYS } from "@/lib/invites"; import { sendMail } from "@/lib/mail"; @@ -9,21 +9,29 @@ function inviteUrl(req: NextRequest, rawToken: string): string { return `${appUrl}/accept-invite?token=${rawToken}`; } -/** Offene Einladungen. Nur Admin. */ +/** Offene Einladungen: Server-Admin sieht alle, Haushalts-Admin die des eigenen Haushalts. */ export async function GET() { - if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 }); + const viewer = await getViewer(); + if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); 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); + if (viewer.role !== "admin" && !(viewer.householdId !== null && viewer.householdRole === "admin")) { + return NextResponse.json({ error: "forbidden" }, { status: 403 }); + } + const where = viewer.role === "admin" ? { acceptedAt: null } : { acceptedAt: null, householdId: viewer.householdId }; + const invites = await db.invite.findMany({ where, orderBy: { createdAt: "desc" } }); + return NextResponse.json(invites.map(({ id, email, role, householdId, expiresAt, createdAt }) => ({ id, email, role, householdId, expiresAt, createdAt }))); } -/** Neue Einladung per E-Mail. Nur Admin. Gibt den Link einmalig zurück (zum Kopieren). */ +/** + * Neue Einladung per E-Mail. Server-Admin wählt den Haushalt (oder lässt ihn + * leer für unzugeordnete Nutzer); Haushalts-Admin lädt immer in den eigenen ein. + */ export async function POST(req: NextRequest) { - if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 }); + const viewer = await getViewer(); + if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + const isServerAdmin = viewer.role === "admin"; + const isHouseholdAdmin = viewer.householdId !== null && viewer.householdRole === "admin"; + if (!isServerAdmin && !isHouseholdAdmin) 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 }); @@ -31,12 +39,24 @@ export async function POST(req: NextRequest) { if (await db.user.findUnique({ where: { email } })) { return NextResponse.json({ errors: ["email"], message: "Für diese E-Mail existiert bereits ein Konto." }, { status: 409 }); } + let householdId: number | null = null; + if (isServerAdmin) { + if (body.householdId !== undefined && body.householdId !== null) { + const hid = Number(body.householdId); + if (!Number.isInteger(hid) || !(await db.household.findUnique({ where: { id: hid } }))) { + return NextResponse.json({ errors: ["householdId"] }, { status: 400 }); + } + householdId = hid; + } + } else { + householdId = viewer.householdId; + } 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 }, + create: { email, tokenHash: hashInviteToken(rawToken), role: "member", householdId, expiresAt }, + update: { tokenHash: hashInviteToken(rawToken), expiresAt, acceptedAt: null, householdId }, }); const url = inviteUrl(req, rawToken); let mailError: string | null = null; @@ -48,14 +68,19 @@ export async function POST(req: NextRequest) { return NextResponse.json({ ok: true, url, mailError }, { status: 201 }); } -/** Einladung zurückziehen. Nur Admin. */ +/** Einladung zurückziehen. Server-Admin überall, Haushalts-Admin nur im eigenen Haushalt. */ export async function DELETE(req: NextRequest) { - if (!(await requireAdmin())) return NextResponse.json({ error: "forbidden" }, { status: 403 }); + const viewer = await getViewer(); + if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); 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 }); + const allowed = + viewer.role === "admin" || + (viewer.householdId !== null && viewer.householdRole === "admin" && existing.householdId === viewer.householdId); + if (!allowed) return NextResponse.json({ error: "forbidden" }, { status: 403 }); await db.invite.delete({ where: { id } }); return NextResponse.json({ ok: true }); } diff --git a/app/api/users/me/route.ts b/app/api/users/me/route.ts index f40a7ca..c3b7a74 100644 --- a/app/api/users/me/route.ts +++ b/app/api/users/me/route.ts @@ -1,20 +1,36 @@ import { NextRequest, NextResponse } from "next/server"; import { getDb } from "@/lib/db"; -import { getSessionUser } from "@/lib/authz"; +import { getViewer } from "@/lib/viewer"; import { normalizeEmail } from "@/lib/invites"; +/** Eigenes Profil inkl. Haushaltszugehörigkeit (für Formulare). */ +export async function GET() { + const viewer = await getViewer(); + if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + return NextResponse.json({ + id: viewer.id, + name: viewer.name, + email: viewer.email, + role: viewer.role, + isServerAdmin: viewer.isServerAdmin, + householdId: viewer.householdId, + householdName: viewer.householdName, + householdRole: viewer.householdRole, + }); +} + /** 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 viewer = await getViewer(); + if (!viewer) 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) { + if (taken && taken.id !== viewer.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 } }); + const updated = await db.user.update({ where: { id: viewer.id }, data: { email } }); return NextResponse.json({ ok: true, email: updated.email }); } diff --git a/app/api/users/route.ts b/app/api/users/route.ts index d6b9d1c..0990ada 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -1,17 +1,55 @@ import { NextResponse } from "next/server"; import { getDb } from "@/lib/db"; -import { requireAdmin } from "@/lib/authz"; +import { getViewer } from "@/lib/viewer"; -/** Alle Nutzer (ohne Geheimnisse). Nur Admin. */ +/** + * Nutzerliste: + * - Server-Admin: alle Nutzer mit Haushaltszuordnung. + * - Haushalts-Admin: nur Mitglieder des eigenen Haushalts. + * - Andere: keine Einsicht. + */ export async function GET() { - const admin = await requireAdmin(); - if (!admin) return NextResponse.json({ error: "forbidden" }, { status: 403 }); + const viewer = await getViewer(); + if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); const db = getDb(); - const users = await db.user.findMany({ + if (viewer.role === "admin") { + const users = await db.user.findMany({ + orderBy: { createdAt: "asc" }, + include: { membership: { include: { household: true } } }, + }); + return NextResponse.json( + users.map((u) => ({ + id: u.id, + name: u.name, + email: u.email, + role: u.role, + oidcVerknuepft: u.oidcSubject !== null, + createdAt: u.createdAt, + householdId: u.membership?.householdId ?? null, + householdName: u.membership?.household.name ?? null, + householdRole: u.membership?.role ?? null, + })) + ); + } + if (viewer.householdId === null || viewer.householdRole !== "admin") { + return NextResponse.json({ error: "forbidden" }, { status: 403 }); + } + const members = await db.householdMember.findMany({ + where: { householdId: viewer.householdId }, + include: { user: true }, 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 })) + members.map((m) => ({ + id: m.user.id, + name: m.user.name, + email: m.user.email, + role: m.user.role, + oidcVerknuepft: m.user.oidcSubject !== null, + createdAt: m.user.createdAt, + householdId: viewer.householdId, + householdName: viewer.householdName, + householdRole: m.role, + })) ); } diff --git a/app/api/vouchers/[id]/route.ts b/app/api/vouchers/[id]/route.ts index a7a45c9..ba16243 100644 --- a/app/api/vouchers/[id]/route.ts +++ b/app/api/vouchers/[id]/route.ts @@ -2,64 +2,79 @@ 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"; +import { getViewer } from "@/lib/viewer"; +import { canAccess, contactUsableIn } from "@/lib/access"; + +async function loadVoucher(numId: number) { + const db = getDb(); + const viewer = await getViewer(); + if (!viewer) return { status: 401 as const }; + const v = await db.voucher.findUnique({ + where: { id: numId }, + include: { contacts: { include: { contact: true } }, rules: true, household: true }, + }); + if (!v || !canAccess(viewer, v)) return { status: 404 as const }; + return { status: 200 as const, viewer, v }; +} export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const numId = Number(id); if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 }); - const db = getDb(); - const v = await db.voucher.findUnique({ where: { id: numId }, include: { contacts: { include: { contact: true } }, rules: true } }); - if (!v) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json(await withLogoUrl(v)); + const found = await loadVoucher(numId); + if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status }); + return NextResponse.json(await withLogoUrl(found.v)); } export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const numId = Number(id); if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 }); - const db = getDb(); - const v = await db.voucher.findUnique({ where: { id: numId } }); - if (!v) return NextResponse.json({ error: "not found" }, { status: 404 }); + const found = await loadVoucher(numId); + if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status }); const body = await req.json().catch(() => ({})); if (!("shopUrl" in body)) return NextResponse.json({ errors: ["shopUrl"] }, { status: 400 }); const shop = parseShopUrl(body.shopUrl); if (shop.error) return NextResponse.json({ errors: ["shopUrl"] }, { status: 400 }); + const db = getDb(); const updated = await db.voucher.update({ where: { id: numId }, data: { shopUrl: shop.url } }); return NextResponse.json(await withLogoUrl(updated)); } 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 }); + const found = await loadVoucher(numId); + if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status }); const body = await req.json().catch(() => ({})); if (body.action !== "redeem") return NextResponse.json({ error: "unknown action" }, { status: 400 }); const db = getDb(); - const v = await db.voucher.findUnique({ where: { id: numId } }); - if (!v) return NextResponse.json({ error: "not found" }, { status: 404 }); - const updated = await db.voucher.update({ where: { id: v.id }, data: { status: "eingeloest", restCents: 0 } }); + const updated = await db.voucher.update({ where: { id: numId }, data: { status: "eingeloest", restCents: 0 } }); return NextResponse.json(updated); } 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 }); - const db = getDb(); - const existing = await db.voucher.findUnique({ where: { id: numId } }); - if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 }); + const found = await loadVoucher(numId); + if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status }); + const { viewer } = found; const body = await req.json(); const { fields, errors: parseErrors } = parseVoucherFields(body); const errs = [...parseErrors, ...validateVoucher(fields)]; if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 }); + const db = getDb(); if (fields.contactIds.length) { - const found = await db.contact.findMany({ where: { id: { in: fields.contactIds } }, select: { id: true } }); - if (found.length !== new Set(fields.contactIds).size) { + const contacts = await db.contact.findMany({ where: { id: { in: fields.contactIds } } }); + if (contacts.length !== new Set(fields.contactIds).size) { return NextResponse.json({ errors: ["contactIds"] }, { status: 400 }); } + const householdId = found.v.householdId; + if (contacts.some((c) => !contactUsableIn(viewer, householdId, c))) { + return NextResponse.json({ errors: ["contactIds"] }, { status: 403 }); + } } await db.$transaction([ db.voucherContact.deleteMany({ where: { voucherId: numId } }), @@ -91,13 +106,12 @@ 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 }); + const found = await loadVoucher(numId); + if (found.status !== 200) return NextResponse.json({ error: found.status === 401 ? "unauthorized" : "not found" }, { status: found.status }); const db = getDb(); - const existing = await db.voucher.findUnique({ where: { id: numId } }); - if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 }); await db.voucher.delete({ where: { id: numId } }); return NextResponse.json({ ok: true }); } diff --git a/app/api/vouchers/route.ts b/app/api/vouchers/route.ts index 9e05442..7f5bfe2 100644 --- a/app/api/vouchers/route.ts +++ b/app/api/vouchers/route.ts @@ -3,12 +3,21 @@ 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"; +import { getViewer } from "@/lib/viewer"; +import { canAccess, contactUsableIn, resolveTarget, scopeWhere } from "@/lib/access"; export async function GET(req: NextRequest) { + const viewer = await getViewer(); + if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); const filter = req.nextUrl.searchParams.get("filter"); + const scopeParam = req.nextUrl.searchParams.get("bestand") ?? (viewer.householdId !== null ? "household" : "private"); const db = getDb(); - const all = await db.voucher.findMany({ orderBy: { createdAt: "desc" } }); + const scoped = await db.voucher.findMany({ where: scopeWhere(viewer), orderBy: { createdAt: "desc" } }); + const all = scopeParam === "household" && viewer.householdId !== null + ? scoped.filter((v) => v.householdId === viewer.householdId) + : scopeParam === "private" + ? scoped.filter((v) => v.householdId === null) + : scoped; const filtered = all.filter((v) => { const s = getVoucherStatus(v); if (filter === "eingeloest") return s === "eingeloest"; @@ -21,20 +30,23 @@ 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 viewer = await getViewer(); + if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); const body = await req.json(); + const target = resolveTarget(viewer, body.scope ?? body.ziel); + if (!target.ok) return NextResponse.json({ errors: ["scope"], message: target.error }, { status: 400 }); const { fields, errors: parseErrors } = parseVoucherFields(body); const errs = [...parseErrors, ...validateVoucher(fields)]; if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 }); const db = getDb(); if (fields.contactIds.length) { - const found = await db.contact.findMany({ where: { id: { in: fields.contactIds } }, select: { id: true } }); + const found = await db.contact.findMany({ where: { id: { in: fields.contactIds } } }); if (found.length !== new Set(fields.contactIds).size) { return NextResponse.json({ errors: ["contactIds"] }, { status: 400 }); } + const unusable = found.filter((c) => !contactUsableIn(viewer, target.householdId, c)); + if (unusable.length) return NextResponse.json({ errors: ["contactIds"] }, { status: 403 }); } - const createdBy: string | null = me.name; const created = await db.voucher.create({ data: { name: fields.name.trim(), @@ -49,8 +61,10 @@ export async function POST(req: NextRequest) { kategorie: fields.kategorie, notiz: fields.notiz, providerDomain: fields.providerDomain, - createdBy, + createdBy: viewer.name, shopUrl: fields.shopUrl, + ownerId: viewer.id, + householdId: target.householdId, contacts: fields.contactIds.length ? { create: fields.contactIds.map((contactId) => ({ contactId })) } : undefined, rules: { create: fields.reminderDays.map((tageVorher) => ({ tageVorher })), diff --git a/app/components/overview-toolbar.tsx b/app/components/overview-toolbar.tsx index 6a56975..133f836 100644 --- a/app/components/overview-toolbar.tsx +++ b/app/components/overview-toolbar.tsx @@ -11,6 +11,8 @@ export type OverviewParams = { sort: string; ansicht: string; gruppe: string; + bestand: string; + householdName: string | null; }; const FILTERS = [ @@ -30,6 +32,7 @@ function toUrl(p: OverviewParams) { if (p.sort !== "neu") q.set("sort", p.sort); if (p.ansicht !== "liste") q.set("ansicht", p.ansicht); if (p.gruppe !== "keine") q.set("gruppe", p.gruppe); + if (p.bestand && p.bestand !== "household") q.set("bestand", p.bestand); const s = q.toString(); return s ? `/?${s}` : "/"; } @@ -62,16 +65,42 @@ export default function OverviewToolbar({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [q]); + const bestandDefault = current.householdName !== null ? "household" : "private"; const hasActive = current.filter !== "" || current.q !== "" || current.kat !== "" || current.typ !== "" || current.sort !== "neu" || - current.gruppe !== "keine"; + current.gruppe !== "keine" || + (current.bestand !== "" && current.bestand !== bestandDefault); return (
+ {current.householdName !== null && ( +
+ {( + [ + { key: "household", label: current.householdName }, + { key: "private", label: "Privat" }, + ] as const + ).map((b) => ( + + ))} +
+ )}