Compare commits
6
Commits
a4bf3c77b6
...
v1.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f4753b753 | ||
|
|
cdda7df17a | ||
|
|
c6891f66ff | ||
|
|
ff28352907 | ||
|
|
830d6d7379 | ||
|
|
3cb5421a20 |
@@ -7,6 +7,17 @@ Versionsnummer in der Fußzeile.
|
|||||||
> Diese Datei wird erzeugt: `npm run changelog`.
|
> Diese Datei wird erzeugt: `npm run changelog`.
|
||||||
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
|
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
|
||||||
|
|
||||||
|
## 1.7.0 — 21.08.2026
|
||||||
|
|
||||||
|
Logo im Code, Freigabe je Gruppe und ein Domain-Check.
|
||||||
|
|
||||||
|
- Ein Logo in der Mitte des Codes — das der Installation oder ein eigenes je Code, 10 bis 25 % der Kantenlänge. Die Fehlerkorrektur springt dabei auf H.
|
||||||
|
- QR-Typen lassen sich je Gruppe freigeben. Die Grundeinstellung bleibt die Grundlage; eine globale Abschaltung gewinnt immer.
|
||||||
|
- Der Knopf „Prüfen“ bei jeder Domain meldet DNS, Zertifikat, Erreichbarkeit — und ob dahinter wirklich diese Installation steht.
|
||||||
|
- Eine Kurzhilfe auf der Domain-Seite führt in sechs Schritten auf, was für eine neue Domain einzurichten ist.
|
||||||
|
- „Meine Codes“ lässt sich nach Art filtern: alle, nur dynamische, nur statische.
|
||||||
|
- Beim Ablegen eines statischen Codes wird gewarnt, wenn derselbe Inhalt schon in der Gruppe liegt.
|
||||||
|
|
||||||
## 1.6.0 — 21.08.2026
|
## 1.6.0 — 21.08.2026
|
||||||
|
|
||||||
Sichtbare Version mit Änderungsverlauf.
|
Sichtbare Version mit Änderungsverlauf.
|
||||||
|
|||||||
@@ -20,16 +20,18 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
|
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
|
||||||
- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
|
- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
|
||||||
10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
|
10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
|
||||||
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
|
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Filter nach Gruppe
|
||||||
Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen
|
und nach Art, Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch
|
||||||
ablegen, um sie wiederzufinden.
|
mit Namen ablegen; liegt derselbe Inhalt schon dort, wird gewarnt.
|
||||||
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
|
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
|
||||||
erhoben, ohne Name und IP-Adresse.
|
erhoben, ohne Name und IP-Adresse.
|
||||||
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
|
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
|
||||||
Administrator, Bearbeiter und Betrachter.
|
Administrator, Bearbeiter und Betrachter.
|
||||||
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
|
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt — mit Kurzhilfe
|
||||||
|
zur Einrichtung und einer Prüfung, ob eine Domain wirklich hierher führt.
|
||||||
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
|
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
|
||||||
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst.
|
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst —
|
||||||
|
für die ganze Installation oder je Gruppe.
|
||||||
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
|
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
|
||||||
Admin-Bereich einstellbar.
|
Admin-Bereich einstellbar.
|
||||||
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
|
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
|
||||||
@@ -609,12 +611,32 @@ Zwei Eigenschaften sind dabei zu unterscheiden:
|
|||||||
|
|
||||||
| | Bedeutung | Woher |
|
| | Bedeutung | Woher |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **umgesetzt** | Der Typ ist ausprogrammiert. In Version 1 gilt das nur für „Website“. | Registry im Code |
|
| **umgesetzt** | Der Typ ist ausprogrammiert. Seit 1.1.0 gilt das für alle fünfzehn. | Registry im Code |
|
||||||
| **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar |
|
| **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar |
|
||||||
|
|
||||||
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
|
||||||
@@ -653,7 +675,10 @@ Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
|
|||||||
Kartenansicht.
|
Kartenansicht.
|
||||||
|
|
||||||
Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein
|
Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein
|
||||||
Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Steht der
|
Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Beim
|
||||||
|
Ablegen läuft eine **Dublettenprüfung**: Liegt in derselben Gruppe schon ein
|
||||||
|
statischer Code mit identischem Inhalt, hält das Ablegen an und nennt ihn —
|
||||||
|
eine Rückfrage mit dem Knopf „Trotzdem ablegen“, keine Sperre. Verglichen wird nur der Inhalt, nicht die Gestaltung. Steht der
|
||||||
Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach
|
Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach
|
||||||
Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.
|
Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.
|
||||||
|
|
||||||
@@ -995,8 +1020,10 @@ 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) |
|
||||||
|
| `POST` | `/api/admin/domains/{id}/check` | Domain prüfen: DNS, Zertifikat, Erreichbarkeit, Ziel |
|
||||||
|
| `GET` | `/api/domain-check` | Beweis für die Domain-Prüfung (`?nonce=…`, ohne Anmeldung) |
|
||||||
|
|
||||||
Beispiel:
|
Beispiel:
|
||||||
|
|
||||||
|
|||||||
+92
-3
@@ -112,12 +112,40 @@ gescannt zu werden.
|
|||||||
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
|
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
|
||||||
trennen kann.
|
trennen kann.
|
||||||
|
|
||||||
|
### Ein Logo in die Mitte setzen
|
||||||
|
|
||||||
|
Ganz unten in der Gestaltung steht **Logo in der Mitte**. Ein Haken genügt,
|
||||||
|
dann fragt die Anwendung, woher das Bild kommen soll:
|
||||||
|
|
||||||
|
- **Logo dieser Installation** — das, was unter Verwaltung → Erscheinungsbild
|
||||||
|
hinterlegt ist. Kein Upload je Code nötig. Die Wahl erscheint nur, wenn dort
|
||||||
|
überhaupt eines liegt.
|
||||||
|
- **Eigenes Logo** — je Code hochgeladen, für Kundenprojekte mit fremdem
|
||||||
|
Zeichen. PNG, JPEG, WebP oder AVIF, bis 2 MB.
|
||||||
|
|
||||||
|
Die **Größe** liegt zwischen 10 und 25 % der Kantenlänge; voreingestellt sind
|
||||||
|
20 %, das verdeckt rund 4 % der Fläche. Hinter dem Bild liegt eine Fläche in
|
||||||
|
der Hintergrundfarbe, damit es sich absetzt.
|
||||||
|
|
||||||
|
**Die Fehlerkorrektur springt dabei auf H** und lässt sich nicht mehr ändern —
|
||||||
|
was das Bild verdeckt, muss sie ausgleichen. Das steht auch so unter der
|
||||||
|
Auswahl; heimlich geschieht es nicht.
|
||||||
|
|
||||||
|
Ab 22 % erscheint eine Warnung. Die Fehlerkorrektur trägt das zwar, aber
|
||||||
|
manche Scanner-Apps suchen in der Mitte nach Ausrichtungsmustern.
|
||||||
|
|
||||||
|
> **Einmal selbst scannen, bevor etwas in den Druck geht.** Ein Code mit Logo
|
||||||
|
> ist die einzige Stelle, an der die Anwendung etwas ausliefern kann, das auf
|
||||||
|
> dem einen Gerät geht und auf dem anderen nicht. Zwei verschiedene Apps
|
||||||
|
> kosten eine halbe Minute und ersparen eine Neuauflage.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Meine Codes
|
## Meine Codes
|
||||||
|
|
||||||
Hier stehen alle **dynamischen** Codes. Statische erscheinen nicht — sie
|
Hier stehen die **dynamischen** Codes und die statischen, die du abgelegt
|
||||||
werden nicht gespeichert.
|
hast. Beide Arten stehen nebeneinander in derselben Liste — ein statischer
|
||||||
|
trägt statt einer Kurz-URL das Abzeichen „statisch“ und zählt keine Scans.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -128,6 +156,10 @@ Projekte nebeneinander.
|
|||||||
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
|
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
|
||||||
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
|
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
|
||||||
liegen.
|
liegen.
|
||||||
|
- Ein **Filter nach Art** erscheint, sobald dynamische und statische Codes
|
||||||
|
nebeneinander liegen: *Alle Arten*, *Nur dynamische*, *Nur statische*.
|
||||||
|
Steht er auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung
|
||||||
|
nach Scans mit ihr — bei einer Liste aus lauter Strichen sagt sie nichts.
|
||||||
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
|
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
|
||||||
|
|
||||||
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
|
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
|
||||||
@@ -135,6 +167,21 @@ Vorschau. Die Wahl merkt sich der Browser.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### Statische Codes ablegen
|
||||||
|
|
||||||
|
Ein statischer Code ist mit dem Download eigentlich fertig — sein Inhalt
|
||||||
|
steckt in ihm. Der Knopf **In „Meine Codes“ ablegen** legt ihn trotzdem in die
|
||||||
|
Liste, damit du ihn wiederfindest und erneut herunterladen kannst.
|
||||||
|
|
||||||
|
Liegt dort schon ein statischer Code mit **identischem Inhalt**, hält das
|
||||||
|
Ablegen an und zeigt, welcher das ist und wann er entstand. Der Knopf heißt
|
||||||
|
dann **Trotzdem ablegen** — es ist eine Warnung, keine Sperre. Verglichen wird
|
||||||
|
nur der Inhalt, nicht die Gestaltung: Derselbe Code in Schwarz-Weiß und in
|
||||||
|
Hausfarben gilt hier als Dublette.
|
||||||
|
|
||||||
|
Bei **dynamischen** Codes fragt niemand nach. Zwei Kurz-Links auf dasselbe
|
||||||
|
Ziel sind der Normalfall, sobald zwei Kampagnen getrennt gezählt werden.
|
||||||
|
|
||||||
### Ziel ändern
|
### Ziel ändern
|
||||||
|
|
||||||

