Compare commits
3
Commits
v1.5.0
...
3cb5421a20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cb5421a20 | ||
|
|
b96e9ddbe5 | ||
|
|
586e1f042f |
@@ -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.
|
||||
@@ -34,6 +34,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
||||
eigene Wahl.
|
||||
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
|
||||
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
|
||||
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,
|
||||
Rollen und der Weg zurück
|
||||
- **[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,
|
||||
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
|
||||
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,
|
||||
> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter
|
||||
> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also
|
||||
@@ -961,8 +984,8 @@ deutschen Meldung.
|
||||
| `DELETE` | `/api/codes/{id}` | Code löschen |
|
||||
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) |
|
||||
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
|
||||
| `GET` | `/api/admin/types` | Zustand aller QR-Typen |
|
||||
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten |
|
||||
| `GET` | `/api/admin/types` | Zustand aller QR-Typen (`?group={id}` je Gruppe) |
|
||||
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten (`groupId` optional) |
|
||||
|
||||
Beispiel:
|
||||
|
||||
|
||||
+30
-1
@@ -466,7 +466,7 @@ auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
|
||||
| --- | --- |
|
||||
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
|
||||
| **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 |
|
||||
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
|
||||
| **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
|
||||
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
|
||||
@@ -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
|
||||
|
||||
Alles, was die Oberfläche tut, geht auch über HTTP:
|
||||
|
||||
+9
-87
@@ -7,94 +7,16 @@ Aufwände sind grobe Einschätzungen, keine Zusagen.
|
||||
|
||||
## 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
|
||||
Proxy, Migrationen beim Start
|
||||
- QR-Typ-Registry als Dispatch-Tabelle — ein neuer Typ ist ein Modul plus ein
|
||||
Formular, ohne Eingriff in den übrigen Code
|
||||
- Typ **Website**, statisch und dynamisch, mit Kurz-URL, 302-Weiterleitung und
|
||||
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.
|
||||
Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
|
||||
Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
|
||||
1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
|
||||
und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes
|
||||
und 1.6.0 diesen Versionshinweis.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-1
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "qrcode-generator",
|
||||
"version": "1.1.0",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"changelog": "node scripts/changelog.mjs",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"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;
|
||||
@@ -87,6 +87,7 @@ model Group {
|
||||
memberships Membership[]
|
||||
codes QrCode[]
|
||||
domainGrants DomainGrant[]
|
||||
typeSettings QrTypeGroupSetting[]
|
||||
}
|
||||
|
||||
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
||||
@@ -155,6 +156,31 @@ model QrTypeSetting {
|
||||
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
|
||||
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
|
||||
///
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -52,7 +52,12 @@ function Hinweis({
|
||||
export default async function CreatePage({ params }: Props) {
|
||||
const actor = await requirePageActor();
|
||||
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) {
|
||||
notFound();
|
||||
@@ -63,7 +68,11 @@ export default async function CreatePage({ params }: Props) {
|
||||
<Hinweis
|
||||
icon="lock"
|
||||
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
|
||||
// noch einmal.
|
||||
const { editorsMaySetSlug } = await getSettings();
|
||||
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
|
||||
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
|
||||
const maySetSlug = can(actor, "codes:slug", {
|
||||
groupId: zielgruppe,
|
||||
editorsMaySetSlug,
|
||||
});
|
||||
|
||||
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
|
||||
import { can } from "@/lib/auth/policy";
|
||||
import { getBranding } from "@/lib/branding";
|
||||
import { APP_VERSION } from "@/lib/changelog";
|
||||
|
||||
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
|
||||
// werden.
|
||||
@@ -128,9 +129,20 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
||||
<main className="flex-1">{children}</main>
|
||||
|
||||
<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">
|
||||
{branding.appName}
|
||||
{branding.footerText ? ` · ${branding.footerText}` : null}
|
||||
<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">
|
||||
<span>
|
||||
{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>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -3,15 +3,16 @@ import Link from "next/link";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Badge, Notice } from "@/components/ui";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
import { getVisibleQrTypes } from "@/lib/qr/availability";
|
||||
import { getVisibleQrTypesForActor } from "@/lib/qr/availability";
|
||||
|
||||
// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
|
||||
// änderbar — die Startseite darf deshalb nicht vorgerendert werden.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
await requirePageActor();
|
||||
const types = await getVisibleQrTypes();
|
||||
const actor = await requirePageActor();
|
||||
// Wer in mehreren Gruppen ist, sieht die Vereinigung ihrer Freigaben.
|
||||
const types = await getVisibleQrTypesForActor(actor);
|
||||
const usableCount = types.filter((entry) => entry.usable).length;
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
|
||||
import { requirePagePermission } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getQrTypeAvailability } from "@/lib/qr/availability";
|
||||
|
||||
export const metadata: Metadata = { title: "QR-Typen" };
|
||||
@@ -15,16 +16,23 @@ export default async function TypeSettingsPage() {
|
||||
const availability = await getQrTypeAvailability();
|
||||
|
||||
const initial: TypeSetting[] = availability.map(
|
||||
({ qrType, implemented, enabled }) => ({
|
||||
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup }) => ({
|
||||
id: qrType.id,
|
||||
title: qrType.title,
|
||||
description: qrType.description,
|
||||
icon: qrType.icon,
|
||||
implemented,
|
||||
enabled,
|
||||
enabledGlobally,
|
||||
disabledInGroup,
|
||||
}),
|
||||
);
|
||||
|
||||
const groups = await prisma.group.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Link
|
||||
@@ -44,6 +52,11 @@ export default async function TypeSettingsPage() {
|
||||
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
|
||||
anlegen.
|
||||
</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">
|
||||
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
|
||||
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
|
||||
@@ -51,7 +64,7 @@ export default async function TypeSettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TypeSettings initial={initial} />
|
||||
<TypeSettings initial={initial} groups={groups} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { z } from "zod";
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getQrTypeAvailability,
|
||||
setQrTypeEnabled,
|
||||
setQrTypeEnabledForGroup,
|
||||
} from "@/lib/qr/availability";
|
||||
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
|
||||
|
||||
@@ -15,23 +17,45 @@ export const dynamic = "force-dynamic";
|
||||
const updateSchema = z.object({
|
||||
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
|
||||
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 {
|
||||
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({
|
||||
types: types.map(({ qrType, implemented, enabled, usable }) => ({
|
||||
id: qrType.id,
|
||||
title: qrType.title,
|
||||
description: qrType.description,
|
||||
icon: qrType.icon,
|
||||
implemented,
|
||||
enabled,
|
||||
usable,
|
||||
})),
|
||||
groupId: groupId ?? null,
|
||||
types: types.map(
|
||||
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup, usable }) => ({
|
||||
id: qrType.id,
|
||||
title: qrType.title,
|
||||
description: qrType.description,
|
||||
icon: qrType.icon,
|
||||
implemented,
|
||||
enabled,
|
||||
enabledGlobally,
|
||||
disabledInGroup,
|
||||
usable,
|
||||
}),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
@@ -48,8 +72,17 @@ export async function PATCH(request: NextRequest): Promise<Response> {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
await setQrTypeEnabled(parsed.data.type, parsed.data.enabled);
|
||||
return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled });
|
||||
const { type, enabled, groupId } = parsed.data;
|
||||
|
||||
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) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
|
||||
@@ -100,7 +100,8 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
// ist — und der Slug ist nur je Domain eindeutig.
|
||||
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);
|
||||
if (!validated.ok) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
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 type { QrTypeId } from "@/lib/qr/type-module";
|
||||
|
||||
@@ -13,9 +13,19 @@ export type TypeSetting = {
|
||||
description: string;
|
||||
icon: IconName;
|
||||
implemented: boolean;
|
||||
/** Freigegeben in der gerade gezeigten Sicht (global oder Gruppe). */
|
||||
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({
|
||||
checked,
|
||||
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 [scope, setScope] = useState<string>(GLOBAL);
|
||||
const [pending, setPending] = useState<QrTypeId | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 usableCount = types.filter(
|
||||
(entry) => entry.enabled && entry.implemented,
|
||||
).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 before = types;
|
||||
// Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter
|
||||
// soll sich nicht zäh anfühlen.
|
||||
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);
|
||||
setError(null);
|
||||
try {
|
||||
await apiJson("/api/admin/types", {
|
||||
method: "PATCH",
|
||||
json: { type: id, enabled },
|
||||
json: { type: id, enabled, ...(inGruppe ? { groupId: scope } : {}) },
|
||||
});
|
||||
} catch (caught) {
|
||||
setTypes(before);
|
||||
@@ -90,6 +142,40 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
|
||||
<div className="space-y-4">
|
||||
{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 ? (
|
||||
<Notice tone="warning">
|
||||
Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es
|
||||
@@ -138,17 +224,28 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-slate-500">
|
||||
{entry.enabled
|
||||
? entry.implemented
|
||||
? "Erscheint auf der Startseite und kann verwendet werden."
|
||||
: "Erscheint auf der Startseite als „Bald verfügbar“."
|
||||
: "Ausgeblendet — erscheint nicht auf der Startseite."}
|
||||
{inGruppe && !entry.enabledGlobally
|
||||
? "In der Grundeinstellung abgeschaltet — für diese Gruppe nicht einschaltbar."
|
||||
: entry.enabled
|
||||
? entry.implemented
|
||||
? 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>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
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"}`}
|
||||
onChange={(next) => void toggle(entry.id, next)}
|
||||
/>
|
||||
@@ -158,8 +255,9 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
|
||||
</Card>
|
||||
|
||||
<p className="text-sm text-slate-500">
|
||||
{usableCount} von {types.length} Typen nutzbar, {visibleCount} auf der
|
||||
Startseite sichtbar.
|
||||
{inGruppe
|
||||
? `${usableCount} von ${types.length} Typen stehen ${gruppenname} zur Verfügung.`
|
||||
: `${usableCount} von ${types.length} Typen nutzbar, ${visibleCount} auf der Startseite sichtbar.`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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
@@ -1,5 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import type { Actor } from "@/lib/auth/actor";
|
||||
import { visibleGroupIds } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getQrType, qrTypeList } from "@/lib/qr/registry";
|
||||
import {
|
||||
@@ -19,6 +21,16 @@ import {
|
||||
* - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ
|
||||
* 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
|
||||
* mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren
|
||||
* weiter** — sie sind gedruckt und verteilt, und ein Schalter in der
|
||||
@@ -29,8 +41,15 @@ export type QrTypeAvailability = {
|
||||
qrType: QrTypeModule;
|
||||
/** Der Typ ist in dieser Version ausprogrammiert. */
|
||||
implemented: boolean;
|
||||
/** Die Verwaltung hat den Typ freigegeben. */
|
||||
/** Die Verwaltung hat den Typ freigegeben — global und in der Gruppe. */
|
||||
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. */
|
||||
usable: boolean;
|
||||
/** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */
|
||||
@@ -56,16 +75,52 @@ async function loadDisabledTypes(): Promise<ReadonlySet<string>> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Verfügbarkeit aller Typen in Anzeigereihenfolge. */
|
||||
export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> {
|
||||
/** Abweichungen einer Gruppe; leer, wenn sie der globalen Einstellung folgt. */
|
||||
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 inGruppeAbgewaehlt =
|
||||
groupId === undefined
|
||||
? new Set<string>()
|
||||
: await loadGroupDisabledTypes(groupId);
|
||||
|
||||
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 {
|
||||
qrType,
|
||||
implemented: qrType.implemented,
|
||||
enabled,
|
||||
enabledGlobally: global,
|
||||
disabledInGroup: inGruppe,
|
||||
usable: 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. */
|
||||
export async function getVisibleQrTypes(): Promise<QrTypeAvailability[]> {
|
||||
return (await getQrTypeAvailability()).filter((entry) => entry.enabled);
|
||||
export async function getVisibleQrTypes(
|
||||
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(
|
||||
id: string,
|
||||
groupId?: string,
|
||||
): Promise<QrTypeAvailability | 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(
|
||||
id: string,
|
||||
groupId?: string,
|
||||
): Promise<QrTypeModule> {
|
||||
const state = await getQrTypeState(id);
|
||||
const state = await getQrTypeState(id, groupId);
|
||||
|
||||
if (!state) {
|
||||
throw new QrRequestError("Unbekannter QR-Typ.", 404);
|
||||
@@ -104,7 +206,9 @@ export async function requireAvailableQrType(
|
||||
}
|
||||
if (!state.enabled) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -128,7 +232,7 @@ export function requireImplementedQrType(id: string): QrTypeModule {
|
||||
return qrType;
|
||||
}
|
||||
|
||||
/** Freigabe eines Typs setzen. */
|
||||
/** Globale Freigabe eines Typs setzen. */
|
||||
export async function setQrTypeEnabled(
|
||||
id: QrTypeId,
|
||||
enabled: boolean,
|
||||
@@ -139,3 +243,30 @@ export async function setQrTypeEnabled(
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user