Phase B1: Anmeldung, Benutzer, Gruppen und Rollen

Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.

Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().

- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
  Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
  bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
  scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
  Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
  wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
  offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
  entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
  nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
  gedruckter Code mehr scannen.

Zwei Fehler dabei gefunden und behoben:

- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
  zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
  wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
  aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
  Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
  Erkennung laeuft jetzt ueber eine Eigenschaft.

Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
This commit is contained in:
Florian Wilde
2026-08-14 12:31:16 +02:00
parent 94a373ff48
commit f1cdb3da13
39 changed files with 3076 additions and 64 deletions
+143
View File
@@ -0,0 +1,143 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { DEFAULT_GROUP_ID } from "@/lib/auth/groups";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
const updateSchema = z.object({
name: z.string().trim().min(1).max(80).optional(),
description: z.string().trim().max(300).nullable().optional(),
/**
* Vollständige Mitgliederliste. Wer fehlt, verliert die Mitgliedschaft —
* so bleibt der Zustand nach dem Speichern eindeutig.
*/
members: z
.array(
z.object({
userId: z.string().trim().min(1),
role: z.enum(["ADMIN", "EDITOR", "VIEWER"], {
error: "Ungültige Rolle.",
}),
}),
)
.optional(),
});
export async function PATCH(
request: NextRequest,
context: Context,
): Promise<Response> {
try {
const actor = await getActor();
const { id } = await context.params;
const group = await prisma.group.findUnique({ where: { id } });
if (!group) {
throw new QrRequestError("Diese Gruppe existiert nicht (mehr).", 404);
}
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
// Name und Beschreibung sind Sache der Instanz-Verwaltung, Mitglieder
// darf auch ein Administrator der Gruppe selbst pflegen.
if (parsed.data.name !== undefined || parsed.data.description !== undefined) {
requirePermission(actor, "instance:manage");
}
if (parsed.data.members !== undefined) {
requirePermission(actor, "group:manage", { groupId: id });
}
if (parsed.data.name !== undefined || parsed.data.description !== undefined) {
await prisma.group.update({
where: { id },
data: {
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
...(parsed.data.description !== undefined
? { description: parsed.data.description || null }
: {}),
},
});
}
if (parsed.data.members !== undefined) {
const members = parsed.data.members;
const known = await prisma.user.findMany({
where: { id: { in: members.map((m) => m.userId) } },
select: { id: true },
});
if (known.length !== members.length) {
throw new QrRequestError(
"Mindestens einer der ausgewählten Benutzer existiert nicht mehr.",
);
}
// In einer Transaktion, damit die Gruppe nie ohne Mitglieder dasteht,
// weil das Setzen der neuen Liste fehlgeschlagen ist.
await prisma.$transaction([
prisma.membership.deleteMany({
where: { groupId: id, userId: { notIn: members.map((m) => m.userId) } },
}),
...members.map((member) =>
prisma.membership.upsert({
where: {
userId_groupId: { userId: member.userId, groupId: id },
},
create: { userId: member.userId, groupId: id, role: member.role },
update: { role: member.role },
}),
),
]);
}
return Response.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(
_request: NextRequest,
context: Context,
): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const { id } = await context.params;
if (id === DEFAULT_GROUP_ID) {
throw new QrRequestError(
"Die Standardgruppe lässt sich nicht löschen — ohne sie hätten Codes bei ausgeschalteter Anmeldung keine Heimat.",
);
}
const group = await prisma.group.findUnique({
where: { id },
include: { _count: { select: { codes: true } } },
});
if (!group) {
throw new QrRequestError("Diese Gruppe existiert nicht (mehr).", 404);
}
if (group._count.codes > 0) {
throw new QrRequestError(
`Diese Gruppe enthält noch ${group._count.codes} ${group._count.codes === 1 ? "Code" : "Codes"}. Lösche oder verschiebe sie zuerst — sonst würden gedruckte Codes ins Leere führen.`,
);
}
await prisma.group.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { can, requirePermission, visibleGroupIds } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
const createSchema = z.object({
name: z
.string()
.trim()
.min(1, { error: "Bitte gib einen Namen für die Gruppe ein." })
.max(80, { error: "Der Name darf höchstens 80 Zeichen lang sein." }),
description: z.string().trim().max(300).optional(),
});
/** Gruppen samt Mitgliedern — gefiltert nach dem, was der Actor sehen darf. */
export async function GET(): Promise<Response> {
try {
const actor = await getActor();
if (actor.kind === "anonymous") {
throw new QrRequestError("Bitte melde dich an.", 401);
}
const visible = visibleGroupIds(actor);
const groups = await prisma.group.findMany({
where: visible === null ? undefined : { id: { in: visible } },
orderBy: { name: "asc" },
include: {
memberships: {
include: { user: { select: { id: true, email: true, name: true } } },
orderBy: { createdAt: "asc" },
},
_count: { select: { codes: true } },
},
});
return Response.json({
groups: groups.map((group) => ({
id: group.id,
name: group.name,
description: group.description,
codeCount: group._count.codes,
canManage: can(actor, "group:manage", { groupId: group.id }),
members: group.memberships.map((m) => ({
userId: m.user.id,
email: m.user.email,
name: m.user.name,
role: m.role,
})),
})),
});
} catch (error) {
return errorResponse(error);
}
}
export async function POST(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const existing = await prisma.group.findUnique({
where: { name: parsed.data.name },
select: { id: true },
});
if (existing) {
throw new QrRequestError("Eine Gruppe mit diesem Namen gibt es schon.", 409);
}
const group = await prisma.group.create({
data: {
name: parsed.data.name,
description: parsed.data.description || null,
},
});
return Response.json({ group: { id: group.id, name: group.name } }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}