|

|
||||||
@@ -247,6 +294,25 @@ sich Typen abschalten; sie verschwinden dann aus der Auswahl.
|
|||||||
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
|
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
|
||||||
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
|
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
|
||||||
|
|
||||||
|
**Freigabe je Gruppe.** Über der Liste steht die Auswahl **Freigabe für**:
|
||||||
|
|
||||||
|
| Auswahl | Wirkung |
|
||||||
|
| --- | --- |
|
||||||
|
| Alle Gruppen (Grundeinstellung) | gilt für die ganze Installation |
|
||||||
|
| Nur: *eine Gruppe* | weicht für diese eine Gruppe ab |
|
||||||
|
|
||||||
|
So bekommt die Marketingabteilung Social Media und die Werkstatt nur Website
|
||||||
|
und PDF. Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung.
|
||||||
|
|
||||||
|
**Die Grundeinstellung gewinnt immer.** Ein Typ, der dort abgeschaltet ist,
|
||||||
|
lässt sich in keiner Gruppe einschalten — der Schalter steht dann ausgegraut
|
||||||
|
und nennt den Grund. Andersherum wäre „hier abgeschaltet“ keine verlässliche
|
||||||
|
Aussage mehr.
|
||||||
|
|
||||||
|
Wer in mehreren Gruppen ist, sieht auf der Startseite die **Vereinigung**.
|
||||||
|
Welche Freigabe zählt, entscheidet sich beim Anlegen anhand der gewählten
|
||||||
|
Gruppe.
|
||||||
|
|
||||||
### Gruppen
|
### Gruppen
|
||||||
|
|
||||||

