From f1cdb3da130545f12757ddd400338dab604fea56 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 14 Aug 2026 12:31:16 +0200 Subject: [PATCH] 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. --- .env.example | 8 + README.md | 81 ++++ .../migration.sql | 124 ++++++ prisma/schema.prisma | 127 +++++- src/app/anmelden/page.tsx | 53 +++ src/app/api/admin/groups/[id]/route.ts | 143 ++++++ src/app/api/admin/groups/route.ts | 90 ++++ src/app/api/admin/types/route.ts | 5 + src/app/api/admin/users/[id]/route.ts | 171 ++++++++ src/app/api/admin/users/route.ts | 107 +++++ src/app/api/auth/route.ts | 189 ++++++++ src/app/api/codes/[id]/qr/route.ts | 6 + src/app/api/codes/[id]/route.ts | 32 +- src/app/api/codes/route.ts | 36 +- src/app/api/qr/download/route.ts | 5 + src/app/api/qr/preview/route.ts | 5 + src/app/erstellen/[type]/page.tsx | 2 + src/app/kein-zugriff/page.tsx | 27 ++ src/app/layout.tsx | 70 ++- src/app/meine-codes/page.tsx | 7 +- src/app/page.tsx | 2 + src/app/verwaltung/benutzer/page.tsx | 45 ++ src/app/verwaltung/gruppen/page.tsx | 42 ++ src/app/verwaltung/page.tsx | 108 +++++ src/app/verwaltung/typen/page.tsx | 2 + src/components/auth-forms.tsx | 196 +++++++++ src/components/group-admin.tsx | 401 +++++++++++++++++ src/components/user-admin.tsx | 407 ++++++++++++++++++ src/lib/auth/actor.ts | 63 +++ src/lib/auth/groups.ts | 93 ++++ src/lib/auth/guard.ts | 34 ++ src/lib/auth/password.ts | 111 +++++ src/lib/auth/policy.ts | 125 ++++++ src/lib/auth/session.ts | 130 ++++++ src/lib/codes.ts | 13 +- src/lib/dto.ts | 5 + src/lib/http.ts | 39 +- src/lib/qr/render.ts | 3 +- src/lib/qr/type-module.ts | 33 +- 39 files changed, 3076 insertions(+), 64 deletions(-) create mode 100644 prisma/migrations/20260814095859_anmeldung_gruppen_rollen/migration.sql create mode 100644 src/app/anmelden/page.tsx create mode 100644 src/app/api/admin/groups/[id]/route.ts create mode 100644 src/app/api/admin/groups/route.ts create mode 100644 src/app/api/admin/users/[id]/route.ts create mode 100644 src/app/api/admin/users/route.ts create mode 100644 src/app/api/auth/route.ts create mode 100644 src/app/kein-zugriff/page.tsx create mode 100644 src/app/verwaltung/benutzer/page.tsx create mode 100644 src/app/verwaltung/gruppen/page.tsx create mode 100644 src/app/verwaltung/page.tsx create mode 100644 src/components/auth-forms.tsx create mode 100644 src/components/group-admin.tsx create mode 100644 src/components/user-admin.tsx create mode 100644 src/lib/auth/actor.ts create mode 100644 src/lib/auth/groups.ts create mode 100644 src/lib/auth/guard.ts create mode 100644 src/lib/auth/password.ts create mode 100644 src/lib/auth/policy.ts create mode 100644 src/lib/auth/session.ts 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 -