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);
}
}
+5
View File
@@ -1,6 +1,8 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import {
getQrTypeAvailability,
@@ -18,6 +20,7 @@ const updateSchema = z.object({
/** Zustand aller QR-Typen für die Verwaltung. */
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const types = await getQrTypeAvailability();
return Response.json({
types: types.map(({ qrType, implemented, enabled, usable }) => ({
@@ -38,6 +41,8 @@ export async function GET(): Promise<Response> {
/** Einen Typ freigeben oder abschalten. */
export async function PATCH(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
+171
View File
@@ -0,0 +1,171 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MIN_PASSWORD_LENGTH,
} from "@/lib/auth/password";
import { requirePermission } from "@/lib/auth/policy";
import { destroyAllSessions } from "@/lib/auth/session";
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().max(120).nullable().optional(),
isActive: z.boolean().optional(),
isInstanceAdmin: z.boolean().optional(),
password: z
.string()
.min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
})
.optional(),
/** Sperre nach Fehlversuchen aufheben. */
unlock: z.boolean().optional(),
});
/**
* Verhindert, dass sich die Installation selbst aussperrt: es muss immer
* mindestens ein aktiver Administrator übrig bleiben.
*/
async function assertNotLastAdmin(userId: string, action: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { isInstanceAdmin: true, isActive: true },
});
if (!user?.isInstanceAdmin || !user.isActive) return;
const others = await prisma.user.count({
where: { isInstanceAdmin: true, isActive: true, id: { not: userId } },
});
if (others === 0) {
throw new QrRequestError(
`Das ist der letzte aktive Administrator — ${action} würde die Installation unbedienbar machen. Lege zuerst einen weiteren Administrator an.`,
);
}
}
export async function PATCH(
request: NextRequest,
context: Context,
): Promise<Response> {
try {
const actor = await getActor();
requirePermission(actor, "instance:manage");
const { id } = await context.params;
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) {
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
}
const data: {
name?: string | null;
isActive?: boolean;
isInstanceAdmin?: boolean;
passwordHash?: string;
failedLogins?: number;
lockedUntil?: Date | null;
} = {};
if (parsed.data.name !== undefined) {
data.name = parsed.data.name || null;
}
if (parsed.data.isActive === false) {
await assertNotLastAdmin(id, "das Deaktivieren");
data.isActive = false;
} else if (parsed.data.isActive === true) {
data.isActive = true;
}
if (parsed.data.isInstanceAdmin === false) {
await assertNotLastAdmin(id, "der Entzug der Administratorrechte");
data.isInstanceAdmin = false;
} else if (parsed.data.isInstanceAdmin === true) {
data.isInstanceAdmin = true;
}
if (parsed.data.password) {
data.passwordHash = await hashPassword(parsed.data.password);
data.failedLogins = 0;
data.lockedUntil = null;
}
if (parsed.data.unlock) {
data.failedLogins = 0;
data.lockedUntil = null;
}
if (Object.keys(data).length === 0) {
return jsonError("Es wurden keine Änderungen übermittelt.");
}
const updated = await prisma.user.update({ where: { id }, data });
// Deaktivieren oder ein neues Passwort beenden laufende Sitzungen —
// sonst bliebe der Betroffene angemeldet.
if (data.isActive === false || data.passwordHash) {
await destroyAllSessions(id);
}
return Response.json({
user: {
id: updated.id,
email: updated.email,
isActive: updated.isActive,
isInstanceAdmin: updated.isInstanceAdmin,
},
});
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(
_request: NextRequest,
context: Context,
): Promise<Response> {
try {
const actor = await getActor();
requirePermission(actor, "instance:manage");
const { id } = await context.params;
if (actor.kind === "user" && actor.user.id === id) {
throw new QrRequestError(
"Du kannst dein eigenes Konto nicht löschen.",
);
}
const target = await prisma.user.findUnique({
where: { id },
select: { id: true },
});
if (!target) {
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
}
await assertNotLastAdmin(id, "das Löschen");
// Die Codes des Benutzers bleiben bestehen; nur der Ersteller entfällt
// (onDelete: SetNull). Gedruckte Codes dürfen nicht daran hängen, wer
// sie einmal angelegt hat.
await prisma.user.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}
+107
View File
@@ -0,0 +1,107 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MIN_PASSWORD_LENGTH,
} from "@/lib/auth/password";
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";
const createSchema = z.object({
email: z
.string()
.trim()
.toLowerCase()
.min(1, { error: "Bitte gib eine E-Mail-Adresse ein." })
.max(254, { error: "Die Adresse ist zu lang." })
.refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), {
error: "Das ist keine gültige E-Mail-Adresse.",
}),
name: z.string().trim().max(120).optional(),
password: z.string().min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
}),
isInstanceAdmin: z.boolean().default(false),
});
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const users = await prisma.user.findMany({
orderBy: [{ isInstanceAdmin: "desc" }, { email: "asc" }],
include: {
memberships: { include: { group: { select: { id: true, name: true } } } },
_count: { select: { sessions: true } },
},
});
return Response.json({
users: users.map((user) => ({
id: user.id,
email: user.email,
name: user.name,
isInstanceAdmin: user.isInstanceAdmin,
isActive: user.isActive,
isLocked: Boolean(
user.lockedUntil && user.lockedUntil.getTime() > Date.now(),
),
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
createdAt: user.createdAt.toISOString(),
activeSessions: user._count.sessions,
memberships: user.memberships.map((m) => ({
groupId: m.group.id,
groupName: m.group.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.user.findUnique({
where: { email: parsed.data.email },
select: { id: true },
});
if (existing) {
throw new QrRequestError(
"Diese E-Mail-Adresse ist bereits vergeben.",
409,
);
}
const user = await prisma.user.create({
data: {
email: parsed.data.email,
name: parsed.data.name || null,
passwordHash: await hashPassword(parsed.data.password),
isInstanceAdmin: parsed.data.isInstanceAdmin,
},
});
return Response.json(
{ user: { id: user.id, email: user.email } },
{ status: 201 },
);
} catch (error) {
return errorResponse(error);
}
}
+189
View File
@@ -0,0 +1,189 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MIN_PASSWORD_LENGTH,
verifyPassword,
} from "@/lib/auth/password";
import { createSession, destroySession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
/** Sperrdauer nach zu vielen Fehlversuchen. */
const MAX_ATTEMPTS = 8;
const LOCK_MINUTES = 15;
const emailSchema = z
.string()
.trim()
.toLowerCase()
.min(1, { error: "Bitte gib deine E-Mail-Adresse ein." })
.max(254, { error: "Die Adresse ist zu lang." })
.refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), {
error: "Das ist keine gültige E-Mail-Adresse.",
});
const loginSchema = z.object({
action: z.literal("login"),
email: emailSchema,
password: z.string().min(1, { error: "Bitte gib dein Passwort ein." }),
});
const setupSchema = z.object({
action: z.literal("setup"),
email: emailSchema,
name: z.string().trim().max(120).optional(),
password: z.string().min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
}),
});
const logoutSchema = z.object({ action: z.literal("logout") });
const bodySchema = z.discriminatedUnion("action", [
loginSchema,
setupSchema,
logoutSchema,
]);
/** Wer ist angemeldet? Von der Oberfläche für den Kopfbereich genutzt. */
export async function GET(): Promise<Response> {
try {
const actor = await getActor();
return Response.json({
authEnabled: isAuthEnabled(),
setupOpen: await isSetupOpen(),
user:
actor.kind === "user"
? {
id: actor.user.id,
email: actor.user.email,
name: actor.user.name,
isInstanceAdmin: actor.user.isInstanceAdmin,
memberships: actor.user.memberships,
}
: null,
});
} catch (error) {
return errorResponse(error);
}
}
export async function POST(request: NextRequest): Promise<Response> {
try {
const parsed = bodySchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
if (parsed.data.action === "logout") {
await destroySession();
return Response.json({ ok: true });
}
if (!isAuthEnabled()) {
throw new QrRequestError(
"Die Anmeldung ist ausgeschaltet. Setze AUTH_ENABLED=true in der .env, um sie zu aktivieren.",
);
}
// Das `await` ist hier nicht optional: ohne es verlässt das Promise den
// try-Block, bevor es sich auflöst — eine Ablehnung käme dann nie im
// catch an und würde als nackter 500er beim Aufrufer landen.
if (parsed.data.action === "setup") {
return await handleSetup(parsed.data);
}
return await handleLogin(parsed.data);
} catch (error) {
return errorResponse(error);
}
}
/** Legt den ersten Benutzer an — nur solange es noch keinen gibt. */
async function handleSetup(
input: z.infer<typeof setupSchema>,
): Promise<Response> {
if (!(await isSetupOpen())) {
throw new QrRequestError(
"Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.",
403,
);
}
const user = await prisma.user.create({
data: {
email: input.email,
name: input.name && input.name.length > 0 ? input.name : null,
passwordHash: await hashPassword(input.password),
isInstanceAdmin: true,
},
});
await createSession(user.id);
return Response.json({ ok: true, email: user.email }, { status: 201 });
}
async function handleLogin(
input: z.infer<typeof loginSchema>,
): Promise<Response> {
const user = await prisma.user.findUnique({ where: { email: input.email } });
// Immer dieselbe Meldung, egal ob die Adresse existiert — sonst ließe sich
// damit herausfinden, welche Konten es gibt.
const invalid = new QrRequestError(
"E-Mail-Adresse oder Passwort stimmt nicht.",
401,
);
if (!user) {
// Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät.
await verifyPassword(input.password, "scrypt$32768$8$1$AAAA$AAAA");
throw invalid;
}
if (!user.isActive) {
throw new QrRequestError(
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
403,
);
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
const minutes = Math.ceil(
(user.lockedUntil.getTime() - Date.now()) / 60000,
);
throw new QrRequestError(
`Zu viele Fehlversuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.`,
429,
);
}
if (!(await verifyPassword(input.password, user.passwordHash))) {
const failedLogins = user.failedLogins + 1;
await prisma.user.update({
where: { id: user.id },
data: {
failedLogins,
lockedUntil:
failedLogins >= MAX_ATTEMPTS
? new Date(Date.now() + LOCK_MINUTES * 60 * 1000)
: null,
},
});
throw invalid;
}
await prisma.user.update({
where: { id: user.id },
data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date() },
});
await createSession(user.id);
return Response.json({ ok: true, email: user.email });
}
+6
View File
@@ -1,5 +1,7 @@
import type { NextRequest } from "next/server";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { shortUrl } from "@/lib/env";
import { errorResponse } from "@/lib/http";
@@ -32,6 +34,10 @@ export async function GET(
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
}
// Das Bild verrät die Kurz-URL und damit das Ziel — es gehört hinter
// dieselbe Prüfung wie die Liste.
requirePermission(await getActor(), "codes:read", { groupId: code.groupId });
const design = designSchema.parse({
fgColor: code.fgColor,
bgColor: code.bgColor,
+28 -4
View File
@@ -2,6 +2,8 @@ import type { NextRequest } from "next/server";
import { z } from "zod";
import type { Prisma } from "@/generated/prisma/client";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
@@ -14,6 +16,11 @@ export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
const WITH_RELATIONS = {
group: { select: { name: true } },
createdBy: { select: { name: true, email: true } },
} as const;
const updateSchema = z.object({
data: z.record(z.string(), z.unknown()).optional(),
design: designSchema.optional(),
@@ -26,7 +33,10 @@ const updateSchema = z.object({
});
async function loadCode(id: string) {
const code = await prisma.qrCode.findUnique({ where: { id } });
const code = await prisma.qrCode.findUnique({
where: { id },
include: WITH_RELATIONS,
});
if (!code) {
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
}
@@ -39,7 +49,10 @@ export async function GET(
): Promise<Response> {
try {
const { id } = await context.params;
return Response.json({ code: toQrCodeDto(await loadCode(id)) });
const actor = await getActor();
const code = await loadCode(id);
requirePermission(actor, "codes:read", { groupId: code.groupId });
return Response.json({ code: toQrCodeDto(code) });
} catch (error) {
return errorResponse(error);
}
@@ -52,12 +65,16 @@ export async function PATCH(
): Promise<Response> {
try {
const { id } = await context.params;
const actor = await getActor();
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const existing = await loadCode(id);
requirePermission(actor, "codes:update", { groupId: existing.groupId });
const update: Prisma.QrCodeUpdateInput = {};
if (parsed.data.data !== undefined) {
@@ -91,7 +108,11 @@ export async function PATCH(
return jsonError("Es wurden keine Änderungen übermittelt.");
}
const updated = await prisma.qrCode.update({ where: { id }, data: update });
const updated = await prisma.qrCode.update({
where: { id },
data: update,
include: WITH_RELATIONS,
});
return Response.json({ code: toQrCodeDto(updated) });
} catch (error) {
return errorResponse(error);
@@ -104,7 +125,10 @@ export async function DELETE(
): Promise<Response> {
try {
const { id } = await context.params;
await loadCode(id);
const actor = await getActor();
const existing = await loadCode(id);
requirePermission(actor, "codes:delete", { groupId: existing.groupId });
await prisma.qrCode.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {
+34 -2
View File
@@ -1,6 +1,9 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
@@ -21,6 +24,8 @@ const createSchema = z.object({
.trim()
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.optional(),
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
groupId: z.string().trim().min(1).optional(),
});
function isUniqueViolation(error: unknown): boolean {
@@ -32,10 +37,24 @@ function isUniqueViolation(error: unknown): boolean {
);
}
/** Alle dynamischen Codes, neueste zuerst. */
/** Die dynamischen Codes, die der Anfragende sehen darf — neueste zuerst. */
export async function GET(): Promise<Response> {
try {
const codes = await prisma.qrCode.findMany({ orderBy: { createdAt: "desc" } });
const actor = await getActor();
if (actor.kind === "anonymous") {
throw new QrRequestError("Bitte melde dich an.", 401);
}
const visible = visibleGroupIds(actor);
const codes = await prisma.qrCode.findMany({
where: visible === null ? undefined : { groupId: { in: visible } },
orderBy: { createdAt: "desc" },
include: {
group: { select: { name: true } },
createdBy: { select: { name: true, email: true } },
},
});
return Response.json({ codes: codes.map(toQrCodeDto) });
} catch (error) {
return errorResponse(error);
@@ -45,12 +64,19 @@ export async function GET(): Promise<Response> {
/** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */
export async function POST(request: NextRequest): Promise<Response> {
try {
const actor = await getActor();
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const { type, design, label } = parsed.data;
// Zielgruppe zuerst: davon hängt ab, ob überhaupt angelegt werden darf.
const groupId = await resolveTargetGroup(actor, parsed.data.groupId);
requirePermission(actor, "codes:create", { groupId });
const qrType = await requireAvailableQrType(type);
const validated = qrType.validate(parsed.data.data);
@@ -70,8 +96,14 @@ export async function POST(request: NextRequest): Promise<Response> {
type: qrType.id,
label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data),
groupId,
createdById: actor.kind === "user" ? actor.user.id : null,
...design,
},
include: {
group: { select: { name: true } },
createdBy: { select: { name: true, email: true } },
},
});
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} catch (error) {
+5
View File
@@ -1,6 +1,7 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import {
@@ -17,6 +18,10 @@ const downloadSchema = qrRequestSchema.extend({
/** Liefert den fertigen QR-Code als PNG- oder SVG-Datei zum Herunterladen. */
export async function POST(request: NextRequest): Promise<Response> {
try {
// Der Generator steht nur Angemeldeten offen; die Weiterleitung
// unter /r/{slug} bleibt davon unberührt und öffentlich.
requireSignedIn(await getActor());
const parsed = downloadSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
+5
View File
@@ -1,5 +1,6 @@
import type { NextRequest } from "next/server";
import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { renderSvg } from "@/lib/qr/render";
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
@@ -10,6 +11,10 @@ import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
*/
export async function POST(request: NextRequest): Promise<Response> {
try {
// Der Generator steht nur Angemeldeten offen; die Weiterleitung
// unter /r/{slug} bleibt davon unberührt und öffentlich.
requireSignedIn(await getActor());
const parsed = qrRequestSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));