|

|
||||||
@@ -275,7 +341,30 @@ mehreren Gruppen dienen; die Codes bleiben trotzdem strikt nach Gruppe
|
|||||||
getrennt.
|
getrennt.
|
||||||
|
|
||||||
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||||
Anwendung leiten — siehe README, Abschnitt „Mehrere Domains“.
|
Anwendung leiten. Was dafür einzurichten ist, steht nicht nur im README: Der
|
||||||
|
Knopf **Kurzhilfe** über der Liste führt es in sechs Schritten auf — DNS,
|
||||||
|
Weiterleitung im Proxy samt der drei nötigen Kopfzeilen, Zertifikat,
|
||||||
|
Registrieren, Gruppenzuteilung, Nachprüfen.
|
||||||
|
|
||||||
|
**Prüfen, ob eine Domain wirklich hierher führt.** Bei jedem Eintrag steht ein
|
||||||
|
Knopf **Prüfen**. Er geht den Weg der Scanner nach und meldet vier Dinge:
|
||||||
|
|
||||||
|
| Schritt | Was er sagt |
|
||||||
|
| --- | --- |
|
||||||
|
| DNS | ob der Name auflöst, und auf welche Adressen |
|
||||||
|
| Zertifikat | gültig bis wann, von wem ausgestellt |
|
||||||
|
| Erreichbar | ob dort ein Webserver antwortet, und wie schnell |
|
||||||
|
| Ziel | ob dahinter **diese** Installation steht |
|
||||||
|
|
||||||
|
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
|
||||||
|
sagt wenig; die Prüfung lässt sich beweisen, dass es diese Anwendung ist.
|
||||||
|
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
|
||||||
|
Meldung würde die eine wichtige verdecken.
|
||||||
|
|
||||||
|
> **Der Aufruf startet dort, wo der Container läuft.** Bei internem DNS oder
|
||||||
|
> Hairpin-NAT kann er scheitern, obwohl von außen alles stimmt. Und
|
||||||
|
> umgekehrt: Ein Erfolg von innen beweist noch nicht, dass die Domain von
|
||||||
|
> außen erreichbar ist. Dafür bleibt der Blick von einem fremden Netz aus.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+69
-3
@@ -315,8 +315,9 @@ Die **Vorschau** wird serverseitig gerendert und aktualisiert sich bei jeder
|
|||||||
|
|
||||||
## Meine Codes
|
## Meine Codes
|
||||||
|
|
||||||
Zeigt alle dynamischen Codes — statische erscheinen nicht, weil sie nicht
|
Zeigt die dynamischen Codes und die statischen, die abgelegt wurden. Ein
|
||||||
gespeichert werden.
|
abgelegter statischer Code trägt statt einer Kurz-URL das Abzeichen
|
||||||
|
„statisch“ und zählt keine Scans.
|
||||||
|
|
||||||
- **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten
|
- **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten
|
||||||
mit großer Vorschau. Die Wahl merkt sich der Browser.
|
mit großer Vorschau. Die Wahl merkt sich der Browser.
|
||||||
@@ -324,6 +325,10 @@ gespeichert werden.
|
|||||||
- **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl.
|
- **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl.
|
||||||
- **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt
|
- **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt
|
||||||
es auch eine Gruppenspalte.
|
es auch eine Gruppenspalte.
|
||||||
|
- **Filter nach Art** (*Alle Arten*, *Nur dynamische*, *Nur statische*),
|
||||||
|
sobald beide Arten vorkommen. Bei „Nur statische“ verschwindet die
|
||||||
|
Scan-Spalte und mit ihr die Sortierung danach: Statische zählen keine
|
||||||
|
Scans, die Spalte wäre eine Reihe aus Strichen.
|
||||||
- **Kurz-URL kopieren** mit einem Klick.
|
- **Kurz-URL kopieren** mit einem Klick.
|
||||||
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
|
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
|
||||||
- **Löschen** mit Rückfrage, die den betroffenen Code nennt.
|
- **Löschen** mit Rückfrage, die den betroffenen Code nennt.
|
||||||
@@ -426,6 +431,53 @@ Domains werden in der Verwaltung angelegt und Gruppen zugeteilt.
|
|||||||
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||||
Anwendung leiten.
|
Anwendung leiten.
|
||||||
|
|
||||||
|
### Kurzhilfe zur Einrichtung
|
||||||
|
|
||||||
|
Was außerhalb der Anwendung einzurichten ist, steht nicht nur im README: Auf
|
||||||
|
der Domain-Seite öffnet der Knopf **Kurzhilfe** eine Erklärung in sechs
|
||||||
|
Schritten — DNS-Eintrag, Weiterleitung im Reverse Proxy samt der drei nötigen
|
||||||
|
Kopfzeilen zum Abtippen, Zertifikat, Registrieren, Gruppenzuteilung,
|
||||||
|
Nachprüfen. Der Hostname aus `BASE_URL` wird als Beispiel eingesetzt.
|
||||||
|
|
||||||
|
Bewusst ein Dialog und kein Dauertext: Die Erklärung braucht man einmal beim
|
||||||
|
Einrichten und danach nie wieder.
|
||||||
|
|
||||||
|
### Prüfen, ob eine Domain hierher führt
|
||||||
|
|
||||||
|
Bei jedem Eintrag steht ein Knopf **Prüfen**. Er geht den Weg der Scanner nach
|
||||||
|
und meldet vier Dinge:
|
||||||
|
|
||||||
|
| Schritt | Was geprüft wird |
|
||||||
|
| --- | --- |
|
||||||
|
| DNS | Löst der Name auf? Auf welche Adressen? |
|
||||||
|
| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
|
||||||
|
| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
|
||||||
|
| Ziel | Steht dahinter **diese** Installation? |
|
||||||
|
|
||||||
|
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
|
||||||
|
sagt wenig — es könnte die Parkseite des Registrars sein oder eine andere
|
||||||
|
Installation dieser Anwendung. Deshalb ruft die Prüfung unter der Domain eine
|
||||||
|
eigene Route auf und lässt sich einen HMAC über eine frische Zufallszahl
|
||||||
|
zurückgeben. Das Geheimnis dafür entsteht beim ersten Check, liegt in der
|
||||||
|
Datenbank und verlässt den Server nie.
|
||||||
|
|
||||||
|
Die Meldungen sind im Klartext: abgelaufen, selbst ausgestellt, gilt für einen
|
||||||
|
anderen Namen, Zwischenzertifikat fehlt, Port 443 nimmt nichts an, „es
|
||||||
|
antwortet etwas, aber nicht diese Anwendung“. Läuft ein Zertifikat in
|
||||||
|
höchstens 14 Tagen ab, gilt der Schritt als Warnung statt als Erfolg.
|
||||||
|
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
|
||||||
|
Meldung würde die eine wichtige verdecken.
|
||||||
|
|
||||||
|
**Die Grenze der Aussage** steht unter jedem Bericht: Der Aufruf startet dort,
|
||||||
|
wo der Container läuft. Bei internem DNS oder Hairpin-NAT kann er scheitern,
|
||||||
|
obwohl von außen alles stimmt; umgekehrt beweist ein Erfolg von innen nicht,
|
||||||
|
dass die Domain von außen erreichbar ist.
|
||||||
|
|
||||||
|
Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer aus
|
||||||
|
der Anfrage — sonst wäre die Route ein Werkzeug, mit dem sich vom Server aus
|
||||||
|
beliebige Adressen abklopfen ließen. Näheres in
|
||||||
|
[sicherheit.md](sicherheit.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Erscheinungsbild
|
## Erscheinungsbild
|
||||||
@@ -500,7 +552,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 |
|
||||||
@@ -512,6 +564,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
|
||||||
|
|||||||
+24
-38
@@ -15,8 +15,13 @@ Fußzeile.
|
|||||||
Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
|
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,
|
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
|
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 Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes,
|
||||||
und 1.6.0 diesen Versionshinweis.
|
1.6.0 den Versionshinweis und 1.7.0 sechs Erweiterungen auf einmal — Logo im
|
||||||
|
Code, Freigabe je Gruppe, Domain-Check, Dublettenprüfung, Filter nach Art und
|
||||||
|
die Kurzhilfe zu den Domains.
|
||||||
|
|
||||||
|
Damit sind zwei Punkte erledigt, die hier lange unter „Als Nächstes“ standen:
|
||||||
|
**QR-Typen je Gruppe freigeben** und **Logo im QR-Code**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,27 +30,7 @@ und 1.6.0 diesen Versionshinweis.
|
|||||||
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
|
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
|
||||||
Person, die den Code kennt.
|
Person, die den Code kennt.
|
||||||
|
|
||||||
### 1. QR-Typen je Gruppe freigeben · mittel
|
### 1. Statistik über die Zeit · mittel
|
||||||
|
|
||||||
Heute gilt die Freigabe eines Typs für die ganze Installation: Was
|
|
||||||
abgeschaltet ist, ist für alle weg. Bei mehreren Gruppen ist das zu grob — die
|
|
||||||
Marketingabteilung braucht Social Media, die Werkstatt nur Website und PDF.
|
|
||||||
|
|
||||||
Geplant: Die Freigabe bekommt eine zweite Dimension. Es bleibt eine globale
|
|
||||||
Einstellung als Grundlage, und je Gruppe lässt sich davon abweichen. Wer in
|
|
||||||
mehreren Gruppen ist, sieht auf der Startseite die Vereinigung; beim Anlegen
|
|
||||||
entscheidet die gewählte Gruppe.
|
|
||||||
|
|
||||||
Zu klären ist der Fall, in dem ein Typ global aus, in einer Gruppe aber an
|
|
||||||
ist — entweder die Gruppe darf das überstimmen, oder die globale Abschaltung
|
|
||||||
gewinnt immer. Die zweite Lesart ist die weniger überraschende und
|
|
||||||
wahrscheinlich die richtige.
|
|
||||||
|
|
||||||
Berührt: `QrTypeSetting` (zusammengesetzter Schlüssel aus Typ und Gruppe),
|
|
||||||
`src/lib/qr/availability.ts`, die Startseite und die Typ-Verwaltung, die dann
|
|
||||||
eine Gruppenauswahl bekommt.
|
|
||||||
|
|
||||||
### 2. Statistik über die Zeit · mittel
|
|
||||||
|
|
||||||
Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der
|
Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der
|
||||||
Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe
|
Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe
|
||||||
@@ -59,7 +44,7 @@ sagt nichts über einzelne Menschen aus und braucht deshalb weder Einwilligung
|
|||||||
noch Löschfristen. Wer mehr will, muss sich mit beidem befassen — das ist eine
|
noch Löschfristen. Wer mehr will, muss sich mit beidem befassen — das ist eine
|
||||||
andere Entscheidung als „ein Diagramm wäre schön“.
|
andere Entscheidung als „ein Diagramm wäre schön“.
|
||||||
|
|
||||||
### 3. Papierkorb statt Löschen · klein
|
### 2. Papierkorb statt Löschen · klein
|
||||||
|
|
||||||
Heute ist ein gedruckter Code nach einem Fehlklick unwiederbringlich tot,
|
Heute ist ein gedruckter Code nach einem Fehlklick unwiederbringlich tot,
|
||||||
samt seiner Dateien und Rückmeldungen.
|
samt seiner Dateien und Rückmeldungen.
|
||||||
@@ -69,20 +54,7 @@ Geplant: Gelöschte Codes 30 Tage aufheben und dann endgültig entfernen. In
|
|||||||
im Papierkorb liegt, bleibt sein Slug belegt — sonst könnte ihn ein neuer Code
|
im Papierkorb liegt, bleibt sein Slug belegt — sonst könnte ihn ein neuer Code
|
||||||
bekommen und die gedruckten Codes zeigten plötzlich woandershin.
|
bekommen und die gedruckten Codes zeigten plötzlich woandershin.
|
||||||
|
|
||||||
### 4. Logo im QR-Code · mittel
|
### 3. Ablaufdatum und Zeitsteuerung · mittel
|
||||||
|
|
||||||
Ein kleines Bild in die Mitte setzen, wie man es von Firmen-Codes kennt.
|
|
||||||
|
|
||||||
Geplant: Upload eines Logos je Code, mittig eingesetzt, mit erzwungener
|
|
||||||
Fehlerkorrektur **H** und einer Warnung, sobald zu viel Fläche verdeckt wird.
|
|
||||||
|
|
||||||
Das ist der Punkt mit dem größten Risiko, etwas Kaputtes auszuliefern: Ein
|
|
||||||
Code, der auf dem eigenen Handy noch funktioniert, kann bei anderen
|
|
||||||
Scanner-Apps durchfallen. Gehört deshalb nicht ohne eine ehrliche Prüfung mit
|
|
||||||
mehreren Geräten und Apps ausgeliefert — und mit einer klaren Obergrenze für
|
|
||||||
die verdeckte Fläche.
|
|
||||||
|
|
||||||
### 5. Ablaufdatum und Zeitsteuerung · mittel
|
|
||||||
|
|
||||||
Ein Code, der ab einem Datum woandershin führt oder abläuft — für
|
Ein Code, der ab einem Datum woandershin führt oder abläuft — für
|
||||||
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
|
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
|
||||||
@@ -99,6 +71,20 @@ Geplant, in zwei Stufen:
|
|||||||
Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen
|
Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen
|
||||||
Fenstern geschieht.
|
Fenstern geschieht.
|
||||||
|
|
||||||
|
### 4. Gestaltung eines gespeicherten Codes ändern · klein
|
||||||
|
|
||||||
|
Aufgefallen beim Einbau des Logos: In „Meine Codes“ lässt sich die Gestaltung
|
||||||
|
eines abgelegten Codes gar nicht mehr anfassen — weder Farben noch Größe noch
|
||||||
|
Logo. Wer ein Logo nachrüsten will, kommt nur über die Schnittstelle daran.
|
||||||
|
|
||||||
|
Bei einem **dynamischen** Code ist das unnötig streng: Das gedruckte Bild
|
||||||
|
bleibt zwar, wie es ist, aber der erneute Download soll ja gerade die neue
|
||||||
|
Gestaltung tragen. Geplant: derselbe Gestaltungsbereich wie im Generator, im
|
||||||
|
Bearbeiten-Dialog.
|
||||||
|
|
||||||
|
Bei einem **statischen** Code bleibt es beim Nein — er ist ein Schnappschuss,
|
||||||
|
und eine geänderte Gestaltung ergäbe ein anderes Bild als das gedruckte.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Später denkbar
|
## Später denkbar
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.6.0",
|
"version": "1.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -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[]
|
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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -53,7 +53,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();
|
||||||
@@ -64,7 +69,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."
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -83,8 +92,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,
|
||||||
|
});
|
||||||
|
|
||||||
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
|
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
|
||||||
// der eigene Upload.
|
// der eigene Upload.
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
types: types.map(
|
||||||
|
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup, usable }) => ({
|
||||||
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,
|
||||||
usable,
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,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) {
|
||||||
|
|||||||
@@ -98,13 +98,21 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`}</HelpCode>
|
|||||||
</HelpStep>
|
</HelpStep>
|
||||||
|
|
||||||
<HelpStep n={6} title="Nachprüfen">
|
<HelpStep n={6} title="Nachprüfen">
|
||||||
<p>Von außerhalb des eigenen Netzes:</p>
|
<p>
|
||||||
|
Dafür gibt es den Knopf <strong>Prüfen</strong> bei jedem Eintrag
|
||||||
|
in der Liste. Er meldet DNS, Zertifikat, Erreichbarkeit — und ob
|
||||||
|
hinter der Domain wirklich diese Installation steht.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Der Aufruf startet dort, wo dieser Container läuft. Bei internem
|
||||||
|
DNS oder Hairpin-NAT kann er scheitern, obwohl von außen alles
|
||||||
|
stimmt. Von einem fremden Netz aus hilft dann:
|
||||||
|
</p>
|
||||||
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
|
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
|
||||||
<p>
|
<p>
|
||||||
Kommt eine Antwort dieser Anwendung, stimmen DNS, Proxy und
|
Zum Schluss einen Code der zugeteilten Gruppe anlegen, die Domain
|
||||||
Zertifikat. Danach einen Code der zugeteilten Gruppe anlegen, die
|
auswählen und den fertigen Code einmal mit dem Handy scannen — das
|
||||||
Domain auswählen und den fertigen Code einmal mit dem Handy
|
ist die einzige Prüfung, die wirklich zählt.
|
||||||
scannen — das ist die einzige Prüfung, die wirklich zählt.
|
|
||||||
</p>
|
</p>
|
||||||
</HelpStep>
|
</HelpStep>
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@@ -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
|
||||||
|
? "In der Grundeinstellung abgeschaltet — für diese Gruppe nicht einschaltbar."
|
||||||
|
: entry.enabled
|
||||||
? entry.implemented
|
? entry.implemented
|
||||||
? "Erscheint auf der Startseite und kann verwendet werden."
|
? inGruppe
|
||||||
|
? `Steht ${gruppenname} zur Verfügung.`
|
||||||
|
: "Erscheint auf der Startseite und kann verwendet werden."
|
||||||
: "Erscheint auf der Startseite als „Bald verfügbar“."
|
: "Erscheint auf der Startseite als „Bald verfügbar“."
|
||||||
|
: inGruppe
|
||||||
|
? `Für ${gruppenname} abgeschaltet — andere Gruppen bleiben unberührt.`
|
||||||
: "Ausgeblendet — erscheint nicht auf der Startseite."}
|
: "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>
|
||||||
);
|
);
|
||||||
|
|||||||
+17
-4
@@ -8,10 +8,10 @@ import pkg from "../../package.json";
|
|||||||
* unweigerlich auseinander; eine Prüfung wacht darüber, dass die erzeugte
|
* unweigerlich auseinander; eine Prüfung wacht darüber, dass die erzeugte
|
||||||
* Datei zu dieser Quelle passt.
|
* Datei zu dieser Quelle passt.
|
||||||
*
|
*
|
||||||
* Die Einträge sind aus dem Abschnitt „Gebaut“ der Roadmap und der
|
* Die Einträge bis 1.5.0 sind aus dem Abschnitt „Gebaut“ der Roadmap und der
|
||||||
* Git-Historie zusammengetragen. Versionsnummern gab es dabei nie — es
|
* Git-Historie zusammengetragen: Versionsnummern gab es damals nicht, deshalb
|
||||||
* existieren keine Tags —, deshalb ist je **Erscheinungstag** eine Version
|
* ist je **Erscheinungstag** eine gebildet. Nachträglich vergeben, aber die
|
||||||
* gebildet. Das ist nachträglich vergeben, aber die Daten selbst stimmen.
|
* Daten selbst stimmen. Ab 1.6.0 trägt jede Fassung einen Git-Tag.
|
||||||
*/
|
*/
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -23,6 +23,19 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: readonly ChangelogEntry[] = [
|
export const CHANGELOG: readonly ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "1.7.0",
|
||||||
|
date: "2026-08-21",
|
||||||
|
summary: "Logo im Code, Freigabe je Gruppe und ein Domain-Check.",
|
||||||
|
changes: [
|
||||||
|
"Ein Logo in der Mitte des Codes — das der Installation oder ein eigenes je Code, 10 bis 25 % der Kantenlänge. Die Fehlerkorrektur springt dabei auf H.",
|
||||||
|
"QR-Typen lassen sich je Gruppe freigeben. Die Grundeinstellung bleibt die Grundlage; eine globale Abschaltung gewinnt immer.",
|
||||||
|
"Der Knopf „Prüfen“ bei jeder Domain meldet DNS, Zertifikat, Erreichbarkeit — und ob dahinter wirklich diese Installation steht.",
|
||||||
|
"Eine Kurzhilfe auf der Domain-Seite führt in sechs Schritten auf, was für eine neue Domain einzurichten ist.",
|
||||||
|
"„Meine Codes“ lässt sich nach Art filtern: alle, nur dynamische, nur statische.",
|
||||||
|
"Beim Ablegen eines statischen Codes wird gewarnt, wenn derselbe Inhalt schon in der Gruppe liegt.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "1.6.0",
|
version: "1.6.0",
|
||||||
date: "2026-08-21",
|
date: "2026-08-21",
|
||||||
|
|||||||
+143
-11
@@ -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 {
|
||||||
@@ -15,10 +17,21 @@ import {
|
|||||||
* Zwei voneinander unabhängige Eigenschaften bestimmen, was ein Typ kann:
|
* Zwei voneinander unabhängige Eigenschaften bestimmen, was ein Typ kann:
|
||||||
*
|
*
|
||||||
* - `implemented` — steht in der Registry und sagt, ob der Typ überhaupt
|
* - `implemented` — steht in der Registry und sagt, ob der Typ überhaupt
|
||||||
* ausprogrammiert ist. In V1 gilt das nur für „Website“.
|
* ausprogrammiert ist. Seit 1.1.0 gilt das für alle fünfzehn; die
|
||||||
|
* Unterscheidung bleibt für Typen, die später dazukommen.
|
||||||
* - `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 +42,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 +76,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 +129,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 +192,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 +207,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 +233,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 +244,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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user