3 Commits
Author SHA1 Message Date
Florian Wilde 3cb5421a20 QR-Typen je Gruppe freigeben
Bisher galt die Freigabe eines Typs fuer die ganze Installation. Bei
mehreren Gruppen ist das zu grob: Die Marketingabteilung braucht Social
Media, die Werkstatt nur Website und PDF.

Die Freigabe bekommt eine zweite Ebene. Die Grundeinstellung bleibt die
Grundlage; je Gruppe laesst sich davon abweichen. Fehlt eine Abweichung,
folgt die Gruppe der Grundeinstellung.

Zur offenen Frage im Issue: Die globale Abschaltung gewinnt immer. Ein
global abgeschalteter Typ laesst sich in keiner Gruppe einschalten — der
Schalter steht dort sichtbar still und nennt den Grund. Andersherum waere
"hier abgeschaltet" keine verlaessliche Aussage mehr.

Eine eigene Tabelle statt eines zusammengesetzten Schluessels in
QrTypeSetting: Der globale Fall braeuchte dort eine leere Gruppe, und
PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
nebeneinander zu — die Eindeutigkeit der globalen Zeile waere also gerade
nicht gesichert.

Die Startseite zeigt die Vereinigung ueber die eigenen Gruppen; beim
Anlegen entscheidet die gewaehlte Gruppe, und die Route prueft das noch
einmal.

Refs #11
2026-08-21 03:48:22 +02:00
florian.wilde b96e9ddbe5 Merge #9: Versionshinweis mit Aenderungsverlauf 2026-08-21 00:06:30 +00:00
Florian Wilde 586e1f042f Versionshinweis mit Aenderungsverlauf
Bisher liess sich der laufenden Anwendung nicht ansehen, welche Fassung sie
ist. Nach einem Update war damit auch nicht zu erkennen, ob es angekommen
ist — die haeufigste Frage im Betrieb.

Die Fussleiste nennt jetzt die Version und fuehrt auf /version mit dem
vollstaendigen Verlauf. Zusammengetragen ist er aus dem, was es schon gab:
dem Abschnitt „Gebaut" der Roadmap und der Git-Historie.

Gepflegt wird der Verlauf an genau einer Stelle, src/lib/changelog.ts. Daraus
entsteht sowohl die Seite als auch die CHANGELOG.md (npm run changelog); eine
Pruefung mit --check wacht darueber, dass beides zusammenpasst. Zwei gepflegte
Listen liefen unweigerlich auseinander, und ein Changelog, der nicht stimmt,
ist schlimmer als keiner.

Die Historie hatte keine Tags. Die Eintraege sind deshalb nach Erscheinungstag
zu Versionen zusammengefasst — die Nummern sind nachtraeglich vergeben, die
Daten stammen aus den Commits. Zu jeder Version gibt es jetzt einen
annotierten Tag im Repo; v1.6.0 folgt beim Merge.

Die Roadmap fuehrt den Verlauf nicht mehr doppelt, sondern verweist darauf und
behaelt den Blick nach vorn.

