diff --git a/.env.example b/.env.example
index 5f8977c..29c65ab 100644
--- a/.env.example
+++ b/.env.example
@@ -13,6 +13,14 @@ BASE_URL=http://localhost:3000
# Host-Port, unter dem die App veröffentlicht wird (Port-Mapping APP_PORT:3000).
APP_PORT=3000
+# Anmeldung mit Benutzern, Gruppen und Rollen.
+# false — jeder, der die App erreicht, darf alles (Einzelplatz/LAN).
+# true — Anmeldung erforderlich. Beim ersten Aufruf legst du den ersten
+# Administrator an.
+# Bewusst hier und nicht in der Oberfläche: so kann man sich nicht aussperren.
+# Wer den Zugang verliert, setzt den Wert zurück auf false und startet neu.
+AUTH_ENABLED=false
+
# --- PostgreSQL ------------------------------------------------------------
POSTGRES_USER=qrcode
POSTGRES_PASSWORD=bitte-aendern
diff --git a/README.md b/README.md
index bb28a95..01ec206 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ ausblenden.
- [Aktualisieren](#aktualisieren)
- [Konfiguration](#konfiguration)
- [Bedienung](#bedienung)
+- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
- [Technischer Aufbau](#technischer-aufbau)
- [Entwicklung ohne Docker](#entwicklung-ohne-docker)
- [API](#api)
@@ -301,6 +302,7 @@ Alle Einstellungen stehen in der `.env` im Projektverzeichnis (Vorlage:
| ------------------- | ------------------------------------------------------------- | ------------------------ |
| `BASE_URL` | Öffentliche Adresse der App — landet in dynamischen QR-Codes | `http://localhost:3000` |
| `APP_PORT` | Host-Port, auf dem die App veröffentlicht wird | `3000` |
+| `AUTH_ENABLED` | Anmeldung mit Benutzern, Gruppen und Rollen | `false` |
| `DATABASE_URL` | Verbindungsstring der App zur Datenbank | — |
| `POSTGRES_USER` | Datenbankbenutzer | `qrcode` |
| `POSTGRES_PASSWORD` | Datenbankpasswort (**muss gesetzt sein**) | — |
@@ -452,6 +454,85 @@ Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden.
---
+## Anmeldung, Gruppen und Rollen
+
+Standardmäßig ist die Anmeldung **aus**: wer die Anwendung erreicht, darf
+alles. Für den Einzelplatz- oder LAN-Betrieb ist das gewollt. Sobald mehrere
+Leute damit arbeiten, lässt sie sich einschalten.
+
+### Einschalten
+
+In der `.env`:
+
+```bash
+AUTH_ENABLED=true
+```
+
+Danach `docker compose up -d`. Beim nächsten Aufruf erscheint die
+**Ersteinrichtung** und du legst den ersten Administrator an. Diese Seite
+schließt sich, sobald es einen Benutzer gibt — danach führt sie zur normalen
+Anmeldung.
+
+> **Aussperren ist nicht möglich.** Der Schalter steht bewusst in der `.env`
+> und nicht in der Oberfläche. Wer den Zugang verliert, setzt
+> `AUTH_ENABLED=false`, startet neu und kommt wieder an alles heran.
+
+Bestehende Codes gehen dabei nicht verloren: die Migration legt eine Gruppe
+**Standard** an und ordnet ihnen diese zu.
+
+### Die vier Ebenen
+
+| Ebene | Darf |
+| --- | --- |
+| **Instanz-Administrator** | alles: Benutzer, Gruppen, QR-Typen und sämtliche Codes |
+| **Administrator** einer Gruppe | alles in seiner Gruppe, einschließlich Mitglieder und Rollen |
+| **Bearbeiter** | Codes der Gruppe anlegen, ändern, löschen |
+| **Betrachter** | Codes der Gruppe ansehen und herunterladen — sonst nichts |
+
+Ein Benutzer kann in mehreren Gruppen sein, mit unterschiedlicher Rolle je
+Gruppe. Er sieht ausschließlich die Codes der Gruppen, in denen er Mitglied
+ist.
+
+| Aktion | Betrachter | Bearbeiter | Admin der Gruppe | Instanz-Admin |
+| --- | :-: | :-: | :-: | :-: |
+| Codes sehen, Bilder herunterladen | ✓ | ✓ | ✓ | ✓ |
+| Statische Codes erzeugen | ✓ | ✓ | ✓ | ✓ |
+| Dynamische Codes anlegen und ändern | – | ✓ | ✓ | ✓ |
+| Codes löschen | – | ✓ | ✓ | ✓ |
+| Mitglieder und Rollen der Gruppe | – | – | ✓ | ✓ |
+| Gruppen und Benutzer anlegen | – | – | – | ✓ |
+| QR-Typen freigeben | – | – | – | ✓ |
+
+### Was unabhängig von der Anmeldung bleibt
+
+Die Weiterleitung unter `/r/{slug}` ist **immer öffentlich** — sonst könnte
+niemand einen gedruckten Code scannen. Sie verrät nur das Ziel, nicht die
+Verwaltung.
+
+### Benutzer verwalten
+
+Unter **Verwaltung → Benutzer**: Konten anlegen, Passwörter setzen, Konten
+deaktivieren oder löschen.
+
+- Passwörter werden mit **scrypt** gespeichert (Node-Standardbibliothek, kein
+ natives Modul — dadurch keine Abhängigkeit, die zur Container-Plattform
+ passen muss).
+- Ein neues Passwort oder das Deaktivieren beendet **alle offenen Sitzungen**
+ des Betroffenen sofort.
+- Nach acht Fehlversuchen ist ein Konto 15 Minuten gesperrt; ein Administrator
+ kann die Sperre vorzeitig aufheben.
+- Der **letzte aktive Administrator** kann sich weder selbst deaktivieren noch
+ sich die Rechte entziehen noch gelöscht werden.
+- Passwörter kann nur ein Administrator setzen. Einen Versand per E-Mail gibt
+ es bewusst nicht — das spart einen SMTP-Zugang.
+
+### Gruppen verwalten
+
+Unter **Verwaltung → Gruppen**: Gruppen anlegen und Mitglieder mit Rollen
+zuweisen. Eine Gruppe, die noch Codes enthält, lässt sich nicht löschen —
+sonst führten gedruckte Codes ins Leere.
+
+---
## Technischer Aufbau
### Stack
diff --git a/prisma/migrations/20260814095859_anmeldung_gruppen_rollen/migration.sql b/prisma/migrations/20260814095859_anmeldung_gruppen_rollen/migration.sql
new file mode 100644
index 0000000..2201357
--- /dev/null
+++ b/prisma/migrations/20260814095859_anmeldung_gruppen_rollen/migration.sql
@@ -0,0 +1,124 @@
+-- Anmeldung, Gruppen und Rollen.
+--
+-- Die Reihenfolge ist wichtig: `QrCode.groupId` ist verpflichtend, es gibt
+-- aber möglicherweise schon Codes. Deshalb wird die Spalte erst ohne
+-- Einschränkung angelegt, dann mit der Standardgruppe gefüllt und erst
+-- danach auf NOT NULL gesetzt. Ohne diesen Zwischenschritt scheitert die
+-- Migration auf jeder Installation, die bereits benutzt wurde.
+
+-- CreateEnum
+CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR', 'VIEWER');
+
+-- CreateTable
+CREATE TABLE "User" (
+ "id" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "name" TEXT,
+ "passwordHash" TEXT NOT NULL,
+ "isInstanceAdmin" BOOLEAN NOT NULL DEFAULT false,
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+ "failedLogins" INTEGER NOT NULL DEFAULT 0,
+ "lockedUntil" TIMESTAMP(3),
+ "lastLoginAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "User_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Session" (
+ "id" TEXT NOT NULL,
+ "tokenHash" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+ "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Group" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Group_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Membership" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "groupId" TEXT NOT NULL,
+ "role" "Role" NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Membership_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+
+-- CreateIndex
+CREATE INDEX "User_email_idx" ON "User"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
+
+-- CreateIndex
+CREATE INDEX "Session_userId_idx" ON "Session"("userId");
+
+-- CreateIndex
+CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Group_name_key" ON "Group"("name");
+
+-- CreateIndex
+CREATE INDEX "Membership_groupId_idx" ON "Membership"("groupId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Membership_userId_groupId_key" ON "Membership"("userId", "groupId");
+
+-- Standardgruppe anlegen. Sie ist auch bei ausgeschalteter Anmeldung die
+-- Heimat aller Codes, damit es keinen Sonderfall "Code ohne Gruppe" gibt.
+INSERT INTO "Group" ("id", "name", "description", "createdAt", "updatedAt")
+VALUES (
+ 'grp_standard',
+ 'Standard',
+ 'Beim ersten Start angelegt. Enthält alle Codes, die vor Einführung der Benutzerverwaltung entstanden sind.',
+ CURRENT_TIMESTAMP,
+ CURRENT_TIMESTAMP
+);
+
+-- AlterTable: erst nullable anlegen …
+ALTER TABLE "QrCode" ADD COLUMN "createdById" TEXT,
+ADD COLUMN "groupId" TEXT;
+
+-- … bestehende Codes der Standardgruppe zuordnen …
+UPDATE "QrCode" SET "groupId" = 'grp_standard' WHERE "groupId" IS NULL;
+
+-- … und erst jetzt verpflichtend machen.
+ALTER TABLE "QrCode" ALTER COLUMN "groupId" SET NOT NULL;
+
+-- CreateIndex
+CREATE INDEX "QrCode_groupId_idx" ON "QrCode"("groupId");
+
+-- AddForeignKey
+ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Membership" ADD CONSTRAINT "Membership_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "QrCode" ADD CONSTRAINT "QrCode_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "QrCode" ADD CONSTRAINT "QrCode_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f7d3dca..ce59739 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -10,6 +10,90 @@ datasource db {
provider = "postgresql"
}
+/// Rolle eines Benutzers innerhalb einer Gruppe.
+///
+/// Darüber liegt noch eine Ebene: `User.isInstanceAdmin`. Sie darf Gruppen
+/// und Benutzer überhaupt erst anlegen — ohne sie gäbe es niemanden, der die
+/// erste Gruppe erstellt.
+enum Role {
+ /// Darf zusätzlich Mitglieder und Rollen der Gruppe verwalten.
+ ADMIN
+ /// Darf Codes anlegen, ändern und löschen.
+ EDITOR
+ /// Darf nur lesen und herunterladen.
+ VIEWER
+}
+
+model User {
+ id String @id @default(cuid())
+ email String @unique
+ name String?
+ /// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
+ passwordHash String
+ /// Darf Gruppen, Benutzer und Domains verwalten.
+ isInstanceAdmin Boolean @default(false)
+ /// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
+ /// werden beim nächsten Zugriff verworfen.
+ isActive Boolean @default(true)
+ failedLogins Int @default(0)
+ lockedUntil DateTime?
+ lastLoginAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ sessions Session[]
+ memberships Membership[]
+ createdCodes QrCode[] @relation("QrCodeCreator")
+
+ @@index([email])
+}
+
+/// Angemeldete Sitzung. In der Datenbank statt in einem signierten Cookie,
+/// damit sie sich serverseitig beenden lässt — etwa wenn ein Benutzer
+/// deaktiviert wird.
+model Session {
+ id String @id @default(cuid())
+ /// SHA-256 des Cookie-Werts. Der Wert selbst steht nirgends in der Datenbank:
+ /// wer sie liest, kann sich damit trotzdem nicht anmelden.
+ tokenHash String @unique
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ expiresAt DateTime
+ lastSeenAt DateTime @default(now())
+ createdAt DateTime @default(now())
+
+ @@index([userId])
+ @@index([expiresAt])
+}
+
+/// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören.
+model Group {
+ id String @id @default(cuid())
+ name String @unique
+ description String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ memberships Membership[]
+ codes QrCode[]
+}
+
+/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
+/// kann in mehreren Gruppen sein — mit unterschiedlicher Rolle je Gruppe.
+model Membership {
+ id String @id @default(cuid())
+ userId String
+ groupId String
+ role Role
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, groupId])
+ @@index([groupId])
+}
+
/// Freigabe eines QR-Typs durch die Verwaltung.
///
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
@@ -26,24 +110,33 @@ model QrTypeSetting {
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
///
-/// `type` + `data` sind bewusst generisch gehalten: V1 kennt nur den Typ
-/// "website" (`data = { url: "https://…" }`), V2-Typen (vCard, WLAN, …)
-/// legen ihr eigenes Payload-Format in `data` ab, ohne Schemaänderung.
+/// `type` + `data` sind bewusst generisch gehalten: die Typ-Module legen ihr
+/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
model QrCode {
- id String @id @default(cuid())
- slug String @unique
- type String
- label String?
- data Json
- fgColor String @default("#000000")
- bgColor String @default("#FFFFFF")
- size Int @default(512)
- ecLevel String @default("M")
- margin Int @default(4)
- scans Int @default(0)
- lastScanAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id @default(cuid())
+ slug String @unique
+ type String
+ label String?
+ data Json
+ fgColor String @default("#000000")
+ bgColor String @default("#FFFFFF")
+ size Int @default(512)
+ ecLevel String @default("M")
+ margin Int @default(4)
+ scans Int @default(0)
+ lastScanAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ /// Jeder Code gehört einer Gruppe — auch bei ausgeschalteter Anmeldung.
+ /// Dann ist es die beim ersten Start angelegte Standardgruppe.
+ groupId String
+ group Group @relation(fields: [groupId], references: [id])
+ /// Wer den Code angelegt hat. Leer für Codes aus der Zeit vor der
+ /// Anmeldung und für Codes, deren Ersteller gelöscht wurde.
+ createdById String?
+ createdBy User? @relation("QrCodeCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@index([createdAt])
+ @@index([groupId])
}
diff --git a/src/app/anmelden/page.tsx b/src/app/anmelden/page.tsx
new file mode 100644
index 0000000..876f845
--- /dev/null
+++ b/src/app/anmelden/page.tsx
@@ -0,0 +1,53 @@
+import type { Metadata } from "next";
+import { redirect } from "next/navigation";
+
+import { LoginForm, SetupForm } from "@/components/auth-forms";
+import { Icon } from "@/components/icons";
+import { Notice } from "@/components/ui";
+import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
+import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
+
+export const metadata: Metadata = { title: "Anmelden" };
+export const dynamic = "force-dynamic";
+
+export default async function SignInPage() {
+ // Ohne Anmeldung gibt es hier nichts zu tun.
+ if (!isAuthEnabled()) redirect("/");
+
+ const actor = await getActor();
+ if (actor.kind === "user") redirect("/");
+
+ const setup = await isSetupOpen();
+
+ return (
+
+
+
+
+
+
+ {setup ? "Ersteinrichtung" : "Anmelden"}
+
+
+ {setup
+ ? "Es gibt noch keinen Benutzer. Lege jetzt den ersten Administrator an — danach ist diese Seite geschlossen."
+ : "Bitte melde dich an, um QR-Codes zu erstellen und zu verwalten."}
+
+
+
+ {setup ? : }
+
+ {setup ? (
+
+ Der erste Benutzer wird Administrator der Installation und darf
+ Gruppen, Benutzer und die QR-Typen verwalten.
+
+ ) : (
+
+ Passwort vergessen? Ein Administrator kann es unter Verwaltung →
+ Benutzer zurücksetzen.
+
+ )}
+
+ );
+}
diff --git a/src/app/api/admin/groups/[id]/route.ts b/src/app/api/admin/groups/[id]/route.ts
new file mode 100644
index 0000000..69414d9
--- /dev/null
+++ b/src/app/api/admin/groups/[id]/route.ts
@@ -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 {
+ 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 {
+ 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);
+ }
+}
diff --git a/src/app/api/admin/groups/route.ts b/src/app/api/admin/groups/route.ts
new file mode 100644
index 0000000..e6181c6
--- /dev/null
+++ b/src/app/api/admin/groups/route.ts
@@ -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 {
+ 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 {
+ 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);
+ }
+}
diff --git a/src/app/api/admin/types/route.ts b/src/app/api/admin/types/route.ts
index b640208..ed54ec7 100644
--- a/src/app/api/admin/types/route.ts
+++ b/src/app/api/admin/types/route.ts
@@ -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 {
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 {
/** Einen Typ freigeben oder abschalten. */
export async function PATCH(request: NextRequest): Promise {
try {
+ requirePermission(await getActor(), "instance:manage");
+
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts
new file mode 100644
index 0000000..89af8de
--- /dev/null
+++ b/src/app/api/admin/users/[id]/route.ts
@@ -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 {
+ 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 {
+ 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);
+ }
+}
diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts
new file mode 100644
index 0000000..097ce7f
--- /dev/null
+++ b/src/app/api/admin/users/route.ts
@@ -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 {
+ 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 {
+ 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);
+ }
+}
diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts
new file mode 100644
index 0000000..99987b6
--- /dev/null
+++ b/src/app/api/auth/route.ts
@@ -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 {
+ 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 {
+ 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,
+): Promise {
+ 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,
+): Promise {
+ 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 });
+}
diff --git a/src/app/api/codes/[id]/qr/route.ts b/src/app/api/codes/[id]/qr/route.ts
index e3806fa..df549f8 100644
--- a/src/app/api/codes/[id]/qr/route.ts
+++ b/src/app/api/codes/[id]/qr/route.ts
@@ -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,
diff --git a/src/app/api/codes/[id]/route.ts b/src/app/api/codes/[id]/route.ts
index 1f22bc8..247b2fe 100644
--- a/src/app/api/codes/[id]/route.ts
+++ b/src/app/api/codes/[id]/route.ts
@@ -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 {
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 {
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 {
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) {
diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts
index 7a88a4c..d2b2445 100644
--- a/src/app/api/codes/route.ts
+++ b/src/app/api/codes/route.ts
@@ -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 {
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 {
/** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */
export async function POST(request: NextRequest): Promise {
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 {
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) {
diff --git a/src/app/api/qr/download/route.ts b/src/app/api/qr/download/route.ts
index 8fb8cb5..ff2f44a 100644
--- a/src/app/api/qr/download/route.ts
+++ b/src/app/api/qr/download/route.ts
@@ -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 {
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));
diff --git a/src/app/api/qr/preview/route.ts b/src/app/api/qr/preview/route.ts
index 16f4993..5b8f24c 100644
--- a/src/app/api/qr/preview/route.ts
+++ b/src/app/api/qr/preview/route.ts
@@ -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 {
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));
diff --git a/src/app/erstellen/[type]/page.tsx b/src/app/erstellen/[type]/page.tsx
index 02c0665..ef614a8 100644
--- a/src/app/erstellen/[type]/page.tsx
+++ b/src/app/erstellen/[type]/page.tsx
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator";
+import { requirePageActor } from "@/lib/auth/guard";
import { getQrTypeState } from "@/lib/qr/availability";
import { getQrType } from "@/lib/qr/registry";
@@ -46,6 +47,7 @@ function Hinweis({
}
export default async function CreatePage({ params }: Props) {
+ await requirePageActor();
const { type } = await params;
const state = await getQrTypeState(type);
diff --git a/src/app/kein-zugriff/page.tsx b/src/app/kein-zugriff/page.tsx
new file mode 100644
index 0000000..61b40ed
--- /dev/null
+++ b/src/app/kein-zugriff/page.tsx
@@ -0,0 +1,27 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+
+import { Icon } from "@/components/icons";
+
+export const metadata: Metadata = { title: "Kein Zugriff" };
+
+export default function NoAccessPage() {
+ return (
+
+
+
+
+
Kein Zugriff
+
+ Für diesen Bereich fehlt dir die nötige Berechtigung. Wenn du meinst,
+ dass das ein Versehen ist, wende dich an einen Administrator.
+
+
+ Zur Startseite
+
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 0954bf1..a9533a7 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import Link from "next/link";
+import { LogoutButton } from "@/components/auth-forms";
import { Icon } from "@/components/icons";
+import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
+import { can } from "@/lib/auth/policy";
import "./globals.css";
export const metadata: Metadata = {
@@ -13,7 +16,18 @@ export const metadata: Metadata = {
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
};
-export default function RootLayout({ children }: LayoutProps<"/">) {
+// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
+// werden.
+export const dynamic = "force-dynamic";
+
+const NAV_LINK =
+ "rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900";
+
+export default async function RootLayout({ children }: LayoutProps<"/">) {
+ const actor = await getActor();
+ const signedIn = actor.kind !== "anonymous";
+ const showAdmin = can(actor, "instance:manage");
+
return (
@@ -30,25 +44,41 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
Navigation nicht mehr in eine Zeile. */}
QR-Code-Generator
-