Phase B1: Anmeldung, Benutzer, Gruppen und Rollen

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

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

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

Zwei Fehler dabei gefunden und behoben:

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

Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
This commit is contained in:
Florian Wilde
2026-08-14 12:31:16 +02:00
parent 94a373ff48
commit f1cdb3da13
39 changed files with 3076 additions and 64 deletions
+8
View File
@@ -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
+81
View File
@@ -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
@@ -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;
+110 -17
View File
@@ -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])
}
+53
View File
@@ -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 (
<div className="mx-auto flex max-w-md flex-col gap-6 px-4 py-14 sm:px-6">
<div className="text-center">
<span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-indigo-600 text-white">
<Icon name={setup ? "lock" : "qr"} className="size-6" />
</span>
<h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900">
{setup ? "Ersteinrichtung" : "Anmelden"}
</h1>
<p className="mt-2 text-sm text-slate-600">
{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."}
</p>
</div>
{setup ? <SetupForm minLength={MIN_PASSWORD_LENGTH} /> : <LoginForm />}
{setup ? (
<Notice tone="info">
Der erste Benutzer wird Administrator der Installation und darf
Gruppen, Benutzer und die QR-Typen verwalten.
</Notice>
) : (
<p className="text-center text-sm text-slate-500">
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
Benutzer zurücksetzen.
</p>
)}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { DEFAULT_GROUP_ID } from "@/lib/auth/groups";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
const updateSchema = z.object({
name: z.string().trim().min(1).max(80).optional(),
description: z.string().trim().max(300).nullable().optional(),
/**
* Vollständige Mitgliederliste. Wer fehlt, verliert die Mitgliedschaft —
* so bleibt der Zustand nach dem Speichern eindeutig.
*/
members: z
.array(
z.object({
userId: z.string().trim().min(1),
role: z.enum(["ADMIN", "EDITOR", "VIEWER"], {
error: "Ungültige Rolle.",
}),
}),
)
.optional(),
});
export async function PATCH(
request: NextRequest,
context: Context,
): Promise<Response> {
try {
const actor = await getActor();
const { id } = await context.params;
const group = await prisma.group.findUnique({ where: { id } });
if (!group) {
throw new QrRequestError("Diese Gruppe existiert nicht (mehr).", 404);
}
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
// Name und Beschreibung sind Sache der Instanz-Verwaltung, Mitglieder
// darf auch ein Administrator der Gruppe selbst pflegen.
if (parsed.data.name !== undefined || parsed.data.description !== undefined) {
requirePermission(actor, "instance:manage");
}
if (parsed.data.members !== undefined) {
requirePermission(actor, "group:manage", { groupId: id });
}
if (parsed.data.name !== undefined || parsed.data.description !== undefined) {
await prisma.group.update({
where: { id },
data: {
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
...(parsed.data.description !== undefined
? { description: parsed.data.description || null }
: {}),
},
});
}
if (parsed.data.members !== undefined) {
const members = parsed.data.members;
const known = await prisma.user.findMany({
where: { id: { in: members.map((m) => m.userId) } },
select: { id: true },
});
if (known.length !== members.length) {
throw new QrRequestError(
"Mindestens einer der ausgewählten Benutzer existiert nicht mehr.",
);
}
// In einer Transaktion, damit die Gruppe nie ohne Mitglieder dasteht,
// weil das Setzen der neuen Liste fehlgeschlagen ist.
await prisma.$transaction([
prisma.membership.deleteMany({
where: { groupId: id, userId: { notIn: members.map((m) => m.userId) } },
}),
...members.map((member) =>
prisma.membership.upsert({
where: {
userId_groupId: { userId: member.userId, groupId: id },
},
create: { userId: member.userId, groupId: id, role: member.role },
update: { role: member.role },
}),
),
]);
}
return Response.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(
_request: NextRequest,
context: Context,
): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const { id } = await context.params;
if (id === DEFAULT_GROUP_ID) {
throw new QrRequestError(
"Die Standardgruppe lässt sich nicht löschen — ohne sie hätten Codes bei ausgeschalteter Anmeldung keine Heimat.",
);
}
const group = await prisma.group.findUnique({
where: { id },
include: { _count: { select: { codes: true } } },
});
if (!group) {
throw new QrRequestError("Diese Gruppe existiert nicht (mehr).", 404);
}
if (group._count.codes > 0) {
throw new QrRequestError(
`Diese Gruppe enthält noch ${group._count.codes} ${group._count.codes === 1 ? "Code" : "Codes"}. Lösche oder verschiebe sie zuerst — sonst würden gedruckte Codes ins Leere führen.`,
);
}
await prisma.group.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { can, requirePermission, visibleGroupIds } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
const createSchema = z.object({
name: z
.string()
.trim()
.min(1, { error: "Bitte gib einen Namen für die Gruppe ein." })
.max(80, { error: "Der Name darf höchstens 80 Zeichen lang sein." }),
description: z.string().trim().max(300).optional(),
});
/** Gruppen samt Mitgliedern — gefiltert nach dem, was der Actor sehen darf. */
export async function GET(): Promise<Response> {
try {
const actor = await getActor();
if (actor.kind === "anonymous") {
throw new QrRequestError("Bitte melde dich an.", 401);
}
const visible = visibleGroupIds(actor);
const groups = await prisma.group.findMany({
where: visible === null ? undefined : { id: { in: visible } },
orderBy: { name: "asc" },
include: {
memberships: {
include: { user: { select: { id: true, email: true, name: true } } },
orderBy: { createdAt: "asc" },
},
_count: { select: { codes: true } },
},
});
return Response.json({
groups: groups.map((group) => ({
id: group.id,
name: group.name,
description: group.description,
codeCount: group._count.codes,
canManage: can(actor, "group:manage", { groupId: group.id }),
members: group.memberships.map((m) => ({
userId: m.user.id,
email: m.user.email,
name: m.user.name,
role: m.role,
})),
})),
});
} catch (error) {
return errorResponse(error);
}
}
export async function POST(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const existing = await prisma.group.findUnique({
where: { name: parsed.data.name },
select: { id: true },
});
if (existing) {
throw new QrRequestError("Eine Gruppe mit diesem Namen gibt es schon.", 409);
}
const group = await prisma.group.create({
data: {
name: parsed.data.name,
description: parsed.data.description || null,
},
});
return Response.json({ group: { id: group.id, name: group.name } }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}
+5
View File
@@ -1,6 +1,8 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import {
getQrTypeAvailability,
@@ -18,6 +20,7 @@ const updateSchema = z.object({
/** Zustand aller QR-Typen für die Verwaltung. */
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const types = await getQrTypeAvailability();
return Response.json({
types: types.map(({ qrType, implemented, enabled, usable }) => ({
@@ -38,6 +41,8 @@ export async function GET(): Promise<Response> {
/** Einen Typ freigeben oder abschalten. */
export async function PATCH(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
+171
View File
@@ -0,0 +1,171 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MIN_PASSWORD_LENGTH,
} from "@/lib/auth/password";
import { requirePermission } from "@/lib/auth/policy";
import { destroyAllSessions } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
const updateSchema = z.object({
name: z.string().trim().max(120).nullable().optional(),
isActive: z.boolean().optional(),
isInstanceAdmin: z.boolean().optional(),
password: z
.string()
.min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
})
.optional(),
/** Sperre nach Fehlversuchen aufheben. */
unlock: z.boolean().optional(),
});
/**
* Verhindert, dass sich die Installation selbst aussperrt: es muss immer
* mindestens ein aktiver Administrator übrig bleiben.
*/
async function assertNotLastAdmin(userId: string, action: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { isInstanceAdmin: true, isActive: true },
});
if (!user?.isInstanceAdmin || !user.isActive) return;
const others = await prisma.user.count({
where: { isInstanceAdmin: true, isActive: true, id: { not: userId } },
});
if (others === 0) {
throw new QrRequestError(
`Das ist der letzte aktive Administrator — ${action} würde die Installation unbedienbar machen. Lege zuerst einen weiteren Administrator an.`,
);
}
}
export async function PATCH(
request: NextRequest,
context: Context,
): Promise<Response> {
try {
const actor = await getActor();
requirePermission(actor, "instance:manage");
const { id } = await context.params;
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) {
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
}
const data: {
name?: string | null;
isActive?: boolean;
isInstanceAdmin?: boolean;
passwordHash?: string;
failedLogins?: number;
lockedUntil?: Date | null;
} = {};
if (parsed.data.name !== undefined) {
data.name = parsed.data.name || null;
}
if (parsed.data.isActive === false) {
await assertNotLastAdmin(id, "das Deaktivieren");
data.isActive = false;
} else if (parsed.data.isActive === true) {
data.isActive = true;
}
if (parsed.data.isInstanceAdmin === false) {
await assertNotLastAdmin(id, "der Entzug der Administratorrechte");
data.isInstanceAdmin = false;
} else if (parsed.data.isInstanceAdmin === true) {
data.isInstanceAdmin = true;
}
if (parsed.data.password) {
data.passwordHash = await hashPassword(parsed.data.password);
data.failedLogins = 0;
data.lockedUntil = null;
}
if (parsed.data.unlock) {
data.failedLogins = 0;
data.lockedUntil = null;
}
if (Object.keys(data).length === 0) {
return jsonError("Es wurden keine Änderungen übermittelt.");
}
const updated = await prisma.user.update({ where: { id }, data });
// Deaktivieren oder ein neues Passwort beenden laufende Sitzungen —
// sonst bliebe der Betroffene angemeldet.
if (data.isActive === false || data.passwordHash) {
await destroyAllSessions(id);
}
return Response.json({
user: {
id: updated.id,
email: updated.email,
isActive: updated.isActive,
isInstanceAdmin: updated.isInstanceAdmin,
},
});
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(
_request: NextRequest,
context: Context,
): Promise<Response> {
try {
const actor = await getActor();
requirePermission(actor, "instance:manage");
const { id } = await context.params;
if (actor.kind === "user" && actor.user.id === id) {
throw new QrRequestError(
"Du kannst dein eigenes Konto nicht löschen.",
);
}
const target = await prisma.user.findUnique({
where: { id },
select: { id: true },
});
if (!target) {
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
}
await assertNotLastAdmin(id, "das Löschen");
// Die Codes des Benutzers bleiben bestehen; nur der Ersteller entfällt
// (onDelete: SetNull). Gedruckte Codes dürfen nicht daran hängen, wer
// sie einmal angelegt hat.
await prisma.user.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}
+107
View File
@@ -0,0 +1,107 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MIN_PASSWORD_LENGTH,
} from "@/lib/auth/password";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
const createSchema = z.object({
email: z
.string()
.trim()
.toLowerCase()
.min(1, { error: "Bitte gib eine E-Mail-Adresse ein." })
.max(254, { error: "Die Adresse ist zu lang." })
.refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), {
error: "Das ist keine gültige E-Mail-Adresse.",
}),
name: z.string().trim().max(120).optional(),
password: z.string().min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
}),
isInstanceAdmin: z.boolean().default(false),
});
export async function GET(): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const users = await prisma.user.findMany({
orderBy: [{ isInstanceAdmin: "desc" }, { email: "asc" }],
include: {
memberships: { include: { group: { select: { id: true, name: true } } } },
_count: { select: { sessions: true } },
},
});
return Response.json({
users: users.map((user) => ({
id: user.id,
email: user.email,
name: user.name,
isInstanceAdmin: user.isInstanceAdmin,
isActive: user.isActive,
isLocked: Boolean(
user.lockedUntil && user.lockedUntil.getTime() > Date.now(),
),
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
createdAt: user.createdAt.toISOString(),
activeSessions: user._count.sessions,
memberships: user.memberships.map((m) => ({
groupId: m.group.id,
groupName: m.group.name,
role: m.role,
})),
})),
});
} catch (error) {
return errorResponse(error);
}
}
export async function POST(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const existing = await prisma.user.findUnique({
where: { email: parsed.data.email },
select: { id: true },
});
if (existing) {
throw new QrRequestError(
"Diese E-Mail-Adresse ist bereits vergeben.",
409,
);
}
const user = await prisma.user.create({
data: {
email: parsed.data.email,
name: parsed.data.name || null,
passwordHash: await hashPassword(parsed.data.password),
isInstanceAdmin: parsed.data.isInstanceAdmin,
},
});
return Response.json(
{ user: { id: user.id, email: user.email } },
{ status: 201 },
);
} catch (error) {
return errorResponse(error);
}
}
+189
View File
@@ -0,0 +1,189 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MIN_PASSWORD_LENGTH,
verifyPassword,
} from "@/lib/auth/password";
import { createSession, destroySession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
/** Sperrdauer nach zu vielen Fehlversuchen. */
const MAX_ATTEMPTS = 8;
const LOCK_MINUTES = 15;
const emailSchema = z
.string()
.trim()
.toLowerCase()
.min(1, { error: "Bitte gib deine E-Mail-Adresse ein." })
.max(254, { error: "Die Adresse ist zu lang." })
.refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), {
error: "Das ist keine gültige E-Mail-Adresse.",
});
const loginSchema = z.object({
action: z.literal("login"),
email: emailSchema,
password: z.string().min(1, { error: "Bitte gib dein Passwort ein." }),
});
const setupSchema = z.object({
action: z.literal("setup"),
email: emailSchema,
name: z.string().trim().max(120).optional(),
password: z.string().min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
}),
});
const logoutSchema = z.object({ action: z.literal("logout") });
const bodySchema = z.discriminatedUnion("action", [
loginSchema,
setupSchema,
logoutSchema,
]);
/** Wer ist angemeldet? Von der Oberfläche für den Kopfbereich genutzt. */
export async function GET(): Promise<Response> {
try {
const actor = await getActor();
return Response.json({
authEnabled: isAuthEnabled(),
setupOpen: await isSetupOpen(),
user:
actor.kind === "user"
? {
id: actor.user.id,
email: actor.user.email,
name: actor.user.name,
isInstanceAdmin: actor.user.isInstanceAdmin,
memberships: actor.user.memberships,
}
: null,
});
} catch (error) {
return errorResponse(error);
}
}
export async function POST(request: NextRequest): Promise<Response> {
try {
const parsed = bodySchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
if (parsed.data.action === "logout") {
await destroySession();
return Response.json({ ok: true });
}
if (!isAuthEnabled()) {
throw new QrRequestError(
"Die Anmeldung ist ausgeschaltet. Setze AUTH_ENABLED=true in der .env, um sie zu aktivieren.",
);
}
// Das `await` ist hier nicht optional: ohne es verlässt das Promise den
// try-Block, bevor es sich auflöst — eine Ablehnung käme dann nie im
// catch an und würde als nackter 500er beim Aufrufer landen.
if (parsed.data.action === "setup") {
return await handleSetup(parsed.data);
}
return await handleLogin(parsed.data);
} catch (error) {
return errorResponse(error);
}
}
/** Legt den ersten Benutzer an — nur solange es noch keinen gibt. */
async function handleSetup(
input: z.infer<typeof setupSchema>,
): Promise<Response> {
if (!(await isSetupOpen())) {
throw new QrRequestError(
"Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.",
403,
);
}
const user = await prisma.user.create({
data: {
email: input.email,
name: input.name && input.name.length > 0 ? input.name : null,
passwordHash: await hashPassword(input.password),
isInstanceAdmin: true,
},
});
await createSession(user.id);
return Response.json({ ok: true, email: user.email }, { status: 201 });
}
async function handleLogin(
input: z.infer<typeof loginSchema>,
): Promise<Response> {
const user = await prisma.user.findUnique({ where: { email: input.email } });
// Immer dieselbe Meldung, egal ob die Adresse existiert — sonst ließe sich
// damit herausfinden, welche Konten es gibt.
const invalid = new QrRequestError(
"E-Mail-Adresse oder Passwort stimmt nicht.",
401,
);
if (!user) {
// Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät.
await verifyPassword(input.password, "scrypt$32768$8$1$AAAA$AAAA");
throw invalid;
}
if (!user.isActive) {
throw new QrRequestError(
"Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.",
403,
);
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
const minutes = Math.ceil(
(user.lockedUntil.getTime() - Date.now()) / 60000,
);
throw new QrRequestError(
`Zu viele Fehlversuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.`,
429,
);
}
if (!(await verifyPassword(input.password, user.passwordHash))) {
const failedLogins = user.failedLogins + 1;
await prisma.user.update({
where: { id: user.id },
data: {
failedLogins,
lockedUntil:
failedLogins >= MAX_ATTEMPTS
? new Date(Date.now() + LOCK_MINUTES * 60 * 1000)
: null,
},
});
throw invalid;
}
await prisma.user.update({
where: { id: user.id },
data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date() },
});
await createSession(user.id);
return Response.json({ ok: true, email: user.email });
}
+6
View File
@@ -1,5 +1,7 @@
import type { NextRequest } from "next/server";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { shortUrl } from "@/lib/env";
import { errorResponse } from "@/lib/http";
@@ -32,6 +34,10 @@ export async function GET(
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
}
// Das Bild verrät die Kurz-URL und damit das Ziel — es gehört hinter
// dieselbe Prüfung wie die Liste.
requirePermission(await getActor(), "codes:read", { groupId: code.groupId });
const design = designSchema.parse({
fgColor: code.fgColor,
bgColor: code.bgColor,
+28 -4
View File
@@ -2,6 +2,8 @@ import type { NextRequest } from "next/server";
import { z } from "zod";
import type { Prisma } from "@/generated/prisma/client";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
@@ -14,6 +16,11 @@ export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
const WITH_RELATIONS = {
group: { select: { name: true } },
createdBy: { select: { name: true, email: true } },
} as const;
const updateSchema = z.object({
data: z.record(z.string(), z.unknown()).optional(),
design: designSchema.optional(),
@@ -26,7 +33,10 @@ const updateSchema = z.object({
});
async function loadCode(id: string) {
const code = await prisma.qrCode.findUnique({ where: { id } });
const code = await prisma.qrCode.findUnique({
where: { id },
include: WITH_RELATIONS,
});
if (!code) {
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
}
@@ -39,7 +49,10 @@ export async function GET(
): Promise<Response> {
try {
const { id } = await context.params;
return Response.json({ code: toQrCodeDto(await loadCode(id)) });
const actor = await getActor();
const code = await loadCode(id);
requirePermission(actor, "codes:read", { groupId: code.groupId });
return Response.json({ code: toQrCodeDto(code) });
} catch (error) {
return errorResponse(error);
}
@@ -52,12 +65,16 @@ export async function PATCH(
): Promise<Response> {
try {
const { id } = await context.params;
const actor = await getActor();
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const existing = await loadCode(id);
requirePermission(actor, "codes:update", { groupId: existing.groupId });
const update: Prisma.QrCodeUpdateInput = {};
if (parsed.data.data !== undefined) {
@@ -91,7 +108,11 @@ export async function PATCH(
return jsonError("Es wurden keine Änderungen übermittelt.");
}
const updated = await prisma.qrCode.update({ where: { id }, data: update });
const updated = await prisma.qrCode.update({
where: { id },
data: update,
include: WITH_RELATIONS,
});
return Response.json({ code: toQrCodeDto(updated) });
} catch (error) {
return errorResponse(error);
@@ -104,7 +125,10 @@ export async function DELETE(
): Promise<Response> {
try {
const { id } = await context.params;
await loadCode(id);
const actor = await getActor();
const existing = await loadCode(id);
requirePermission(actor, "codes:delete", { groupId: existing.groupId });
await prisma.qrCode.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {
+34 -2
View File
@@ -1,6 +1,9 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { resolveTargetGroup } from "@/lib/auth/groups";
import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
@@ -21,6 +24,8 @@ const createSchema = z.object({
.trim()
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.optional(),
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
groupId: z.string().trim().min(1).optional(),
});
function isUniqueViolation(error: unknown): boolean {
@@ -32,10 +37,24 @@ function isUniqueViolation(error: unknown): boolean {
);
}
/** Alle dynamischen Codes, neueste zuerst. */
/** Die dynamischen Codes, die der Anfragende sehen darf — neueste zuerst. */
export async function GET(): Promise<Response> {
try {
const codes = await prisma.qrCode.findMany({ orderBy: { createdAt: "desc" } });
const actor = await getActor();
if (actor.kind === "anonymous") {
throw new QrRequestError("Bitte melde dich an.", 401);
}
const visible = visibleGroupIds(actor);
const codes = await prisma.qrCode.findMany({
where: visible === null ? undefined : { groupId: { in: visible } },
orderBy: { createdAt: "desc" },
include: {
group: { select: { name: true } },
createdBy: { select: { name: true, email: true } },
},
});
return Response.json({ codes: codes.map(toQrCodeDto) });
} catch (error) {
return errorResponse(error);
@@ -45,12 +64,19 @@ export async function GET(): Promise<Response> {
/** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */
export async function POST(request: NextRequest): Promise<Response> {
try {
const actor = await getActor();
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const { type, design, label } = parsed.data;
// Zielgruppe zuerst: davon hängt ab, ob überhaupt angelegt werden darf.
const groupId = await resolveTargetGroup(actor, parsed.data.groupId);
requirePermission(actor, "codes:create", { groupId });
const qrType = await requireAvailableQrType(type);
const validated = qrType.validate(parsed.data.data);
@@ -70,8 +96,14 @@ export async function POST(request: NextRequest): Promise<Response> {
type: qrType.id,
label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data),
groupId,
createdById: actor.kind === "user" ? actor.user.id : null,
...design,
},
include: {
group: { select: { name: true } },
createdBy: { select: { name: true, email: true } },
},
});
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} catch (error) {
+5
View File
@@ -1,6 +1,7 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import {
@@ -17,6 +18,10 @@ const downloadSchema = qrRequestSchema.extend({
/** Liefert den fertigen QR-Code als PNG- oder SVG-Datei zum Herunterladen. */
export async function POST(request: NextRequest): Promise<Response> {
try {
// Der Generator steht nur Angemeldeten offen; die Weiterleitung
// unter /r/{slug} bleibt davon unberührt und öffentlich.
requireSignedIn(await getActor());
const parsed = downloadSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
+5
View File
@@ -1,5 +1,6 @@
import type { NextRequest } from "next/server";
import { getActor, requireSignedIn } from "@/lib/auth/actor";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { renderSvg } from "@/lib/qr/render";
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
@@ -10,6 +11,10 @@ import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
*/
export async function POST(request: NextRequest): Promise<Response> {
try {
// Der Generator steht nur Angemeldeten offen; die Weiterleitung
// unter /r/{slug} bleibt davon unberührt und öffentlich.
requireSignedIn(await getActor());
const parsed = qrRequestSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
+2
View File
@@ -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);
+27
View File
@@ -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 (
<div className="mx-auto max-w-md px-4 py-20 text-center sm:px-6">
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Icon name="lock" className="size-7" />
</span>
<h1 className="mt-5 text-2xl font-bold text-slate-900">Kein Zugriff</h1>
<p className="mt-2 text-slate-600">
Für diesen Bereich fehlt dir die nötige Berechtigung. Wenn du meinst,
dass das ein Versehen ist, wende dich an einen Administrator.
</p>
<Link
href="/"
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Zur Startseite
</Link>
</div>
);
}
+50 -20
View File
@@ -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 (
<html lang="de" className="h-full">
<body className="flex min-h-full flex-col">
@@ -30,25 +44,41 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
Navigation nicht mehr in eine Zeile. */}
<span className="hidden sm:inline">QR-Code-Generator</span>
</Link>
<nav className="flex items-center gap-0.5 text-sm font-medium sm:gap-1">
<Link
href="/"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Erstellen
</Link>
<Link
href="/meine-codes"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Meine Codes
</Link>
<Link
href="/verwaltung/typen"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Verwaltung
</Link>
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
{signedIn ? (
<>
<Link href="/" className={NAV_LINK}>
Erstellen
</Link>
<Link href="/meine-codes" className={NAV_LINK}>
Meine Codes
</Link>
{showAdmin ? (
<Link href="/verwaltung" className={NAV_LINK}>
Verwaltung
</Link>
) : null}
</>
) : null}
{isAuthEnabled() && actor.kind === "user" ? (
<>
<span
className="ml-1 hidden max-w-[14rem] truncate text-slate-500 sm:inline"
title={actor.user.email}
>
{actorLabel(actor)}
</span>
<LogoutButton />
</>
) : null}
{isAuthEnabled() && actor.kind === "anonymous" ? (
<Link href="/anmelden" className={NAV_LINK}>
Anmelden
</Link>
) : null}
</nav>
</div>
</header>
+6 -1
View File
@@ -1,10 +1,15 @@
import type { Metadata } from "next";
import { CodesList } from "@/components/codes-list";
import { requirePageActor } from "@/lib/auth/guard";
export const metadata: Metadata = { title: "Meine Codes" };
export default function MyCodesPage() {
export const dynamic = "force-dynamic";
export default async function MyCodesPage() {
await requirePageActor();
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7">
+2
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge, Notice } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard";
import { getVisibleQrTypes } from "@/lib/qr/availability";
// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
@@ -9,6 +10,7 @@ import { getVisibleQrTypes } from "@/lib/qr/availability";
export const dynamic = "force-dynamic";
export default async function HomePage() {
await requirePageActor();
const types = await getVisibleQrTypes();
const usableCount = types.filter((entry) => entry.usable).length;
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { UserAdmin } from "@/components/user-admin";
import { requirePagePermission } from "@/lib/auth/guard";
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
export const metadata: Metadata = { title: "Benutzer" };
export const dynamic = "force-dynamic";
export default async function UsersPage() {
const actor = await requirePagePermission("instance:manage");
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/verwaltung"
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
Verwaltung
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Benutzer
</h1>
<p className="mt-2 text-slate-600">
Konten anlegen, Passwörter setzen und Zugänge deaktivieren. Wer in
welcher Gruppe mitarbeitet, legst du unter{" "}
<Link href="/verwaltung/gruppen" className="font-medium underline">
Gruppen
</Link>{" "}
fest.
</p>
</div>
<UserAdmin
minPasswordLength={MIN_PASSWORD_LENGTH}
currentUserId={actor.kind === "user" ? actor.user.id : null}
/>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import Link from "next/link";
import { GroupAdmin } from "@/components/group-admin";
import { Icon } from "@/components/icons";
import { requirePageActor } from "@/lib/auth/guard";
import { can } from "@/lib/auth/policy";
export const metadata: Metadata = { title: "Gruppen" };
export const dynamic = "force-dynamic";
export default async function GroupsPage() {
// Bewusst nur „angemeldet“: ein Gruppen-Administrator darf seine eigene
// Gruppe pflegen, auch ohne Rechte an der Installation.
const actor = await requirePageActor();
const canCreate = can(actor, "instance:manage");
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href={canCreate ? "/verwaltung" : "/"}
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
>
<Icon name="arrowLeft" className="size-4" />
{canCreate ? "Verwaltung" : "Zur Typ-Auswahl"}
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Gruppen
</h1>
<p className="mt-2 text-slate-600">
Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören. Jedes
Mitglied hat darin eine Rolle: Administrator, Bearbeiter oder
Betrachter.
</p>
</div>
<GroupAdmin canCreate={canCreate} />
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon, type IconName } from "@/components/icons";
import { Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePagePermission } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Verwaltung" };
export const dynamic = "force-dynamic";
function Tile({
href,
icon,
title,
description,
hint,
}: {
href: string;
icon: IconName;
title: string;
description: string;
hint: string;
}) {
return (
<Link
href={href}
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
<Icon name={icon} className="size-6" />
</span>
<span className="min-w-0 flex-1">
<span className="font-semibold text-slate-900">{title}</span>
<span className="mt-1 block text-sm text-slate-600">{description}</span>
<span className="mt-2 block text-xs text-slate-500 tabular-nums">
{hint}
</span>
</span>
</Link>
);
}
export default async function AdminPage() {
await requirePagePermission("instance:manage");
const [userCount, groupCount, codeCount] = await Promise.all([
prisma.user.count(),
prisma.group.count(),
prisma.qrCode.count(),
]);
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Verwaltung
</h1>
<p className="mt-2 text-slate-600">
Benutzer, Gruppen und die verfügbaren QR-Typen.
</p>
</div>
{!isAuthEnabled() ? (
<div className="mb-6">
<Notice tone="info">
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
Jeder, der die Anwendung erreicht, darf alles. Zum Einschalten
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
AUTH_ENABLED=true
</code>
in der <code className="font-mono text-xs">.env</code> setzen und
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
docker compose up -d
</code>
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
an. Benutzer und Gruppen lassen sich schon jetzt vorbereiten.
</Notice>
</div>
) : null}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Tile
href="/verwaltung/benutzer"
icon="contact"
title="Benutzer"
description="Konten anlegen, Passwörter zurücksetzen, deaktivieren."
hint={`${userCount} ${userCount === 1 ? "Benutzer" : "Benutzer"} angelegt`}
/>
<Tile
href="/verwaltung/gruppen"
icon="list"
title="Gruppen"
description="Gruppen anlegen und Mitglieder mit Rollen zuordnen."
hint={`${groupCount} ${groupCount === 1 ? "Gruppe" : "Gruppen"}, ${codeCount} ${codeCount === 1 ? "Code" : "Codes"}`}
/>
<Tile
href="/verwaltung/typen"
icon="qr"
title="QR-Typen"
description="Festlegen, welche Typen auf der Startseite erscheinen."
hint="15 Typen insgesamt"
/>
</div>
</div>
);
}
+2
View File
@@ -3,6 +3,7 @@ import Link from "next/link";
import { Icon } from "@/components/icons";
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
import { requirePagePermission } from "@/lib/auth/guard";
import { getQrTypeAvailability } from "@/lib/qr/availability";
export const metadata: Metadata = { title: "QR-Typen" };
@@ -10,6 +11,7 @@ export const metadata: Metadata = { title: "QR-Typen" };
export const dynamic = "force-dynamic";
export default async function TypeSettingsPage() {
await requirePagePermission("instance:manage");
const availability = await getQrTypeAvailability();
const initial: TypeSetting[] = availability.map(
+196
View File
@@ -0,0 +1,196 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button, Card, Field, Notice, TextInput } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
function useSubmit() {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (json: unknown) => {
setPending(true);
setError(null);
try {
await apiJson("/api/auth", { method: "POST", json });
// refresh() ist hier entscheidend: Kopfbereich und Startseite werden
// serverseitig je nach Anmeldung unterschiedlich gerendert, und ohne
// Verwerfen des Zwischenspeichers käme die Seite von vor der Anmeldung.
router.replace("/");
router.refresh();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Anmeldung ist fehlgeschlagen.",
);
setPending(false);
}
};
return { submit, pending, error, router };
}
export function LoginForm() {
const { submit, pending, error } = useSubmit();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
return (
<Card>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void submit({ action: "login", email, password });
}}
>
{error ? <Notice tone="error">{error}</Notice> : null}
<Field label="E-Mail-Adresse" htmlFor="login-email">
<TextInput
id="login-email"
type="email"
autoComplete="username"
autoFocus
required
value={email}
disabled={pending}
onChange={(event) => setEmail(event.target.value)}
/>
</Field>
<Field label="Passwort" htmlFor="login-password">
<TextInput
id="login-password"
type="password"
autoComplete="current-password"
required
value={password}
disabled={pending}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Button type="submit" disabled={pending}>
{pending ? "Wird angemeldet …" : "Anmelden"}
</Button>
</form>
</Card>
);
}
export function SetupForm({ minLength }: { minLength: number }) {
const { submit, pending, error } = useSubmit();
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const mismatch = repeat.length > 0 && password !== repeat;
return (
<Card>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (mismatch) return;
void submit({ action: "setup", email, name, password });
}}
>
{error ? <Notice tone="error">{error}</Notice> : null}
<Field label="Name (optional)" htmlFor="setup-name">
<TextInput
id="setup-name"
autoComplete="name"
value={name}
disabled={pending}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<Field label="E-Mail-Adresse" htmlFor="setup-email">
<TextInput
id="setup-email"
type="email"
autoComplete="username"
required
value={email}
disabled={pending}
onChange={(event) => setEmail(event.target.value)}
/>
</Field>
<Field
label="Passwort"
htmlFor="setup-password"
hint={`Mindestens ${minLength} Zeichen.`}
>
<TextInput
id="setup-password"
type="password"
autoComplete="new-password"
required
minLength={minLength}
value={password}
disabled={pending}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Field
label="Passwort wiederholen"
htmlFor="setup-repeat"
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
>
<TextInput
id="setup-repeat"
type="password"
autoComplete="new-password"
required
invalid={mismatch}
value={repeat}
disabled={pending}
onChange={(event) => setRepeat(event.target.value)}
/>
</Field>
<Button type="submit" disabled={pending || mismatch}>
{pending ? "Wird angelegt …" : "Administrator anlegen"}
</Button>
</form>
</Card>
);
}
export function LogoutButton() {
const router = useRouter();
const [pending, setPending] = useState(false);
return (
<button
type="button"
disabled={pending}
onClick={async () => {
setPending(true);
try {
await apiJson("/api/auth", {
method: "POST",
json: { action: "logout" },
});
} finally {
router.replace("/anmelden");
router.refresh();
}
}}
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:opacity-50"
>
{pending ? "…" : "Abmelden"}
</button>
);
}
+401
View File
@@ -0,0 +1,401 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import {
Badge,
Button,
Card,
Field,
Notice,
Select,
TextInput,
} from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
type Role = "ADMIN" | "EDITOR" | "VIEWER";
const ROLE_LABELS: Record<Role, string> = {
ADMIN: "Administrator",
EDITOR: "Bearbeiter",
VIEWER: "Betrachter",
};
const ROLE_HINTS: Record<Role, string> = {
ADMIN: "darf auch Mitglieder verwalten",
EDITOR: "darf Codes anlegen und ändern",
VIEWER: "darf nur ansehen",
};
type Member = { userId: string; email: string; name: string | null; role: Role };
type AdminGroup = {
id: string;
name: string;
description: string | null;
codeCount: number;
canManage: boolean;
members: Member[];
};
type PickerUser = { id: string; email: string; name: string | null };
export function GroupAdmin({ canCreate }: { canCreate: boolean }) {
const [groups, setGroups] = useState<AdminGroup[] | null>(null);
const [users, setUsers] = useState<PickerUser[]>([]);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ name: "", description: "" });
const [formError, setFormError] = useState<string | null>(null);
const [editing, setEditing] = useState<AdminGroup | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [memberError, setMemberError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<AdminGroup | null>(null);
const load = useCallback(async () => {
try {
const result = await apiJson<{ groups: AdminGroup[] }>("/api/admin/groups");
setGroups(result.groups);
setError(null);
} catch (caught) {
setGroups([]);
setError(
caught instanceof ApiError
? caught.message
: "Die Gruppen konnten nicht geladen werden.",
);
}
if (canCreate) {
try {
const result = await apiJson<{ users: PickerUser[] }>("/api/admin/users");
setUsers(result.users);
} catch {
// Ohne Benutzerliste lässt sich zwar niemand hinzufügen, die
// Gruppenübersicht bleibt aber brauchbar.
}
}
}, [canCreate]);
useEffect(() => {
// setState folgt erst nach dem await der Netzwerkantwort.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
const create = async () => {
setFormError(null);
try {
await apiJson("/api/admin/groups", { method: "POST", json: form });
setCreating(false);
setForm({ name: "", description: "" });
await load();
} catch (caught) {
setFormError(
caught instanceof ApiError
? caught.message
: "Die Gruppe konnte nicht angelegt werden.",
);
}
};
const saveMembers = async () => {
if (!editing) return;
setSaving(true);
setMemberError(null);
try {
await apiJson(`/api/admin/groups/${editing.id}`, {
method: "PATCH",
json: { members: members.map((m) => ({ userId: m.userId, role: m.role })) },
});
setEditing(null);
await load();
} catch (caught) {
setMemberError(
caught instanceof ApiError
? caught.message
: "Die Mitglieder konnten nicht gespeichert werden.",
);
} finally {
setSaving(false);
}
};
const remove = async () => {
if (!deleting) return;
try {
await apiJson(`/api/admin/groups/${deleting.id}`, { method: "DELETE" });
setDeleting(null);
await load();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Gruppe konnte nicht gelöscht werden.",
);
setDeleting(null);
}
};
if (groups === null) {
return <p className="text-sm text-slate-500">Gruppen werden geladen …</p>;
}
const available = users.filter(
(user) => !members.some((member) => member.userId === user.id),
);
return (
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
{canCreate ? (
<div className="flex justify-end">
<Button type="button" onClick={() => setCreating(true)}>
Gruppe anlegen
</Button>
</div>
) : null}
{groups.map((group) => (
<Card key={group.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-semibold text-slate-900">{group.name}</h2>
<Badge tone="neutral">
{group.codeCount} {group.codeCount === 1 ? "Code" : "Codes"}
</Badge>
</div>
{group.description ? (
<p className="mt-1 text-sm text-slate-600">{group.description}</p>
) : null}
<ul className="mt-3 space-y-1 text-sm">
{group.members.length === 0 ? (
<li className="text-slate-500">Noch keine Mitglieder.</li>
) : (
group.members.map((member) => (
<li key={member.userId} className="text-slate-700">
{member.name?.trim() || member.email}
<span className="text-slate-500">
{" "}
— {ROLE_LABELS[member.role]}
</span>
</li>
))
)}
</ul>
<div className="mt-4 flex flex-wrap gap-2">
{group.canManage ? (
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => {
setEditing(group);
setMembers(group.members);
setMemberError(null);
}}
>
<Icon name="pencil" className="size-3.5" />
Mitglieder verwalten
</Button>
) : null}
{canCreate ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => setDeleting(group)}
>
<Icon name="trash" className="size-3.5" />
Löschen
</Button>
) : null}
</div>
</div>
</div>
</Card>
))}
{/* ------------------------------------------------------- Anlegen */}
<Modal
open={creating}
title="Gruppe anlegen"
onClose={() => setCreating(false)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setCreating(false)}>
Abbrechen
</Button>
<Button type="button" onClick={() => void create()}>
Anlegen
</Button>
</>
}
>
<div className="space-y-4">
{formError ? <Notice tone="error">{formError}</Notice> : null}
<Field label="Name" htmlFor="new-group-name">
<TextInput
id="new-group-name"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
/>
</Field>
<Field label="Beschreibung (optional)" htmlFor="new-group-description">
<TextInput
id="new-group-description"
value={form.description}
onChange={(event) =>
setForm({ ...form, description: event.target.value })
}
/>
</Field>
</div>
</Modal>
{/* ---------------------------------------------------- Mitglieder */}
<Modal
open={editing !== null}
title={`Mitglieder von „${editing?.name ?? ""}“`}
onClose={() => setEditing(null)}
footer={
<>
<Button
type="button"
variant="secondary"
onClick={() => setEditing(null)}
disabled={saving}
>
Abbrechen
</Button>
<Button type="button" onClick={() => void saveMembers()} disabled={saving}>
{saving ? "Speichert …" : "Speichern"}
</Button>
</>
}
>
<div className="space-y-4">
{memberError ? <Notice tone="error">{memberError}</Notice> : null}
{members.length === 0 ? (
<p className="text-sm text-slate-500">
Diese Gruppe hat noch keine Mitglieder.
</p>
) : (
<ul className="space-y-2">
{members.map((member) => (
<li
key={member.userId}
className="flex flex-wrap items-center gap-2 rounded-lg border border-slate-200 p-2.5"
>
<span className="min-w-0 flex-1 truncate text-sm text-slate-800">
{member.name?.trim() || member.email}
</span>
<Select
aria-label={`Rolle von ${member.email}`}
className="!w-auto"
value={member.role}
onChange={(event) =>
setMembers((current) =>
current.map((entry) =>
entry.userId === member.userId
? { ...entry, role: event.target.value as Role }
: entry,
),
)
}
>
{(Object.keys(ROLE_LABELS) as Role[]).map((role) => (
<option key={role} value={role}>
{ROLE_LABELS[role]} — {ROLE_HINTS[role]}
</option>
))}
</Select>
<button
type="button"
aria-label={`${member.email} entfernen`}
onClick={() =>
setMembers((current) =>
current.filter((entry) => entry.userId !== member.userId),
)
}
className="rounded p-1.5 text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<Icon name="trash" className="size-4" />
</button>
</li>
))}
</ul>
)}
{available.length > 0 ? (
<Field label="Mitglied hinzufügen" htmlFor="add-member">
<Select
id="add-member"
value=""
onChange={(event) => {
const user = users.find((u) => u.id === event.target.value);
if (!user) return;
setMembers((current) => [
...current,
{
userId: user.id,
email: user.email,
name: user.name,
role: "EDITOR",
},
]);
}}
>
<option value="">Benutzer wählen …</option>
{available.map((user) => (
<option key={user.id} value={user.id}>
{user.name?.trim() || user.email}
</option>
))}
</Select>
</Field>
) : null}
</div>
</Modal>
{/* -------------------------------------------------------- Löschen */}
<Modal
open={deleting !== null}
title="Gruppe löschen?"
onClose={() => setDeleting(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setDeleting(null)}>
Abbrechen
</Button>
<Button type="button" variant="danger" onClick={() => void remove()}>
Endgültig löschen
</Button>
</>
}
>
{deleting ? (
<p className="text-sm text-slate-600">
<strong className="text-slate-900">{deleting.name}</strong> wird
gelöscht und alle Mitgliedschaften entfallen.
{deleting.codeCount > 0
? ` Die Gruppe enthält noch ${deleting.codeCount} ${deleting.codeCount === 1 ? "Code" : "Codes"} — das Löschen wird deshalb abgelehnt.`
: " Sie enthält keine Codes."}
</p>
) : null}
</Modal>
</div>
);
}
+407
View File
@@ -0,0 +1,407 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import {
Badge,
Button,
Card,
Checkbox,
Field,
Notice,
TextInput,
} from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
type AdminUser = {
id: string;
email: string;
name: string | null;
isInstanceAdmin: boolean;
isActive: boolean;
isLocked: boolean;
lastLoginAt: string | null;
createdAt: string;
activeSessions: number;
memberships: { groupId: string; groupName: string; role: string }[];
};
const dateFormat = new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
});
const formatDate = (value: string | null) =>
value ? dateFormat.format(new Date(value)) : "—";
export function UserAdmin({
minPasswordLength,
currentUserId,
}: {
minPasswordLength: number;
currentUserId: string | null;
}) {
const [users, setUsers] = useState<AdminUser[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({
email: "",
name: "",
password: "",
isInstanceAdmin: false,
});
const [formError, setFormError] = useState<string | null>(null);
const [resetting, setResetting] = useState<AdminUser | null>(null);
const [newPassword, setNewPassword] = useState("");
const [deleting, setDeleting] = useState<AdminUser | null>(null);
const load = useCallback(async () => {
try {
const result = await apiJson<{ users: AdminUser[] }>("/api/admin/users");
setUsers(result.users);
setError(null);
} catch (caught) {
setUsers([]);
setError(
caught instanceof ApiError
? caught.message
: "Die Benutzer konnten nicht geladen werden.",
);
}
}, []);
useEffect(() => {
// setState folgt erst nach dem await der Netzwerkantwort.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
const act = async (id: string, body: unknown, onDone?: () => void) => {
setBusy(id);
setError(null);
try {
await apiJson(`/api/admin/users/${id}`, { method: "PATCH", json: body });
await load();
onDone?.();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Änderung konnte nicht gespeichert werden.",
);
} finally {
setBusy(null);
}
};
const create = async () => {
setFormError(null);
try {
await apiJson("/api/admin/users", { method: "POST", json: form });
setCreating(false);
setForm({ email: "", name: "", password: "", isInstanceAdmin: false });
await load();
} catch (caught) {
setFormError(
caught instanceof ApiError
? caught.message
: "Der Benutzer konnte nicht angelegt werden.",
);
}
};
const remove = async () => {
if (!deleting) return;
setBusy(deleting.id);
try {
await apiJson(`/api/admin/users/${deleting.id}`, { method: "DELETE" });
setDeleting(null);
await load();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Der Benutzer konnte nicht gelöscht werden.",
);
setDeleting(null);
} finally {
setBusy(null);
}
};
if (users === null) {
return <p className="text-sm text-slate-500">Benutzer werden geladen …</p>;
}
return (
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
<div className="flex justify-end">
<Button type="button" onClick={() => setCreating(true)}>
Benutzer anlegen
</Button>
</div>
{users.length === 0 ? (
<Card className="text-center">
<p className="text-sm text-slate-600">
Es gibt noch keine Benutzer. Solange die Anmeldung ausgeschaltet
ist, wird auch keiner gebraucht.
</p>
</Card>
) : null}
{users.map((user) => (
<Card key={user.id}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-slate-900">
{user.name?.trim() || user.email}
</span>
{user.isInstanceAdmin ? (
<Badge tone="popular">Administrator</Badge>
) : null}
{!user.isActive ? <Badge tone="soon">deaktiviert</Badge> : null}
{user.isLocked ? (
<Badge tone="neutral">vorübergehend gesperrt</Badge>
) : null}
{user.id === currentUserId ? (
<Badge tone="success">das bist du</Badge>
) : null}
</div>
{user.name?.trim() ? (
<p className="mt-0.5 text-sm text-slate-500">{user.email}</p>
) : null}
<dl className="mt-2.5 flex flex-wrap gap-x-5 gap-y-1 text-sm text-slate-500">
<span>Letzte Anmeldung: {formatDate(user.lastLoginAt)}</span>
<span>Angelegt: {formatDate(user.createdAt)}</span>
{user.activeSessions > 0 ? (
<span>
{user.activeSessions} offene{" "}
{user.activeSessions === 1 ? "Sitzung" : "Sitzungen"}
</span>
) : null}
</dl>
<p className="mt-2 text-sm text-slate-600">
{user.memberships.length === 0
? "In keiner Gruppe."
: user.memberships
.map((m) => `${m.groupName} (${m.role})`)
.join(" · ")}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === user.id}
onClick={() => {
setResetting(user);
setNewPassword("");
}}
>
<Icon name="lock" className="size-3.5" />
Passwort setzen
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === user.id}
onClick={() =>
void act(user.id, { isActive: !user.isActive })
}
>
{user.isActive ? "Deaktivieren" : "Aktivieren"}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === user.id}
onClick={() =>
void act(user.id, { isInstanceAdmin: !user.isInstanceAdmin })
}
>
{user.isInstanceAdmin
? "Adminrechte entziehen"
: "Zum Administrator machen"}
</Button>
{user.isLocked ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy === user.id}
onClick={() => void act(user.id, { unlock: true })}
>
Sperre aufheben
</Button>
) : null}
{user.id !== currentUserId ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
disabled={busy === user.id}
onClick={() => setDeleting(user)}
>
<Icon name="trash" className="size-3.5" />
Löschen
</Button>
) : null}
</div>
</div>
</div>
</Card>
))}
{/* ------------------------------------------------------- Anlegen */}
<Modal
open={creating}
title="Benutzer anlegen"
onClose={() => setCreating(false)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setCreating(false)}>
Abbrechen
</Button>
<Button type="button" onClick={() => void create()}>
Anlegen
</Button>
</>
}
>
<div className="space-y-4">
{formError ? <Notice tone="error">{formError}</Notice> : null}
<Field label="E-Mail-Adresse" htmlFor="new-user-email">
<TextInput
id="new-user-email"
type="email"
value={form.email}
onChange={(event) =>
setForm({ ...form, email: event.target.value })
}
/>
</Field>
<Field label="Name (optional)" htmlFor="new-user-name">
<TextInput
id="new-user-name"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
/>
</Field>
<Field
label="Passwort"
htmlFor="new-user-password"
hint={`Mindestens ${minPasswordLength} Zeichen. Gib es der Person weiter — sie kann es später nicht selbst ändern, aber ein Administrator kann es neu setzen.`}
>
<TextInput
id="new-user-password"
type="text"
value={form.password}
onChange={(event) =>
setForm({ ...form, password: event.target.value })
}
/>
</Field>
<Checkbox
id="new-user-admin"
label="Administrator der Installation"
hint="Darf Benutzer, Gruppen und QR-Typen verwalten."
checked={form.isInstanceAdmin}
onChange={(event) =>
setForm({ ...form, isInstanceAdmin: event.target.checked })
}
/>
</div>
</Modal>
{/* ---------------------------------------------- Passwort setzen */}
<Modal
open={resetting !== null}
title="Passwort setzen"
onClose={() => setResetting(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setResetting(null)}>
Abbrechen
</Button>
<Button
type="button"
disabled={newPassword.length < minPasswordLength}
onClick={() =>
resetting &&
void act(resetting.id, { password: newPassword }, () =>
setResetting(null),
)
}
>
Passwort setzen
</Button>
</>
}
>
{resetting ? (
<div className="space-y-4">
<p className="text-sm text-slate-600">
Neues Passwort für{" "}
<strong className="text-slate-900">{resetting.email}</strong>.
Alle offenen Sitzungen dieses Benutzers werden dabei beendet.
</p>
<Field
label="Neues Passwort"
htmlFor="reset-password"
hint={`Mindestens ${minPasswordLength} Zeichen.`}
>
<TextInput
id="reset-password"
type="text"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
</Field>
</div>
) : null}
</Modal>
{/* -------------------------------------------------------- Löschen */}
<Modal
open={deleting !== null}
title="Benutzer löschen?"
onClose={() => setDeleting(null)}
footer={
<>
<Button type="button" variant="secondary" onClick={() => setDeleting(null)}>
Abbrechen
</Button>
<Button type="button" variant="danger" onClick={() => void remove()}>
Endgültig löschen
</Button>
</>
}
>
{deleting ? (
<p className="text-sm text-slate-600">
<strong className="text-slate-900">{deleting.email}</strong> wird
gelöscht und aus allen Gruppen entfernt. Die von dieser Person
angelegten QR-Codes bleiben bestehen — sie verlieren nur den
Vermerk, wer sie erstellt hat.
</p>
) : null}
</Modal>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import "server-only";
import { prisma } from "@/lib/db";
import { getSessionUser, type SessionUser } from "@/lib/auth/session";
import { QrRequestError } from "@/lib/qr/type-module";
/**
* Wer gerade handelt.
*
* Der Umschalter für die Anmeldung lebt genau hier: ist sie ausgeschaltet,
* liefert `getActor()` einen `system`-Actor mit Vollzugriff. Dadurch braucht
* keine einzige Route ein `if (anmeldungAktiv)` — sie fragen alle nur die
* Policy, und die kennt beide Betriebsarten.
*/
export type Actor =
| { kind: "system" }
| { kind: "user"; user: SessionUser }
| { kind: "anonymous" };
/**
* Ist die Anmeldung eingeschaltet?
*
* Bewusst eine Umgebungsvariable und kein Schalter in der Oberfläche: so
* kann man sich nicht aussperren. Wer den Zugang verliert, setzt
* `AUTH_ENABLED=false` in der .env und startet neu.
*/
export function isAuthEnabled(): boolean {
return (process.env.AUTH_ENABLED ?? "").trim().toLowerCase() === "true";
}
export async function getActor(): Promise<Actor> {
if (!isAuthEnabled()) return { kind: "system" };
const user = await getSessionUser();
return user ? { kind: "user", user } : { kind: "anonymous" };
}
/** Gibt es überhaupt schon einen Benutzer? Steuert die Ersteinrichtung. */
export async function hasAnyUser(): Promise<boolean> {
return (await prisma.user.count()) > 0;
}
/**
* Die Ersteinrichtung ist nur offen, solange die Anmeldung aktiv ist und es
* noch keinen Benutzer gibt. Danach schließt sie sich von selbst — sonst
* könnte sich jederzeit jemand einen Administrator anlegen.
*/
export async function isSetupOpen(): Promise<boolean> {
return isAuthEnabled() && !(await hasAnyUser());
}
export function actorLabel(actor: Actor): string {
if (actor.kind === "system") return "Ohne Anmeldung";
if (actor.kind === "anonymous") return "Nicht angemeldet";
return actor.user.name?.trim() || actor.user.email;
}
/** Wirft, wenn niemand angemeldet ist. */
export function requireSignedIn(actor: Actor): void {
if (actor.kind === "anonymous") {
throw new QrRequestError("Bitte melde dich an.", 401);
}
}
+93
View File
@@ -0,0 +1,93 @@
import "server-only";
import { prisma } from "@/lib/db";
import type { Actor } from "@/lib/auth/actor";
import { can, visibleGroupIds, writableGroupIds } from "@/lib/auth/policy";
import { QrRequestError } from "@/lib/qr/type-module";
/**
* Die Gruppe, in der Codes landen, wenn niemand angemeldet ist. Sie wird von
* der Migration angelegt; hier steht der Notnagel für den Fall, dass jemand
* sie gelöscht hat — sonst ließe sich plötzlich kein Code mehr anlegen.
*/
export const DEFAULT_GROUP_ID = "grp_standard";
export async function ensureDefaultGroup(): Promise<string> {
const existing = await prisma.group.findUnique({
where: { id: DEFAULT_GROUP_ID },
select: { id: true },
});
if (existing) return existing.id;
const created = await prisma.group.upsert({
where: { name: "Standard" },
create: { id: DEFAULT_GROUP_ID, name: "Standard" },
update: {},
select: { id: true },
});
return created.id;
}
export type GroupOption = { id: string; name: string; canWrite: boolean };
/** Gruppen, die der Actor in Auswahllisten sehen soll. */
export async function listGroupsForActor(actor: Actor): Promise<GroupOption[]> {
const visible = visibleGroupIds(actor);
const writable = writableGroupIds(actor);
const groups = await prisma.group.findMany({
where: visible === null ? undefined : { id: { in: visible } },
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return groups.map((group) => ({
...group,
canWrite: writable === null || writable.includes(group.id),
}));
}
/**
* Bestimmt die Gruppe, in der ein neuer Code angelegt wird.
*
* Ohne Anmeldung ist das immer die Standardgruppe. Mit Anmeldung entscheidet
* die Auswahl des Benutzers; fehlt sie und gibt es genau eine beschreibbare
* Gruppe, wird diese genommen — bei mehreren muss der Benutzer wählen.
*/
export async function resolveTargetGroup(
actor: Actor,
requestedGroupId?: string,
): Promise<string> {
if (actor.kind === "system") {
return requestedGroupId ?? (await ensureDefaultGroup());
}
if (requestedGroupId) {
if (!can(actor, "codes:create", { groupId: requestedGroupId })) {
throw new QrRequestError(
"In dieser Gruppe darfst du keine Codes anlegen.",
403,
);
}
return requestedGroupId;
}
const writable = writableGroupIds(actor);
if (writable === null) {
// Instanz-Administrator ohne Auswahl: die Standardgruppe.
return ensureDefaultGroup();
}
if (writable.length === 0) {
throw new QrRequestError(
"Du bist in keiner Gruppe, in der du Codes anlegen darfst. Bitte wende dich an einen Administrator.",
403,
);
}
if (writable.length > 1) {
throw new QrRequestError(
"Bitte wähle aus, in welcher Gruppe der Code angelegt werden soll.",
);
}
return writable[0];
}
+34
View File
@@ -0,0 +1,34 @@
import "server-only";
import { redirect } from "next/navigation";
import { getActor, isAuthEnabled, type Actor } from "@/lib/auth/actor";
import { can, type Action, type Scope } from "@/lib/auth/policy";
/**
* Wächter für Seiten (nicht für API-Routen — die werfen stattdessen).
*
* Ist die Anmeldung aus, kommt ein `system`-Actor zurück und alles ist
* erlaubt. Ist sie an und niemand angemeldet, geht es zur Anmeldeseite.
*/
export async function requirePageActor(): Promise<Actor> {
const actor = await getActor();
if (actor.kind === "anonymous") {
redirect("/anmelden");
}
return actor;
}
/** Wie `requirePageActor`, prüft zusätzlich eine Berechtigung. */
export async function requirePagePermission(
action: Action,
scope: Scope = {},
): Promise<Actor> {
const actor = await requirePageActor();
if (!can(actor, action, scope)) {
redirect("/kein-zugriff");
}
return actor;
}
export { isAuthEnabled };
+111
View File
@@ -0,0 +1,111 @@
import "server-only";
import {
randomBytes,
scrypt,
timingSafeEqual,
type ScryptOptions,
} from "node:crypto";
// promisify() greift bei scrypt die Überladung ohne Optionen ab — deshalb
// hier von Hand eingepackt.
function scryptAsync(
password: string,
salt: Buffer,
keyLength: number,
options: ScryptOptions,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, keyLength, options, (error, derived) => {
if (error) reject(error);
else resolve(derived);
});
});
}
/**
* Passwörter mit scrypt aus der Node-Standardbibliothek.
*
* Warum nicht Argon2id, das heute meist zuerst empfohlen wird: dafür bräuchte
* es ein natives Modul, das zur Plattform des Containers passen muss. scrypt
* ist ebenfalls speicherhart, gilt als solide und kostet null Abhängigkeiten
* und null Build-Risiko — bei einer selbst gehosteten Anwendung wiegt das
* schwerer als der Unterschied zwischen zwei guten Verfahren.
*
* Format: `scrypt$N$r$p$salt$hash`, beides Base64. Die Parameter stehen im
* Hash, damit sie sich später erhöhen lassen, ohne alte Passwörter zu
* entwerten.
*/
const PARAMS = { N: 32768, r: 8, p: 1, keyLength: 64, saltLength: 16 };
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(PARAMS.saltLength);
const derived = await scryptAsync(
password.normalize("NFKC"),
salt,
PARAMS.keyLength,
{
N: PARAMS.N,
r: PARAMS.r,
p: PARAMS.p,
// Der Standardwert reicht für N = 32768 nicht aus.
maxmem: 256 * 1024 * 1024,
},
);
return [
"scrypt",
PARAMS.N,
PARAMS.r,
PARAMS.p,
salt.toString("base64"),
derived.toString("base64"),
].join("$");
}
export async function verifyPassword(
password: string,
stored: string,
): Promise<boolean> {
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
const N = Number(parts[1]);
const r = Number(parts[2]);
const p = Number(parts[3]);
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) {
return false;
}
let salt: Buffer;
let expected: Buffer;
try {
salt = Buffer.from(parts[4], "base64");
expected = Buffer.from(parts[5], "base64");
} catch {
return false;
}
let derived: Buffer;
try {
derived = await scryptAsync(password.normalize("NFKC"), salt, expected.length, {
N,
r,
p,
maxmem: 256 * 1024 * 1024,
});
} catch {
return false;
}
// Vergleich in konstanter Zeit — ein früher Abbruch würde verraten, wie
// viele Bytes bereits stimmen.
return derived.length === expected.length && timingSafeEqual(derived, expected);
}
/** Mindestanforderungen an ein Passwort. */
export const MIN_PASSWORD_LENGTH = 10;
export function describePasswordRules(): string {
return `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen.`;
}
+125
View File
@@ -0,0 +1,125 @@
import "server-only";
import type { Role } from "@/generated/prisma/client";
import type { Actor } from "@/lib/auth/actor";
import { QrRequestError } from "@/lib/qr/type-module";
/**
* Sämtliche Berechtigungsregeln der Anwendung — an einer Stelle.
*
* Jeder schreibende oder lesende Zugriff geht durch `can` bzw. `require`.
* Die Oberfläche blendet Knöpfe zwar zusätzlich aus, aber das ist nur
* Bequemlichkeit: verlassen wird sich ausschließlich auf diese Prüfungen.
*/
export type Action =
/** Codes einer Gruppe sehen und Bilder herunterladen. */
| "codes:read"
/** Dynamische Codes anlegen. */
| "codes:create"
/** Ziel, Name oder Gestaltung ändern. */
| "codes:update"
/** Codes löschen. */
| "codes:delete"
/** Mitglieder und Rollen einer Gruppe verwalten. */
| "group:manage"
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
| "instance:manage";
export type Scope = { groupId?: string };
const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 };
export const ROLE_LABELS: Record<Role, string> = {
ADMIN: "Administrator",
EDITOR: "Bearbeiter",
VIEWER: "Betrachter",
};
export const ROLE_DESCRIPTIONS: Record<Role, string> = {
ADMIN: "Darf alles in der Gruppe, einschließlich Mitglieder und Rollen.",
EDITOR: "Darf Codes anlegen, ändern und löschen.",
VIEWER: "Darf Codes ansehen und herunterladen, aber nichts ändern.",
};
/** Rolle des Actors in einer bestimmten Gruppe, falls vorhanden. */
export function roleInGroup(actor: Actor, groupId: string): Role | null {
if (actor.kind !== "user") return null;
return (
actor.user.memberships.find((m) => m.groupId === groupId)?.role ?? null
);
}
/** Alle Gruppen, die der Actor sehen darf. `null` heißt: alle. */
export function visibleGroupIds(actor: Actor): string[] | null {
if (actor.kind === "system") return null;
if (actor.kind === "anonymous") return [];
if (actor.user.isInstanceAdmin) return null;
return actor.user.memberships.map((m) => m.groupId);
}
/** Gruppen, in denen der Actor Codes anlegen darf. */
export function writableGroupIds(actor: Actor): string[] | null {
if (actor.kind === "system") return null;
if (actor.kind === "anonymous") return [];
if (actor.user.isInstanceAdmin) return null;
return actor.user.memberships
.filter((m) => ROLE_RANK[m.role] >= ROLE_RANK.EDITOR)
.map((m) => m.groupId);
}
export function can(actor: Actor, action: Action, scope: Scope = {}): boolean {
if (actor.kind === "system") return true;
if (actor.kind === "anonymous") return false;
// Der Instanz-Administrator darf alles — er verwaltet die Installation.
if (actor.user.isInstanceAdmin) return true;
if (action === "instance:manage") return false;
// Alle übrigen Aktionen beziehen sich auf eine Gruppe.
if (!scope.groupId) return false;
const role = roleInGroup(actor, scope.groupId);
if (!role) return false;
switch (action) {
case "codes:read":
return true; // jede Rolle in der Gruppe darf lesen
case "codes:create":
case "codes:update":
case "codes:delete":
return ROLE_RANK[role] >= ROLE_RANK.EDITOR;
case "group:manage":
return role === "ADMIN";
default:
return false;
}
}
const DENIED_MESSAGES: Record<Action, string> = {
"codes:read": "Du hast keinen Zugriff auf die Codes dieser Gruppe.",
"codes:create":
"Zum Anlegen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"codes:update":
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"codes:delete":
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
"group:manage":
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
"instance:manage":
"Diesen Bereich darf nur ein Administrator der Installation verwalten.",
};
/** Wie `can`, wirft aber mit passendem Status und deutscher Meldung. */
export function requirePermission(
actor: Actor,
action: Action,
scope: Scope = {},
): void {
if (can(actor, action, scope)) return;
if (actor.kind === "anonymous") {
throw new QrRequestError("Bitte melde dich an.", 401);
}
throw new QrRequestError(DENIED_MESSAGES[action], 403);
}
+130
View File
@@ -0,0 +1,130 @@
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { prisma } from "@/lib/db";
export const SESSION_COOKIE = "qrgen_session";
/** Wie lange eine Sitzung ohne Aktivität gültig bleibt. */
const SESSION_DAYS = 30;
/**
* Der Cookie enthält ein Zufallstoken, die Datenbank nur dessen SHA-256.
* Wer die Sitzungstabelle liest, kann sich damit also nicht anmelden.
*/
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
function expiryFromNow(): Date {
return new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
}
export type SessionUser = {
id: string;
email: string;
name: string | null;
isInstanceAdmin: boolean;
memberships: { groupId: string; groupName: string; role: "ADMIN" | "EDITOR" | "VIEWER" }[];
};
/** Legt eine Sitzung an und setzt den Cookie. */
export async function createSession(userId: string): Promise<void> {
const token = randomBytes(32).toString("base64url");
await prisma.session.create({
data: { tokenHash: hashToken(token), userId, expiresAt: expiryFromNow() },
});
const store = await cookies();
store.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
// `secure` nur hinter HTTPS — im LAN läuft die Anwendung oft über
// einfaches HTTP, dort würde der Cookie sonst gar nicht gesetzt.
secure: (process.env.BASE_URL ?? "").startsWith("https://"),
expires: expiryFromNow(),
});
}
/** Beendet die aktuelle Sitzung und löscht den Cookie. */
export async function destroySession(): Promise<void> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (token) {
await prisma.session
.deleteMany({ where: { tokenHash: hashToken(token) } })
.catch(() => undefined);
}
store.delete(SESSION_COOKIE);
}
/** Beendet alle Sitzungen eines Benutzers — etwa beim Deaktivieren. */
export async function destroyAllSessions(userId: string): Promise<void> {
await prisma.session.deleteMany({ where: { userId } });
}
/**
* Liest die aktuelle Sitzung. Liefert `null`, wenn kein oder ein ungültiger
* Cookie vorliegt, die Sitzung abgelaufen ist oder der Benutzer inzwischen
* deaktiviert wurde.
*/
export async function getSessionUser(): Promise<SessionUser | null> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return null;
const session = await prisma.session.findUnique({
where: { tokenHash: hashToken(token) },
include: {
user: {
include: { memberships: { include: { group: { select: { name: true } } } } },
},
},
});
if (!session) return null;
if (session.expiresAt.getTime() < Date.now()) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
return null;
}
if (!session.user.isActive) return null;
// Gleitendes Ablaufdatum, aber höchstens einmal pro Stunde schreiben —
// sonst gäbe es bei jedem Seitenaufruf einen Schreibvorgang.
const anHourAgo = Date.now() - 60 * 60 * 1000;
if (session.lastSeenAt.getTime() < anHourAgo) {
await prisma.session
.update({
where: { id: session.id },
data: { lastSeenAt: new Date(), expiresAt: expiryFromNow() },
})
.catch(() => undefined);
}
return {
id: session.user.id,
email: session.user.email,
name: session.user.name,
isInstanceAdmin: session.user.isInstanceAdmin,
memberships: session.user.memberships.map((membership) => ({
groupId: membership.groupId,
groupName: membership.group.name,
role: membership.role,
})),
};
}
/** Räumt abgelaufene Sitzungen auf. */
export async function pruneExpiredSessions(): Promise<number> {
const result = await prisma.session.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return result.count;
}
+12 -1
View File
@@ -26,7 +26,13 @@ function asRecord(value: unknown): Record<string, unknown> {
: {};
}
export function toQrCodeDto(code: QrCode): QrCodeDto {
/** Ein Code samt der Beziehungen, die für die Anzeige gebraucht werden. */
export type QrCodeWithRelations = QrCode & {
group?: { name: string } | null;
createdBy?: { name: string | null; email: string } | null;
};
export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
const data = asRecord(code.data);
const qrType = getQrType(code.type);
@@ -65,5 +71,10 @@ export function toQrCodeDto(code: QrCode): QrCodeDto {
lastScanAt: code.lastScanAt?.toISOString() ?? null,
createdAt: code.createdAt.toISOString(),
updatedAt: code.updatedAt.toISOString(),
groupId: code.groupId,
groupName: code.group?.name ?? null,
createdByName: code.createdBy
? (code.createdBy.name?.trim() || code.createdBy.email)
: null,
};
}
+5
View File
@@ -28,4 +28,9 @@ export type QrCodeDto = {
lastScanAt: string | null;
createdAt: string;
updatedAt: string;
/** Gruppe, der der Code gehört. */
groupId: string;
groupName: string | null;
/** Wer ihn angelegt hat — leer bei Codes aus der Zeit ohne Anmeldung. */
createdByName: string | null;
};
+30 -9
View File
@@ -1,23 +1,44 @@
import { QrRenderError } from "@/lib/qr/render";
import { QrRequestError } from "@/lib/qr/request";
import { QrTypeError } from "@/lib/qr/type-module";
export function jsonError(message: string, status = 400): Response {
return Response.json({ error: message }, { status });
}
/**
* Erkennt Fehler, deren Meldung an den Aufrufer gehen darf.
*
* Bewusst über eine Eigenschaft statt über `instanceof`: die Fehlerklassen
* stehen in einem Modul, das sowohl im Server- als auch im Client-Bündel
* landet. Dann existieren zwei Klassenidentitäten, und ein
* `instanceof`-Vergleich würde eine saubere 400er-Antwort stillschweigend zu
* einem 500er machen.
*/
function asUserFacing(
error: unknown,
): { message: string; status: number } | null {
if (
typeof error !== "object" ||
error === null ||
(error as { userFacing?: unknown }).userFacing !== true
) {
return null;
}
const message = (error as { message?: unknown }).message;
if (typeof message !== "string") return null;
const status = (error as { status?: unknown }).status;
return { message, status: typeof status === "number" ? status : 400 };
}
/**
* Übersetzt eine Ausnahme in eine Antwort mit deutscher Fehlermeldung.
* Unerwartete Fehler werden protokolliert, nach außen aber generisch
* gemeldet.
*/
export function errorResponse(error: unknown): Response {
if (error instanceof QrRequestError) {
return jsonError(error.message, error.status);
}
if (error instanceof QrRenderError || error instanceof QrTypeError) {
return jsonError(error.message, 400);
const userFacing = asUserFacing(error);
if (userFacing) {
return jsonError(userFacing.message, userFacing.status);
}
console.error("Unerwarteter Fehler in einer API-Route:", error);
return jsonError(
"Es ist ein unerwarteter Fehler aufgetreten. Bitte versuche es erneut.",
+2 -1
View File
@@ -3,9 +3,10 @@ import "server-only";
import QRCode, { type QRCodeRenderersOptions } from "qrcode";
import type { QrDesign } from "@/lib/qr/design";
import { UserFacingError } from "@/lib/qr/type-module";
/** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */
export class QrRenderError extends Error {}
export class QrRenderError extends UserFacingError {}
function toOptions(design: QrDesign): QRCodeRenderersOptions {
return {
+24 -9
View File
@@ -44,7 +44,29 @@ export type ValidationResult =
* Erzeugungs-Fehler mit einer Meldung, die direkt in der Oberfläche
* angezeigt werden kann.
*/
export class QrTypeError extends Error {}
/**
* Basisklasse für alle Fehler, deren Meldung unverändert an die Oberfläche
* gehen darf.
*
* Das Merkmal `userFacing` ist bewusst eine Eigenschaft und keine Frage von
* `instanceof`: dieses Modul landet sowohl im Server- als auch im
* Client-Bündel, und dann gibt es zwei Klassenidentitäten. Ein
* `instanceof`-Vergleich schlägt in dem Fall fehl, und eine saubere
* 400er-Antwort würde stillschweigend zu einem 500er.
*/
export class UserFacingError extends Error {
readonly userFacing = true;
constructor(
message: string,
readonly status: number = 400,
) {
super(message);
this.name = new.target.name;
}
}
export class QrTypeError extends UserFacingError {}
/**
* Was über den Zugriff bekannt ist, der die Weiterleitung ausgelöst hat.
@@ -58,14 +80,7 @@ export type RedirectContext = {
* Fehler mit einer Meldung, die unverändert an die Oberfläche geht, plus
* dem passenden HTTP-Status.
*/
export class QrRequestError extends Error {
constructor(
message: string,
readonly status: number = 400,
) {
super(message);
}
}
export class QrRequestError extends UserFacingError {}
/**
* Die Schnittstelle, über die der Rest der Anwendung mit einem QR-Typ