Refs #9
2026-08-21 01:58:24 +02:00
18 changed files with 835 additions and 142 deletions
+74
View File
@@ -0,0 +1,74 @@
# Änderungen
Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der
Anwendung unter **Version und Änderungen**, erreichbar über die
Versionsnummer in der Fußzeile.
> Diese Datei wird erzeugt: `npm run changelog`.
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
## 1.6.0 — 21.08.2026
Sichtbare Version mit Änderungsverlauf.
- Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.
- Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.
- Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.
## 1.5.0 — 20.08.2026
Statische Codes lassen sich benennen und ablegen.
- Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.
- Der Name bestimmt den Dateinamen beim Herunterladen.
- Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.
## 1.4.0 — 17.08.2026
Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.
- Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.
- Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.
- Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.
- Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.
- Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.
## 1.3.0 — 16.08.2026
Eigenes Erscheinungsbild und ein Dark Mode.
- Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.
- Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.
- Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.
- Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.
## 1.2.0 — 15.08.2026
Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.
- Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.
- Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.
- Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.
- Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.
## 1.1.0 — 14.08.2026
Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.
- Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.
- Landingpages als eigene Gattung für Typen ohne fremdes Ziel.
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.
- Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.
- Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.
- „Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.
- Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.
## 1.0.0 — 13.08.2026
Erste lauffähige Fassung.
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.
- QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.
- Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.
- Download als PNG und SVG, serverseitig gerendert.
- Update-Skript für Linux, macOS und Windows.
+26 -3
View File
@@ -34,6 +34,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
eigene Wahl. eigene Wahl.
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen, - **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
nachträglich änderbar — die vorherige Adresse bleibt gültig. nachträglich änderbar — die vorherige Adresse bleibt gültig.
- **Sichtbare Version:** Die Fußzeile nennt die laufende Fassung und führt
zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam.
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start - **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
von selbst; alles Nötige steht in einer `.env`. von selbst; alles Nötige steht in einer `.env`.
@@ -48,7 +50,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung, - **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
Rollen und der Weg zurück Rollen und der Weg zurück
- **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung - **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung
- **[Roadmap](docs/roadmap.md)** — was gebaut ist und was kommen könnte - **[Änderungen](CHANGELOG.md)** — was in welcher Version dazugekommen ist
- **[Roadmap](docs/roadmap.md)** — was als Nächstes kommen könnte
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht, - **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
Härtungen und was bewusst offen bleibt Härtungen und was bewusst offen bleibt
@@ -586,6 +589,26 @@ Zwei Eigenschaften sind dabei zu unterscheiden:
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
umgesetzt, erscheint als Kachel „Bald verfügbar“. umgesetzt, erscheint als Kachel „Bald verfügbar“.
#### Freigabe je Gruppe
Die Auswahl **Freigabe für** über der Liste hat zwei Stufen:
- **Alle Gruppen (Grundeinstellung)** — gilt für die ganze Installation.
- **Nur: \<Gruppe\>** — weicht für diese eine Gruppe davon ab. Die
Marketingabteilung bekommt Social Media, die Werkstatt nur Website und PDF.
Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung. **Die globale
Abschaltung gewinnt immer:** Ein Typ, der in der Grundeinstellung aus ist,
lässt sich in keiner Gruppe einschalten — der Schalter steht dort sichtbar
still und sagt warum. Andersherum wäre „hier abgeschaltet“ keine verlässliche
Aussage mehr, und wer den Schalter umlegt, müsste alle Gruppen durchsehen, um
zu wissen, was er getan hat.
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
in Werkstatt und Marketing ist, sieht beides. Welche Freigabe gilt, entscheidet
sich erst beim Anlegen — dort zählt die gewählte Gruppe, und die Route prüft
das noch einmal.
> **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab, > **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab,
> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter > leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter
> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also > bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also
@@ -961,8 +984,8 @@ deutschen Meldung.
| `DELETE` | `/api/codes/{id}` | Code löschen | | `DELETE` | `/api/codes/{id}` | Code löschen |
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) | | `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) |
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler | | `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
| `GET` | `/api/admin/types` | Zustand aller QR-Typen | | `GET` | `/api/admin/types` | Zustand aller QR-Typen (`?group={id}` je Gruppe) |
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten | | `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten (`groupId` optional) |
Beispiel: Beispiel:
+30 -1
View File
@@ -466,7 +466,7 @@ auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
| --- | --- | | --- | --- |
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links | | **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe | | **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
| **QR-Typen** | einzelne Typen ausblenden | | **QR-Typen** | einzelne Typen ausblenden — für alle oder je Gruppe |
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben | | **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen | | **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
| **Domains** | anlegen, Gruppen zuteilen, abschalten | | **Domains** | anlegen, Gruppen zuteilen, abschalten |
@@ -478,6 +478,20 @@ lassen sich bearbeiten. Nur neue lassen sich nicht mehr anlegen. Ohne Eintrag
in der Datenbank gilt ein Typ als freigegeben, damit eine frische Installation in der Datenbank gilt ein Typ als freigegeben, damit eine frische Installation
alles zeigt und Typen aus späteren Versionen automatisch erscheinen. alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
Die Freigabe hat **zwei Ebenen**. Die Grundeinstellung gilt für die ganze
Installation; je Gruppe lässt sich davon abweichen — die Marketingabteilung
bekommt Social Media, die Werkstatt nur Website und PDF. Fehlt eine Abweichung,
folgt die Gruppe der Grundeinstellung.
**Die globale Abschaltung gewinnt immer.** Ein Typ, der in der
Grundeinstellung aus ist, lässt sich in keiner Gruppe einschalten; der
Schalter steht dort sichtbar still und nennt den Grund. Andersherum wäre „hier
abgeschaltet“ keine verlässliche Aussage mehr.
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
in beiden Gruppen ist, sieht beides; welche Freigabe zählt, entscheidet sich
beim Anlegen anhand der gewählten Gruppe.
--- ---
## Betrieb ## Betrieb
@@ -500,6 +514,21 @@ alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
--- ---
## Version und Änderungen
Die Fußzeile nennt die laufende Version und führt auf eine Seite mit dem
vollständigen Änderungsverlauf. Nach einem Update ist das die schnellste
Antwort auf die Frage, ob der Container wirklich mit dem neuen Abbild läuft:
Steht dort noch die alte Nummer, ist er es nicht.
Der Verlauf wird an **einer** Stelle gepflegt (`src/lib/changelog.ts`).
Daraus entstehen sowohl die Seite in der Anwendung als auch die
[CHANGELOG.md](../CHANGELOG.md) im Projekt (`npm run changelog`); eine
Prüfung wacht darüber, dass beides zusammenpasst. Zu jeder Version gibt es
einen Git-Tag.
---
## Schnittstelle ## Schnittstelle
Alles, was die Oberfläche tut, geht auch über HTTP: Alles, was die Oberfläche tut, geht auch über HTTP:
+9 -87
View File
@@ -7,94 +7,16 @@ Aufwände sind grobe Einschätzungen, keine Zusagen.
## Gebaut ## Gebaut
### Version 1 — Grundgerüst (13.08.2026) Der vollständige Verlauf mit Versionsnummern steht im
[Changelog](../CHANGELOG.md) — und in der Anwendung selbst unter
**Version und Änderungen**, erreichbar über die Versionsnummer in der
Fußzeile.
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, ohne Reverse Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
Proxy, Migrationen beim Start Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
- QR-Typ-Registry als Dispatch-Tabelle — ein neuer Typ ist ein Modul plus ein 1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
Formular, ohne Eingriff in den übrigen Code und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes
- Typ **Website**, statisch und dynamisch, mit Kurz-URL, 302-Weiterleitung und und 1.6.0 diesen Versionshinweis.
Scan-Zähler
- Gestaltung: Farben mit Kontrastwarnung, Größe, Fehlerkorrektur, Rand
- Download als PNG und SVG, serverseitig gerendert
- „Meine Codes“ mit Ziel ändern und Löschen
- Deutsche Oberfläche, deutsche Fehlermeldungen, Validierung mit Zod
- Update-Skript für Linux/macOS und Windows
### Typen einzeln freigeben (14.08.2026)
Ausblenden nicht gebrauchter Typen in der Verwaltung. Bereits erstellte Codes
eines abgeschalteten Typs funktionieren weiter.
### Phase A — acht weitere Typen (14.08.2026)
vCard, App-Link, WhatsApp, E-Mail, SMS, Standort, Zahlung (GiroCode und
Zahlungslink), WLAN.
### Phase B1 — Anmeldung, Gruppen, Rollen (14.08.2026)
Abschaltbare Anmeldung, Benutzerverwaltung, Gruppen mit den Rollen
Administrator, Bearbeiter und Betrachter, Sitzungen in der Datenbank,
scrypt-Passwörter, Sperre nach Fehlversuchen.
### Phase B2 — mehrere Domains (14.08.2026)
Domains für Kurz-URLs, den Gruppen zugeteilt. Slug je Domain eindeutig,
Host-Header gegen die registrierten Domains geprüft.
### Listenansicht (14.08.2026)
„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter — gedacht für
viele Projekte nebeneinander. Karten bleiben als zweite Ansicht.
### Phase C — die letzten sechs Typen (14.08.2026)
Mehrere Links, Social Media, Video, PDF, Bild, Feedback. Dazu:
- Landingpages als eigene Gattung (`/s/{slug}`), ohne App-Navigation
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen
- Feedback mit anonymer Erhebung und Auswertung
Damit sind **alle 15 vorgesehenen Typen** gebaut.
### Single Sign-on (15.08.2026)
Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem
ID-Token.
### Erscheinungsbild (16.08.2026)
Name, Logo, Favicon und Akzentfarbe im Admin-Bereich einstellbar. Die Farbe
wirkt über die Farbvariablen, die Tailwind ohnehin benutzt — deshalb ohne
Umbau der rund 60 eingefärbten Stellen.
### Dark Mode (16.08.2026)
Folgt dem Betriebssystem, mit Umschalter für Hell, Dunkel und System.
Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne
Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl.
### Sprechende Kurz-Links (17.08.2026)
Kurz-Links selbst vergeben und nachträglich ändern, mit `/` als Trenner. Die
vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — sonst würde
jedes Umbenennen gedruckte Codes entwerten. Standardmäßig Administratoren
vorbehalten, per Einstellung für Bearbeiter freigebbar.
Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1).
### Statische Codes benennen und ablegen (20.08.2026)
Das Namensfeld gilt in beiden Betriebsarten. Es bestimmt den Dateinamen beim
Herunterladen und erlaubt, einen statischen Code in „Meine Codes" abzulegen —
als Schnappschuss, dessen Inhalt sich nicht mehr ändert.
Schließt [Issue #2](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/2).
### Dokumentation (15.08.2026)
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
Die Bilder erzeugt ein Skript, damit sie nicht veralten.
--- ---
+2 -1
View File
@@ -1,11 +1,12 @@
{ {
"name": "qrcode-generator", "name": "qrcode-generator",
"version": "1.1.0", "version": "1.6.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"changelog": "node scripts/changelog.mjs",
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"db:generate": "prisma generate", "db:generate": "prisma generate",
@@ -0,0 +1,25 @@
-- QR-Typen lassen sich je Gruppe abschalten.
--
-- Die globale Freigabe in "QrTypeSetting" bleibt die Grundlage. Diese Tabelle
-- traegt nur Abweichungen davon: Fehlt eine Zeile, folgt die Gruppe der
-- globalen Einstellung. Eine globale Abschaltung laesst sich hierueber nicht
-- aufheben — global aus gewinnt immer.
--
-- Eigene Tabelle statt eines zusammengesetzten Schluessels in
-- "QrTypeSetting": Der globale Fall braeuchte dort eine leere Gruppe, und
-- PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
-- nebeneinander zu.
CREATE TABLE "QrTypeGroupSetting" (
"type" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "QrTypeGroupSetting_pkey" PRIMARY KEY ("type","groupId")
);
CREATE INDEX "QrTypeGroupSetting_groupId_idx" ON "QrTypeGroupSetting"("groupId");
-- Mit der Gruppe verschwindet auch ihre Abweichung.
ALTER TABLE "QrTypeGroupSetting" ADD CONSTRAINT "QrTypeGroupSetting_groupId_fkey"
FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+26
View File
@@ -87,6 +87,7 @@ model Group {
memberships Membership[] memberships Membership[]
codes QrCode[] codes QrCode[]
domainGrants DomainGrant[] domainGrants DomainGrant[]
typeSettings QrTypeGroupSetting[]
} }
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer /// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
@@ -155,6 +156,31 @@ model QrTypeSetting {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
/// Abweichung von der globalen Freigabe fuer eine einzelne Gruppe.
///
/// Bewusst eine eigene Tabelle statt eines zusammengesetzten Schluessels in
/// `QrTypeSetting`: Der globale Fall braeuchte dort eine leere Gruppe, und
/// PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
/// nebeneinander zu — die Eindeutigkeit der globalen Zeile waere also gerade
/// nicht gesichert.
///
/// Fehlt eine Zeile, folgt die Gruppe der globalen Einstellung. Eine Zeile
/// mit `enabled = false` schaltet den Typ **nur** in dieser Gruppe ab. Eine
/// Zeile mit `enabled = true` kann eine globale Abschaltung **nicht**
/// aufheben — global aus gewinnt immer.
model QrTypeGroupSetting {
/// Kennung aus der Typ-Registry, z. B. "website".
type String
groupId String
enabled Boolean
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@id([type, groupId])
@@index([groupId])
}
/// 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.
/// ///
+84
View File
@@ -0,0 +1,84 @@
/**
* Schreibt CHANGELOG.md aus src/lib/changelog.ts.
*
* node scripts/changelog.mjs erzeugen
* node scripts/changelog.mjs --check nur prüfen, ob die Datei aktuell ist
*
* Warum erzeugt und nicht von Hand gepflegt: Die Anwendung zeigt denselben
* Verlauf unter /version an. Zwei gepflegte Listen liefen unweigerlich
* auseinander — und ein Changelog, der nicht stimmt, ist schlimmer als keiner.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const wurzel = join(dirname(fileURLToPath(import.meta.url)), "..");
const ziel = join(wurzel, "CHANGELOG.md");
/**
* Die Quelle ist TypeScript, dieses Skript aber schlichtes Node. Statt einen
* Übersetzer dazuzunehmen, wird der Datenteil gelesen und ausgewertet — er
* besteht ausschließlich aus Zeichenketten und Listen.
*/
function ladeVerlauf() {
const quelle = readFileSync(join(wurzel, "src", "lib", "changelog.ts"), "utf8");
const anfang = quelle.indexOf("export const CHANGELOG");
if (anfang === -1) throw new Error("CHANGELOG-Liste nicht gefunden.");
const start = quelle.indexOf("[", anfang);
const ende = quelle.indexOf("\n];", start);
if (start === -1 || ende === -1) throw new Error("Liste nicht abgrenzbar.");
const literal = quelle.slice(start, ende + 2);
return Function(`"use strict"; return (${literal});`)();
}
function formatiereDatum(iso) {
const [jahr, monat, tag] = iso.split("-");
return `${tag}.${monat}.${jahr}`;
}
function baue(verlauf) {
const zeilen = [
"# Änderungen",
"",
"Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der",
"Anwendung unter **Version und Änderungen**, erreichbar über die",
"Versionsnummer in der Fußzeile.",
"",
"> Diese Datei wird erzeugt: `npm run changelog`.",
"> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.",
"",
];
for (const eintrag of verlauf) {
zeilen.push(`## ${eintrag.version} — ${formatiereDatum(eintrag.date)}`, "");
zeilen.push(eintrag.summary, "");
for (const änderung of eintrag.changes) zeilen.push(`- ${änderung}`);
zeilen.push("");
}
return zeilen.join("\n");
}
const inhalt = baue(ladeVerlauf());
if (process.argv.includes("--check")) {
let vorhanden = "";
try {
vorhanden = readFileSync(ziel, "utf8");
} catch {
console.error("CHANGELOG.md fehlt. Erzeugen mit: npm run changelog");
process.exit(1);
}
if (vorhanden !== inhalt) {
console.error(
"CHANGELOG.md passt nicht mehr zu src/lib/changelog.ts. Neu erzeugen mit: npm run changelog",
);
process.exit(1);
}
console.log("CHANGELOG.md ist aktuell.");
} else {
writeFileSync(ziel, inhalt, "utf8");
console.log("CHANGELOG.md geschrieben.");
}
+15 -4
View File
@@ -52,7 +52,12 @@ function Hinweis({
export default async function CreatePage({ params }: Props) { export default async function CreatePage({ params }: Props) {
const actor = await requirePageActor(); const actor = await requirePageActor();
const { type } = await params; const { type } = await params;
const state = await getQrTypeState(type);
// Welche Gruppe gilt, entscheidet mit darueber, ob der Typ hier offensteht.
const zielgruppe = await resolveTargetGroup(actor, undefined).catch(
() => undefined,
);
const state = await getQrTypeState(type, zielgruppe);
if (!state) { if (!state) {
notFound(); notFound();
@@ -63,7 +68,11 @@ export default async function CreatePage({ params }: Props) {
<Hinweis <Hinweis
icon="lock" icon="lock"
titel={`„${state.qrType.title}“ ist abgeschaltet`} titel={`„${state.qrType.title}“ ist abgeschaltet`}
text="Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben." text={
state.enabledGlobally
? "Dieser QR-Typ ist für deine Gruppe abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen für die Gruppe wieder freigeben."
: "Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben."
}
/> />
); );
} }
@@ -82,8 +91,10 @@ export default async function CreatePage({ params }: Props) {
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin // Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
// noch einmal. // noch einmal.
const { editorsMaySetSlug } = await getSettings(); const { editorsMaySetSlug } = await getSettings();
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined); const maySetSlug = can(actor, "codes:slug", {
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug }); groupId: zielgruppe,
editorsMaySetSlug,
});
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />; return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
} }
+15 -3
View File
@@ -6,6 +6,7 @@ import { ThemeToggle } from "@/components/theme-toggle";
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor"; import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
import { can } from "@/lib/auth/policy"; import { can } from "@/lib/auth/policy";
import { getBranding } from "@/lib/branding"; import { getBranding } from "@/lib/branding";
import { APP_VERSION } from "@/lib/changelog";
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert // Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
// werden. // werden.
@@ -128,9 +129,20 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
<footer className="border-t border-slate-200 bg-surface"> <footer className="border-t border-slate-200 bg-surface">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6"> <div className="mx-auto flex max-w-6xl flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-4 py-6 text-sm text-slate-500 sm:px-6">
{branding.appName} <span>
{branding.footerText ? ` · ${branding.footerText}` : null} {branding.appName}
{branding.footerText ? ` · ${branding.footerText}` : null}
</span>
{/* Welche Fassung hier läuft — nach einem Update die schnellste
Antwort auf die Frage, ob das Update auch angekommen ist. */}
<Link
href="/version"
title="Version und Änderungen"
className="rounded font-mono text-xs tabular-nums transition-colors hover:text-slate-900"
>
v{APP_VERSION}
</Link>
</div> </div>
</footer> </footer>
</div> </div>
+4 -3
View File
@@ -3,15 +3,16 @@ 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 { requirePageActor } from "@/lib/auth/guard";
import { getVisibleQrTypes } from "@/lib/qr/availability"; import { getVisibleQrTypesForActor } 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
// änderbar — die Startseite darf deshalb nicht vorgerendert werden. // änderbar — die Startseite darf deshalb nicht vorgerendert werden.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function HomePage() { export default async function HomePage() {
await requirePageActor(); const actor = await requirePageActor();
const types = await getVisibleQrTypes(); // Wer in mehreren Gruppen ist, sieht die Vereinigung ihrer Freigaben.
const types = await getVisibleQrTypesForActor(actor);
const usableCount = types.filter((entry) => entry.usable).length; const usableCount = types.filter((entry) => entry.usable).length;
return ( return (
+83
View File
@@ -0,0 +1,83 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge, Card } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard";
import {
APP_VERSION,
CHANGELOG,
formatChangelogDate,
} from "@/lib/changelog";
export const metadata: Metadata = { title: "Version und Änderungen" };
export const dynamic = "force-dynamic";
export default async function VersionPage() {
await requirePageActor();
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
href="/"
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" />
Zur Typ-Auswahl
</Link>
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Version und Änderungen
</h1>
<p className="mt-2 text-slate-600">
Hier läuft Version{" "}
<span className="font-semibold text-slate-900">{APP_VERSION}</span>.
Darunter steht, was in welcher Fassung dazugekommen ist.
</p>
<p className="mt-2 text-sm text-slate-500">
Nach einem Update lohnt ein Blick hierher: Steht oben noch die alte
Nummer, läuft der Container noch mit dem alten Abbild.
</p>
</div>
<ol className="space-y-4">
{CHANGELOG.map((eintrag) => {
const laeuft = eintrag.version === APP_VERSION;
return (
<li key={eintrag.version}>
<Card>
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 className="text-lg font-semibold text-slate-900">
{eintrag.version}
</h2>
<span className="text-sm text-slate-500 tabular-nums">
{formatChangelogDate(eintrag.date)}
</span>
{laeuft ? (
<Badge tone="accent">läuft hier</Badge>
) : null}
</div>
<p className="mb-3 text-slate-600">{eintrag.summary}</p>
<ul className="space-y-1.5 text-sm text-slate-700">
{eintrag.changes.map((zeile) => (
<li key={zeile} className="flex gap-2.5">
<span
aria-hidden="true"
className="mt-2 size-1.5 shrink-0 rounded-full bg-slate-300"
/>
<span>{zeile}</span>
</li>
))}
</ul>
</Card>
</li>
);
})}
</ol>
</div>
);
}
+15 -2
View File
@@ -4,6 +4,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 { requirePagePermission } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
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" };
@@ -15,16 +16,23 @@ export default async function TypeSettingsPage() {
const availability = await getQrTypeAvailability(); const availability = await getQrTypeAvailability();
const initial: TypeSetting[] = availability.map( const initial: TypeSetting[] = availability.map(
({ qrType, implemented, enabled }) => ({ ({ qrType, implemented, enabled, enabledGlobally, disabledInGroup }) => ({
id: qrType.id, id: qrType.id,
title: qrType.title, title: qrType.title,
description: qrType.description, description: qrType.description,
icon: qrType.icon, icon: qrType.icon,
implemented, implemented,
enabled, enabled,
enabledGlobally,
disabledInGroup,
}), }),
); );
const groups = await prisma.group.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return ( return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10"> <div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link <Link
@@ -44,6 +52,11 @@ export default async function TypeSettingsPage() {
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
anlegen. anlegen.
</p> </p>
<p className="mt-2 text-slate-600">
Die Grundeinstellung gilt für die ganze Installation. Je Gruppe lässt
sich davon abweichen — was hier abgeschaltet ist, bleibt aber überall
abgeschaltet.
</p>
<p className="mt-2 text-sm text-slate-500"> <p className="mt-2 text-sm text-slate-500">
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
@@ -51,7 +64,7 @@ export default async function TypeSettingsPage() {
</p> </p>
</div> </div>
<TypeSettings initial={initial} /> <TypeSettings initial={initial} groups={groups} />
</div> </div>
); );
} }
+47 -14
View File
@@ -4,9 +4,11 @@ import { z } from "zod";
import { getActor } from "@/lib/auth/actor"; import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy"; import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { prisma } from "@/lib/db";
import { import {
getQrTypeAvailability, getQrTypeAvailability,
setQrTypeEnabled, setQrTypeEnabled,
setQrTypeEnabledForGroup,
} from "@/lib/qr/availability"; } from "@/lib/qr/availability";
import { QR_TYPE_IDS } from "@/lib/qr/type-module"; import { QR_TYPE_IDS } from "@/lib/qr/type-module";
@@ -15,23 +17,45 @@ export const dynamic = "force-dynamic";
const updateSchema = z.object({ const updateSchema = z.object({
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }), type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }), enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }),
/**
* Für welche Gruppe der Schalter gilt. Ohne Angabe die Grundeinstellung
* für die ganze Installation.
*/
groupId: z.string().trim().min(1).optional(),
}); });
/** Zustand aller QR-Typen für die Verwaltung. */ /**
export async function GET(): Promise<Response> { * Zustand aller QR-Typen für die Verwaltung.
*
* Mit `?group=<id>` die Sicht einer einzelnen Gruppe, sonst die
* Grundeinstellung.
*/
export async function GET(request: NextRequest): Promise<Response> {
try { try {
requirePermission(await getActor(), "instance:manage"); requirePermission(await getActor(), "instance:manage");
const types = await getQrTypeAvailability();
const groupId = request.nextUrl.searchParams.get("group") ?? undefined;
if (groupId !== undefined) {
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
}
const types = await getQrTypeAvailability(groupId);
return Response.json({ return Response.json({
types: types.map(({ qrType, implemented, enabled, usable }) => ({ groupId: groupId ?? null,
id: qrType.id, types: types.map(
title: qrType.title, ({ qrType, implemented, enabled, enabledGlobally, disabledInGroup, usable }) => ({
description: qrType.description, id: qrType.id,
icon: qrType.icon, title: qrType.title,
implemented, description: qrType.description,
enabled, icon: qrType.icon,
usable, implemented,
})), enabled,
enabledGlobally,
disabledInGroup,
usable,
}),
),
}); });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
@@ -48,8 +72,17 @@ export async function PATCH(request: NextRequest): Promise<Response> {
return jsonError(firstZodMessage(parsed.error)); return jsonError(firstZodMessage(parsed.error));
} }
await setQrTypeEnabled(parsed.data.type, parsed.data.enabled); const { type, enabled, groupId } = parsed.data;
return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled });
if (groupId === undefined) {
await setQrTypeEnabled(type, enabled);
} else {
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
await setQrTypeEnabledForGroup(type, groupId, enabled);
}
return Response.json({ type, enabled, groupId: groupId ?? null });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
} }
+2 -1
View File
@@ -100,7 +100,8 @@ export async function POST(request: NextRequest): Promise<Response> {
// ist — und der Slug ist nur je Domain eindeutig. // ist — und der Slug ist nur je Domain eindeutig.
const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId); const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId);
const qrType = await requireAvailableQrType(type); // Die Freigabe haengt an der Zielgruppe — global abgeschaltet gewinnt.
const qrType = await requireAvailableQrType(type, groupId);
const validated = qrType.validate(parsed.data.data); const validated = qrType.validate(parsed.data.data);
if (!validated.ok) { if (!validated.ok) {
+111 -13
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useCallback, useState } from "react";
import { Icon, type IconName } from "@/components/icons"; import { Icon, type IconName } from "@/components/icons";
import { Badge, Card, Notice, cx } from "@/components/ui"; import { Badge, Card, Notice, Select, cx } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client"; import { ApiError, apiJson } from "@/lib/api-client";
import type { QrTypeId } from "@/lib/qr/type-module"; import type { QrTypeId } from "@/lib/qr/type-module";
@@ -13,9 +13,19 @@ export type TypeSetting = {
description: string; description: string;
icon: IconName; icon: IconName;
implemented: boolean; implemented: boolean;
/** Freigegeben in der gerade gezeigten Sicht (global oder Gruppe). */
enabled: boolean; enabled: boolean;
/** Die globale Freigabe für sich genommen. */
enabledGlobally: boolean;
/** In der gezeigten Gruppe ausdrücklich abgewählt. */
disabledInGroup: boolean;
}; };
export type GroupOption = { id: string; name: string };
/** Die Grundeinstellung, wenn keine Gruppe gewählt ist. */
const GLOBAL = "global";
function Toggle({ function Toggle({
checked, checked,
disabled, disabled,
@@ -50,29 +60,71 @@ function Toggle({
); );
} }
export function TypeSettings({ initial }: { initial: TypeSetting[] }) { export function TypeSettings({
initial,
groups = [],
}: {
initial: TypeSetting[];
groups?: GroupOption[];
}) {
const [types, setTypes] = useState(initial); const [types, setTypes] = useState(initial);
const [scope, setScope] = useState<string>(GLOBAL);
const [pending, setPending] = useState<QrTypeId | null>(null); const [pending, setPending] = useState<QrTypeId | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const inGruppe = scope !== GLOBAL;
const gruppenname = groups.find((g) => g.id === scope)?.name ?? "";
const visibleCount = types.filter((entry) => entry.enabled).length; const visibleCount = types.filter((entry) => entry.enabled).length;
const usableCount = types.filter( const usableCount = types.filter(
(entry) => entry.enabled && entry.implemented, (entry) => entry.enabled && entry.implemented,
).length; ).length;
/** Sicht wechseln — die Zustände unterscheiden sich je Gruppe. */
const wechsle = useCallback(async (next: string) => {
setScope(next);
setLoading(true);
setError(null);
try {
const result = await apiJson<{ types: TypeSetting[] }>(
next === GLOBAL ? "/api/admin/types" : `/api/admin/types?group=${next}`,
);
setTypes(result.types);
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Freigaben konnten nicht geladen werden.",
);
} finally {
setLoading(false);
}
}, []);
const toggle = async (id: QrTypeId, enabled: boolean) => { const toggle = async (id: QrTypeId, enabled: boolean) => {
const before = types; const before = types;
// Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter // Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter
// soll sich nicht zäh anfühlen. // soll sich nicht zäh anfühlen.
setTypes((current) => setTypes((current) =>
current.map((entry) => (entry.id === id ? { ...entry, enabled } : entry)), current.map((entry) =>
entry.id === id
? {
...entry,
enabled,
...(inGruppe
? { disabledInGroup: !enabled }
: { enabledGlobally: enabled }),
}
: entry,
),
); );
setPending(id); setPending(id);
setError(null); setError(null);
try { try {
await apiJson("/api/admin/types", { await apiJson("/api/admin/types", {
method: "PATCH", method: "PATCH",
json: { type: id, enabled }, json: { type: id, enabled, ...(inGruppe ? { groupId: scope } : {}) },
}); });
} catch (caught) { } catch (caught) {
setTypes(before); setTypes(before);
@@ -90,6 +142,40 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
<div className="space-y-4"> <div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null} {error ? <Notice tone="error">{error}</Notice> : null}
{groups.length > 0 ? (
<div className="flex flex-wrap items-center gap-3">
<label
htmlFor="typen-gruppe"
className="text-sm font-medium text-slate-700"
>
Freigabe für
</label>
<Select
id="typen-gruppe"
className="!w-auto"
value={scope}
disabled={loading}
onChange={(event) => void wechsle(event.target.value)}
>
<option value={GLOBAL}>Alle Gruppen (Grundeinstellung)</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
Nur: {group.name}
</option>
))}
</Select>
</div>
) : null}
{inGruppe ? (
<Notice tone="info">
Du siehst die Freigabe für <strong>{gruppenname}</strong>. Was hier
abgeschaltet ist, gilt nur für diese Gruppe. Ein Typ, der in der
Grundeinstellung aus ist, lässt sich hier nicht einschalten — die
globale Abschaltung gewinnt immer.
</Notice>
) : null}
{visibleCount === 0 ? ( {visibleCount === 0 ? (
<Notice tone="warning"> <Notice tone="warning">
Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es
@@ -138,17 +224,28 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
)} )}
</div> </div>
<p className="mt-0.5 text-sm text-slate-500"> <p className="mt-0.5 text-sm text-slate-500">
{entry.enabled {inGruppe && !entry.enabledGlobally
? entry.implemented ? "In der Grundeinstellung abgeschaltet — für diese Gruppe nicht einschaltbar."
? "Erscheint auf der Startseite und kann verwendet werden." : entry.enabled
: "Erscheint auf der Startseite als „Bald verfügbar“." ? entry.implemented
: "Ausgeblendet — erscheint nicht auf der Startseite."} ? inGruppe
? `Steht ${gruppenname} zur Verfügung.`
: "Erscheint auf der Startseite und kann verwendet werden."
: "Erscheint auf der Startseite als „Bald verfügbar“."
: inGruppe
? `Für ${gruppenname} abgeschaltet — andere Gruppen bleiben unberührt.`
: "Ausgeblendet — erscheint nicht auf der Startseite."}
</p> </p>
</div> </div>
<Toggle <Toggle
checked={entry.enabled} checked={entry.enabled}
disabled={pending === entry.id} disabled={
pending === entry.id ||
loading ||
// Global aus gewinnt immer — der Schalter steht dann still.
(inGruppe && !entry.enabledGlobally)
}
label={`${entry.title} ${entry.enabled ? "ausblenden" : "freigeben"}`} label={`${entry.title} ${entry.enabled ? "ausblenden" : "freigeben"}`}
onChange={(next) => void toggle(entry.id, next)} onChange={(next) => void toggle(entry.id, next)}
/> />
@@ -158,8 +255,9 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
</Card> </Card>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
{usableCount} von {types.length} Typen nutzbar, {visibleCount} auf der {inGruppe
Startseite sichtbar. ? `${usableCount} von ${types.length} Typen stehen ${gruppenname} zur Verfügung.`
: `${usableCount} von ${types.length} Typen nutzbar, ${visibleCount} auf der Startseite sichtbar.`}
</p> </p>
</div> </div>
); );
+126
View File
@@ -0,0 +1,126 @@
import pkg from "../../package.json";
/**
* Änderungsverlauf der Anwendung — die einzige Quelle dafür.
*
* Hieraus entsteht beides: die Seite unter `/version` und die `CHANGELOG.md`
* im Wurzelverzeichnis (`npm run changelog`). Zwei gepflegte Listen liefen
* unweigerlich auseinander; eine Prüfung wacht darüber, dass die erzeugte
* Datei zu dieser Quelle passt.
*
* Die Einträge sind aus dem Abschnitt „Gebaut“ der Roadmap und der
* Git-Historie zusammengetragen. Versionsnummern gab es dabei nie — es
* existieren keine Tags —, deshalb ist je **Erscheinungstag** eine Version
* gebildet. Das ist nachträglich vergeben, aber die Daten selbst stimmen.
*/
export type ChangelogEntry = {
version: string;
/** ISO-Datum, an dem der Stand entstanden ist. */
date: string;
/** Eine Zeile zur Einordnung — was diese Version ausmacht. */
summary: string;
changes: string[];
};
export const CHANGELOG: readonly ChangelogEntry[] = [
{
version: "1.6.0",
date: "2026-08-21",
summary: "Sichtbare Version mit Änderungsverlauf.",
changes: [
"Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.",
"Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.",
"Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.",
],
},
{
version: "1.5.0",
date: "2026-08-20",
summary: "Statische Codes lassen sich benennen und ablegen.",
changes: [
"Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.",
"Der Name bestimmt den Dateinamen beim Herunterladen.",
"Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.",
],
},
{
version: "1.4.0",
date: "2026-08-17",
summary: "Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.",
changes: [
"Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.",
"Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.",
"Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.",
"Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.",
"Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.",
],
},
{
version: "1.3.0",
date: "2026-08-16",
summary: "Eigenes Erscheinungsbild und ein Dark Mode.",
changes: [
"Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.",
"Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.",
"Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.",
"Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.",
],
},
{
version: "1.2.0",
date: "2026-08-15",
summary: "Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.",
changes: [
"Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.",
"Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.",
"Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.",
"Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.",
],
},
{
version: "1.1.0",
date: "2026-08-14",
summary: "Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.",
changes: [
"Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.",
"Landingpages als eigene Gattung für Typen ohne fremdes Ziel.",
"Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.",
"Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.",
"Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.",
"„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.",
"Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.",
],
},
{
version: "1.0.0",
date: "2026-08-13",
summary: "Erste lauffähige Fassung.",
changes: [
"Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.",
"QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.",
"Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.",
"Download als PNG und SVG, serverseitig gerendert.",
"Update-Skript für Linux, macOS und Windows.",
],
},
];
/**
* Die laufende Version.
*
* Aus der `package.json`, damit es genau eine Stelle gibt, an der sie steht.
* Der Wert wird beim Bauen eingesetzt — im Container liegt kein Git, aus dem
* sich sonst etwas ableiten ließe.
*/
export const APP_VERSION: string = pkg.version;
/** Der Eintrag zur laufenden Version, falls er im Verlauf steht. */
export function currentEntry(): ChangelogEntry | undefined {
return CHANGELOG.find((eintrag) => eintrag.version === APP_VERSION);
}
/** Datum als „21.08.2026“ — ohne Abhängigkeit von der Zeitzone des Servers. */
export function formatChangelogDate(iso: string): string {
const [jahr, monat, tag] = iso.split("-");
return `${tag}.${monat}.${jahr}`;
}
+141 -10
View File
@@ -1,5 +1,7 @@
import "server-only"; import "server-only";
import type { Actor } from "@/lib/auth/actor";
import { visibleGroupIds } from "@/lib/auth/policy";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getQrType, qrTypeList } from "@/lib/qr/registry"; import { getQrType, qrTypeList } from "@/lib/qr/registry";
import { import {
@@ -19,6 +21,16 @@ import {
* - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ * - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ
* freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben. * freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben.
* *
* Die Freigabe hat zwei Ebenen. **Global** gilt für die ganze Installation
* und ist die Grundlage; **je Gruppe** lässt sich davon abweichen, damit die
* Marketingabteilung Social Media bekommt und die Werkstatt nur Website und
* PDF. Fehlt eine Abweichung, folgt die Gruppe der globalen Einstellung.
*
* **Die globale Abschaltung gewinnt immer.** Ein global abgeschalteter Typ
* lässt sich in keiner Gruppe wieder einschalten. Andersherum wäre „hier
* abgeschaltet“ keine verlässliche Aussage mehr, und wer den Schalter umlegt,
* müsste alle Gruppen durchsehen, um zu wissen, was er getan hat.
*
* Ein abgeschalteter Typ verschwindet aus der Oberfläche und lässt sich nicht * Ein abgeschalteter Typ verschwindet aus der Oberfläche und lässt sich nicht
* mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren * mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren
* weiter** — sie sind gedruckt und verteilt, und ein Schalter in der * weiter** — sie sind gedruckt und verteilt, und ein Schalter in der
@@ -29,8 +41,15 @@ export type QrTypeAvailability = {
qrType: QrTypeModule; qrType: QrTypeModule;
/** Der Typ ist in dieser Version ausprogrammiert. */ /** Der Typ ist in dieser Version ausprogrammiert. */
implemented: boolean; implemented: boolean;
/** Die Verwaltung hat den Typ freigegeben. */ /** Die Verwaltung hat den Typ freigegeben — global und in der Gruppe. */
enabled: boolean; enabled: boolean;
/** Die globale Freigabe für sich genommen, ohne die Gruppe. */
enabledGlobally: boolean;
/**
* Nur bei einer Abfrage für eine bestimmte Gruppe gesetzt: Ist der Typ dort
* ausdrücklich abgewählt? Ohne Abweichung `false`.
*/
disabledInGroup: boolean;
/** Freigegeben und ausprogrammiert — nur dann ist er benutzbar. */ /** Freigegeben und ausprogrammiert — nur dann ist er benutzbar. */
usable: boolean; usable: boolean;
/** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */ /** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */
@@ -56,16 +75,52 @@ async function loadDisabledTypes(): Promise<ReadonlySet<string>> {
} }
} }
/** Verfügbarkeit aller Typen in Anzeigereihenfolge. */ /** Abweichungen einer Gruppe; leer, wenn sie der globalen Einstellung folgt. */
export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> { async function loadGroupDisabledTypes(
groupId: string,
): Promise<ReadonlySet<string>> {
try {
const rows = await prisma.qrTypeGroupSetting.findMany({
where: { groupId, enabled: false },
select: { type: true },
});
return new Set(rows.map((row) => row.type));
} catch (error) {
// Wie oben: im Zweifel lieber alles zeigen als eine leere Startseite.
console.error(
"Freigaben der QR-Typen je Gruppe konnten nicht geladen werden — es gilt die globale Einstellung:",
error,
);
return new Set();
}
}
/**
* Verfügbarkeit aller Typen in Anzeigereihenfolge.
*
* Ohne `groupId` gilt allein die globale Freigabe — das ist die Sicht der
* Verwaltung auf die Grundeinstellung.
*/
export async function getQrTypeAvailability(
groupId?: string,
): Promise<QrTypeAvailability[]> {
const disabled = await loadDisabledTypes(); const disabled = await loadDisabledTypes();
const inGruppeAbgewaehlt =
groupId === undefined
? new Set<string>()
: await loadGroupDisabledTypes(groupId);
return qrTypeList.map((qrType) => { return qrTypeList.map((qrType) => {
const enabled = !disabled.has(qrType.id); const global = !disabled.has(qrType.id);
const inGruppe = inGruppeAbgewaehlt.has(qrType.id);
// Global aus gewinnt immer — eine Gruppe kann das nicht aufheben.
const enabled = global && !inGruppe;
return { return {
qrType, qrType,
implemented: qrType.implemented, implemented: qrType.implemented,
enabled, enabled,
enabledGlobally: global,
disabledInGroup: inGruppe,
usable: enabled && qrType.implemented, usable: enabled && qrType.implemented,
announced: enabled && !qrType.implemented, announced: enabled && !qrType.implemented,
}; };
@@ -73,15 +128,61 @@ export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> {
} }
/** Nur die Typen, die in der Oberfläche erscheinen sollen. */ /** Nur die Typen, die in der Oberfläche erscheinen sollen. */
export async function getVisibleQrTypes(): Promise<QrTypeAvailability[]> { export async function getVisibleQrTypes(
return (await getQrTypeAvailability()).filter((entry) => entry.enabled); groupId?: string,
): Promise<QrTypeAvailability[]> {
return (await getQrTypeAvailability(groupId)).filter((entry) => entry.enabled);
}
/**
* Was jemand auf der Startseite sehen soll: die **Vereinigung** über seine
* Gruppen.
*
* Wer in der Werkstatt und im Marketing ist, sieht beides. Welche Gruppe
* beim Anlegen gilt, entscheidet sich erst dort — und wird dort auch geprüft.
*/
export async function getVisibleQrTypesForActor(
actor: Actor,
): Promise<QrTypeAvailability[]> {
const eigene = visibleGroupIds(actor);
// `null` heißt: alle Gruppen. Ohne Gruppen (oder ohne Anmeldung) bleibt es
// bei der globalen Einstellung.
const gruppen =
eigene === null
? (await prisma.group.findMany({ select: { id: true } })).map((g) => g.id)
: eigene;
if (gruppen.length === 0) return getVisibleQrTypes();
const je = await Promise.all(gruppen.map((id) => getVisibleQrTypes(id)));
const gesehen = new Set<string>();
const vereinigt: QrTypeAvailability[] = [];
// Über qrTypeList laufen statt über die Ergebnisse: so bleibt die
// Anzeigereihenfolge der Registry erhalten.
for (const qrType of qrTypeList) {
for (const liste of je) {
if (gesehen.has(qrType.id)) break;
const treffer = liste.find((entry) => entry.qrType.id === qrType.id);
if (treffer) {
gesehen.add(qrType.id);
vereinigt.push(treffer);
}
}
}
return vereinigt;
} }
export async function getQrTypeState( export async function getQrTypeState(
id: string, id: string,
groupId?: string,
): Promise<QrTypeAvailability | undefined> { ): Promise<QrTypeAvailability | undefined> {
if (!isQrTypeId(id)) return undefined; if (!isQrTypeId(id)) return undefined;
return (await getQrTypeAvailability()).find((entry) => entry.qrType.id === id); return (await getQrTypeAvailability(groupId)).find(
(entry) => entry.qrType.id === id,
);
} }
/** /**
@@ -90,8 +191,9 @@ export async function getQrTypeState(
*/ */
export async function requireAvailableQrType( export async function requireAvailableQrType(
id: string, id: string,
groupId?: string,
): Promise<QrTypeModule> { ): Promise<QrTypeModule> {
const state = await getQrTypeState(id); const state = await getQrTypeState(id, groupId);
if (!state) { if (!state) {
throw new QrRequestError("Unbekannter QR-Typ.", 404); throw new QrRequestError("Unbekannter QR-Typ.", 404);
@@ -104,7 +206,9 @@ export async function requireAvailableQrType(
} }
if (!state.enabled) { if (!state.enabled) {
throw new QrRequestError( throw new QrRequestError(
`Der QR-Typ „${state.qrType.title}“ ist in der Verwaltung abgeschaltet.`, state.enabledGlobally
? `Der QR-Typ „${state.qrType.title}“ ist für diese Gruppe abgeschaltet.`
: `Der QR-Typ „${state.qrType.title}“ ist in der Verwaltung abgeschaltet.`,
403, 403,
); );
} }
@@ -128,7 +232,7 @@ export function requireImplementedQrType(id: string): QrTypeModule {
return qrType; return qrType;
} }
/** Freigabe eines Typs setzen. */ /** Globale Freigabe eines Typs setzen. */
export async function setQrTypeEnabled( export async function setQrTypeEnabled(
id: QrTypeId, id: QrTypeId,
enabled: boolean, enabled: boolean,
@@ -139,3 +243,30 @@ export async function setQrTypeEnabled(
update: { enabled }, update: { enabled },
}); });
} }
/**
* Freigabe eines Typs für eine Gruppe setzen.
*
* `true` löscht die Abweichung, statt sie auf „an“ zu setzen: Die Gruppe
* folgt dann wieder der globalen Einstellung. Eine Zeile „an“ zu hinterlassen
* würde vortäuschen, sie könnte eine globale Abschaltung überstimmen — das
* kann sie nicht.
*/
export async function setQrTypeEnabledForGroup(
id: QrTypeId,
groupId: string,
enabled: boolean,
): Promise<void> {
if (enabled) {
await prisma.qrTypeGroupSetting.deleteMany({
where: { type: id, groupId },
});
return;
}
await prisma.qrTypeGroupSetting.upsert({
where: { type_groupId: { type: id, groupId } },
create: { type: id, groupId, enabled: false },
update: { enabled: false },
});
}