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:
@@ -13,6 +13,14 @@ BASE_URL=http://localhost:3000
|
|||||||
# Host-Port, unter dem die App veröffentlicht wird (Port-Mapping APP_PORT:3000).
|
# Host-Port, unter dem die App veröffentlicht wird (Port-Mapping APP_PORT:3000).
|
||||||
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 ------------------------------------------------------------
|
# --- PostgreSQL ------------------------------------------------------------
|
||||||
POSTGRES_USER=qrcode
|
POSTGRES_USER=qrcode
|
||||||
POSTGRES_PASSWORD=bitte-aendern
|
POSTGRES_PASSWORD=bitte-aendern
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ ausblenden.
|
|||||||
- [Aktualisieren](#aktualisieren)
|
- [Aktualisieren](#aktualisieren)
|
||||||
- [Konfiguration](#konfiguration)
|
- [Konfiguration](#konfiguration)
|
||||||
- [Bedienung](#bedienung)
|
- [Bedienung](#bedienung)
|
||||||
|
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
|
||||||
- [Technischer Aufbau](#technischer-aufbau)
|
- [Technischer Aufbau](#technischer-aufbau)
|
||||||
- [Entwicklung ohne Docker](#entwicklung-ohne-docker)
|
- [Entwicklung ohne Docker](#entwicklung-ohne-docker)
|
||||||
- [API](#api)
|
- [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` |
|
| `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` |
|
| `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 | — |
|
| `DATABASE_URL` | Verbindungsstring der App zur Datenbank | — |
|
||||||
| `POSTGRES_USER` | Datenbankbenutzer | `qrcode` |
|
| `POSTGRES_USER` | Datenbankbenutzer | `qrcode` |
|
||||||
| `POSTGRES_PASSWORD` | Datenbankpasswort (**muss gesetzt sein**) | — |
|
| `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
|
## Technischer Aufbau
|
||||||
|
|
||||||
### Stack
|
### 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;
|
||||||
+96
-3
@@ -10,6 +10,90 @@ datasource db {
|
|||||||
provider = "postgresql"
|
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.
|
/// Freigabe eines QR-Typs durch die Verwaltung.
|
||||||
///
|
///
|
||||||
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
|
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
|
||||||
@@ -26,9 +110,8 @@ model QrTypeSetting {
|
|||||||
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
|
/// 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.
|
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
|
||||||
///
|
///
|
||||||
/// `type` + `data` sind bewusst generisch gehalten: V1 kennt nur den Typ
|
/// `type` + `data` sind bewusst generisch gehalten: die Typ-Module legen ihr
|
||||||
/// "website" (`data = { url: "https://…" }`), V2-Typen (vCard, WLAN, …)
|
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
||||||
/// legen ihr eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
|
||||||
model QrCode {
|
model QrCode {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
slug String @unique
|
slug String @unique
|
||||||
@@ -45,5 +128,15 @@ model QrCode {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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([createdAt])
|
||||||
|
@@index([groupId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
import {
|
import {
|
||||||
getQrTypeAvailability,
|
getQrTypeAvailability,
|
||||||
@@ -18,6 +20,7 @@ const updateSchema = z.object({
|
|||||||
/** Zustand aller QR-Typen für die Verwaltung. */
|
/** Zustand aller QR-Typen für die Verwaltung. */
|
||||||
export async function GET(): Promise<Response> {
|
export async function GET(): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
const types = await getQrTypeAvailability();
|
const types = await getQrTypeAvailability();
|
||||||
return Response.json({
|
return Response.json({
|
||||||
types: types.map(({ qrType, implemented, enabled, usable }) => ({
|
types: types.map(({ qrType, implemented, enabled, usable }) => ({
|
||||||
@@ -38,6 +41,8 @@ export async function GET(): Promise<Response> {
|
|||||||
/** Einen Typ freigeben oder abschalten. */
|
/** Einen Typ freigeben oder abschalten. */
|
||||||
export async function PATCH(request: NextRequest): Promise<Response> {
|
export async function PATCH(request: NextRequest): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
const parsed = updateSchema.safeParse(await request.json());
|
const parsed = updateSchema.safeParse(await request.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return jsonError(firstZodMessage(parsed.error));
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { shortUrl } from "@/lib/env";
|
import { shortUrl } from "@/lib/env";
|
||||||
import { errorResponse } from "@/lib/http";
|
import { errorResponse } from "@/lib/http";
|
||||||
@@ -32,6 +34,10 @@ export async function GET(
|
|||||||
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
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({
|
const design = designSchema.parse({
|
||||||
fgColor: code.fgColor,
|
fgColor: code.fgColor,
|
||||||
bgColor: code.bgColor,
|
bgColor: code.bgColor,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { NextRequest } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
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 { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
@@ -14,6 +16,11 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
type Context = { params: Promise<{ id: string }> };
|
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({
|
const updateSchema = z.object({
|
||||||
data: z.record(z.string(), z.unknown()).optional(),
|
data: z.record(z.string(), z.unknown()).optional(),
|
||||||
design: designSchema.optional(),
|
design: designSchema.optional(),
|
||||||
@@ -26,7 +33,10 @@ const updateSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function loadCode(id: string) {
|
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) {
|
if (!code) {
|
||||||
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
||||||
}
|
}
|
||||||
@@ -39,7 +49,10 @@ export async function GET(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const { id } = await context.params;
|
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) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
@@ -52,12 +65,16 @@ export async function PATCH(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
|
const actor = await getActor();
|
||||||
|
|
||||||
const parsed = updateSchema.safeParse(await request.json());
|
const parsed = updateSchema.safeParse(await request.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return jsonError(firstZodMessage(parsed.error));
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await loadCode(id);
|
const existing = await loadCode(id);
|
||||||
|
requirePermission(actor, "codes:update", { groupId: existing.groupId });
|
||||||
|
|
||||||
const update: Prisma.QrCodeUpdateInput = {};
|
const update: Prisma.QrCodeUpdateInput = {};
|
||||||
|
|
||||||
if (parsed.data.data !== undefined) {
|
if (parsed.data.data !== undefined) {
|
||||||
@@ -91,7 +108,11 @@ export async function PATCH(
|
|||||||
return jsonError("Es wurden keine Änderungen übermittelt.");
|
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) });
|
return Response.json({ code: toQrCodeDto(updated) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
@@ -104,7 +125,10 @@ export async function DELETE(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const { id } = await context.params;
|
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 } });
|
await prisma.qrCode.delete({ where: { id } });
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
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 { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
@@ -21,6 +24,8 @@ const createSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||||
.optional(),
|
.optional(),
|
||||||
|
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
|
||||||
|
groupId: z.string().trim().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function isUniqueViolation(error: unknown): boolean {
|
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> {
|
export async function GET(): Promise<Response> {
|
||||||
try {
|
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) });
|
return Response.json({ codes: codes.map(toQrCodeDto) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(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. */
|
/** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */
|
||||||
export async function POST(request: NextRequest): Promise<Response> {
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
const actor = await getActor();
|
||||||
|
|
||||||
const parsed = createSchema.safeParse(await request.json());
|
const parsed = createSchema.safeParse(await request.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return jsonError(firstZodMessage(parsed.error));
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, design, label } = parsed.data;
|
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 qrType = await requireAvailableQrType(type);
|
||||||
|
|
||||||
const validated = qrType.validate(parsed.data.data);
|
const validated = qrType.validate(parsed.data.data);
|
||||||
@@ -70,8 +96,14 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
type: qrType.id,
|
type: qrType.id,
|
||||||
label: label && label.length > 0 ? label : null,
|
label: label && label.length > 0 ? label : null,
|
||||||
data: toJsonInput(validated.data),
|
data: toJsonInput(validated.data),
|
||||||
|
groupId,
|
||||||
|
createdById: actor.kind === "user" ? actor.user.id : null,
|
||||||
...design,
|
...design,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
group: { select: { name: true } },
|
||||||
|
createdBy: { select: { name: true, email: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +18,10 @@ const downloadSchema = qrRequestSchema.extend({
|
|||||||
/** Liefert den fertigen QR-Code als PNG- oder SVG-Datei zum Herunterladen. */
|
/** Liefert den fertigen QR-Code als PNG- oder SVG-Datei zum Herunterladen. */
|
||||||
export async function POST(request: NextRequest): Promise<Response> {
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
try {
|
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());
|
const parsed = downloadSchema.safeParse(await request.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return jsonError(firstZodMessage(parsed.error));
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
import { renderSvg } from "@/lib/qr/render";
|
import { renderSvg } from "@/lib/qr/render";
|
||||||
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
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> {
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
try {
|
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());
|
const parsed = qrRequestSchema.safeParse(await request.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return jsonError(firstZodMessage(parsed.error));
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
|||||||
|
|
||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { QrGenerator } from "@/components/qr-generator";
|
import { QrGenerator } from "@/components/qr-generator";
|
||||||
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
import { getQrTypeState } from "@/lib/qr/availability";
|
import { getQrTypeState } from "@/lib/qr/availability";
|
||||||
import { getQrType } from "@/lib/qr/registry";
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ function Hinweis({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function CreatePage({ params }: Props) {
|
export default async function CreatePage({ params }: Props) {
|
||||||
|
await requirePageActor();
|
||||||
const { type } = await params;
|
const { type } = await params;
|
||||||
const state = await getQrTypeState(type);
|
const state = await getQrTypeState(type);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+44
-14
@@ -1,7 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { LogoutButton } from "@/components/auth-forms";
|
||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
|
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
|
||||||
|
import { can } from "@/lib/auth/policy";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
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.",
|
"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 (
|
return (
|
||||||
<html lang="de" className="h-full">
|
<html lang="de" className="h-full">
|
||||||
<body className="flex min-h-full flex-col">
|
<body className="flex min-h-full flex-col">
|
||||||
@@ -30,25 +44,41 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
Navigation nicht mehr in eine Zeile. */}
|
Navigation nicht mehr in eine Zeile. */}
|
||||||
<span className="hidden sm:inline">QR-Code-Generator</span>
|
<span className="hidden sm:inline">QR-Code-Generator</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-0.5 text-sm font-medium sm:gap-1">
|
|
||||||
<Link
|
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
|
||||||
href="/"
|
{signedIn ? (
|
||||||
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
<>
|
||||||
>
|
<Link href="/" className={NAV_LINK}>
|
||||||
Erstellen
|
Erstellen
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link href="/meine-codes" className={NAV_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
|
Meine Codes
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
{showAdmin ? (
|
||||||
href="/verwaltung/typen"
|
<Link href="/verwaltung" className={NAV_LINK}>
|
||||||
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
|
||||||
>
|
|
||||||
Verwaltung
|
Verwaltung
|
||||||
</Link>
|
</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import { CodesList } from "@/components/codes-list";
|
import { CodesList } from "@/components/codes-list";
|
||||||
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Meine Codes" };
|
export const metadata: Metadata = { title: "Meine Codes" };
|
||||||
|
|
||||||
export default function MyCodesPage() {
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function MyCodesPage() {
|
||||||
|
await requirePageActor();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
<div className="mb-7">
|
<div className="mb-7">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
|
|
||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { Badge, Notice } from "@/components/ui";
|
import { Badge, Notice } from "@/components/ui";
|
||||||
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
import { getVisibleQrTypes } from "@/lib/qr/availability";
|
import { getVisibleQrTypes } from "@/lib/qr/availability";
|
||||||
|
|
||||||
// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
|
// 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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
|
await requirePageActor();
|
||||||
const types = await getVisibleQrTypes();
|
const types = await getVisibleQrTypes();
|
||||||
const usableCount = types.filter((entry) => entry.usable).length;
|
const usableCount = types.filter((entry) => entry.usable).length;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
|||||||
|
|
||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
|
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
|
||||||
|
import { requirePagePermission } from "@/lib/auth/guard";
|
||||||
import { getQrTypeAvailability } from "@/lib/qr/availability";
|
import { getQrTypeAvailability } from "@/lib/qr/availability";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "QR-Typen" };
|
export const metadata: Metadata = { title: "QR-Typen" };
|
||||||
@@ -10,6 +11,7 @@ export const metadata: Metadata = { title: "QR-Typen" };
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function TypeSettingsPage() {
|
export default async function TypeSettingsPage() {
|
||||||
|
await requirePagePermission("instance:manage");
|
||||||
const availability = await getQrTypeAvailability();
|
const availability = await getQrTypeAvailability();
|
||||||
|
|
||||||
const initial: TypeSetting[] = availability.map(
|
const initial: TypeSetting[] = availability.map(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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.`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 data = asRecord(code.data);
|
||||||
const qrType = getQrType(code.type);
|
const qrType = getQrType(code.type);
|
||||||
|
|
||||||
@@ -65,5 +71,10 @@ export function toQrCodeDto(code: QrCode): QrCodeDto {
|
|||||||
lastScanAt: code.lastScanAt?.toISOString() ?? null,
|
lastScanAt: code.lastScanAt?.toISOString() ?? null,
|
||||||
createdAt: code.createdAt.toISOString(),
|
createdAt: code.createdAt.toISOString(),
|
||||||
updatedAt: code.updatedAt.toISOString(),
|
updatedAt: code.updatedAt.toISOString(),
|
||||||
|
groupId: code.groupId,
|
||||||
|
groupName: code.group?.name ?? null,
|
||||||
|
createdByName: code.createdBy
|
||||||
|
? (code.createdBy.name?.trim() || code.createdBy.email)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,4 +28,9 @@ export type QrCodeDto = {
|
|||||||
lastScanAt: string | null;
|
lastScanAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: 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
@@ -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 {
|
export function jsonError(message: string, status = 400): Response {
|
||||||
return Response.json({ error: message }, { status });
|
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.
|
* Übersetzt eine Ausnahme in eine Antwort mit deutscher Fehlermeldung.
|
||||||
* Unerwartete Fehler werden protokolliert, nach außen aber generisch
|
* Unerwartete Fehler werden protokolliert, nach außen aber generisch
|
||||||
* gemeldet.
|
* gemeldet.
|
||||||
*/
|
*/
|
||||||
export function errorResponse(error: unknown): Response {
|
export function errorResponse(error: unknown): Response {
|
||||||
if (error instanceof QrRequestError) {
|
const userFacing = asUserFacing(error);
|
||||||
return jsonError(error.message, error.status);
|
if (userFacing) {
|
||||||
}
|
return jsonError(userFacing.message, userFacing.status);
|
||||||
if (error instanceof QrRenderError || error instanceof QrTypeError) {
|
|
||||||
return jsonError(error.message, 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("Unerwarteter Fehler in einer API-Route:", error);
|
console.error("Unerwarteter Fehler in einer API-Route:", error);
|
||||||
return jsonError(
|
return jsonError(
|
||||||
"Es ist ein unerwarteter Fehler aufgetreten. Bitte versuche es erneut.",
|
"Es ist ein unerwarteter Fehler aufgetreten. Bitte versuche es erneut.",
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import "server-only";
|
|||||||
import QRCode, { type QRCodeRenderersOptions } from "qrcode";
|
import QRCode, { type QRCodeRenderersOptions } from "qrcode";
|
||||||
|
|
||||||
import type { QrDesign } from "@/lib/qr/design";
|
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. */
|
/** 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 {
|
function toOptions(design: QrDesign): QRCodeRenderersOptions {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -44,7 +44,29 @@ export type ValidationResult =
|
|||||||
* Erzeugungs-Fehler mit einer Meldung, die direkt in der Oberfläche
|
* Erzeugungs-Fehler mit einer Meldung, die direkt in der Oberfläche
|
||||||
* angezeigt werden kann.
|
* 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.
|
* 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
|
* Fehler mit einer Meldung, die unverändert an die Oberfläche geht, plus
|
||||||
* dem passenden HTTP-Status.
|
* dem passenden HTTP-Status.
|
||||||
*/
|
*/
|
||||||
export class QrRequestError extends Error {
|
export class QrRequestError extends UserFacingError {}
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
readonly status: number = 400,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Schnittstelle, über die der Rest der Anwendung mit einem QR-Typ
|
* Die Schnittstelle, über die der Rest der Anwendung mit einem QR-Typ
|
||||||
|
|||||||
Reference in New Issue
Block a user