2 Commits
Author SHA1 Message Date
hnzio 08e8875cb5 Release 0.2.0 2026-10-21 17:25:28 +02:00
hnzio 7a49e66c37 Release 0.1.1 2026-10-21 16:38:56 +02:00
43 changed files with 3243 additions and 449 deletions

No files matched your search

+16
View File
@@ -1,3 +1,19 @@
# Relative paths are resolved from the project directory.
SENDEWERK_DB_PATH=.data/sendewerk.sqlite
SENDEWERK_UPLOAD_DIR=.data/uploads
SENDEWERK_MAIL_OUTBOX_DIR=.data/mail-outbox
# Generate this locally and insert it here as described in README.md. Never commit .env.
SENDEWERK_SETUP_TOKEN=
SENDEWERK_APP_ORIGIN=http://localhost:5173
# "local" writes messages to the outbox and never contacts a mail server.
SENDEWERK_MAIL_TRANSPORT=local
SENDEWERK_MAIL_FROM=Sendewerk <sendewerk@localhost>
# Required only when SENDEWERK_MAIL_TRANSPORT=smtp is explicitly selected.
SENDEWERK_SMTP_HOST=
SENDEWERK_SMTP_PORT=587
SENDEWERK_SMTP_SECURE=false
SENDEWERK_SMTP_USER=
SENDEWERK_SMTP_PASSWORD=
+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.1.0",
"version": "0.2.0",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
+55 -13
View File
@@ -2,9 +2,9 @@
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
ausschließlich Etappe 1: technisches Grundgerüst, persistente Beispieldaten
und gekennzeichnete Gestaltungsvorschauen. Anmeldung, Bearbeitung und echte
Live-Steuerung folgen in späteren Etappen.
Etappe 1 und 2: technisches Grundgerüst, persistente Beispieldaten,
Gestaltungsvorschauen sowie echte Anmeldung mit Barkeeper-/Thekencrew-Rollen.
Inhaltsbearbeitung und Live-Steuerung folgen in späteren Etappen.
## Voraussetzungen
@@ -20,6 +20,9 @@ nicht erforderlich.
```sh
npm ci
cp .env.example .env
SETUP_TOKEN="$(openssl rand -hex 32)"
sed -i "s/^SENDEWERK_SETUP_TOKEN=.*/SENDEWERK_SETUP_TOKEN=$SETUP_TOKEN/" .env
printf 'Betreiber-Setup-Token: %s\n' "$SETUP_TOKEN"
npm run setup
npm run dev
```
@@ -29,18 +32,41 @@ erstellt die konfigurierten Verzeichnisse, führt ausstehende Migrationen aus
und lädt den reproduzierbaren Beispieldatensatz. Der Seed darf beliebig oft
ausgeführt werden und ersetzt nur die von ihm verwalteten Beispieldaten.
Beim ersten Aufruf verlangt `/setup` den ausgegebenen Wert aus
`SENDEWERK_SETUP_TOKEN` und legt genau einen Barkeeper an. Der Befehl schreibt
den Wert in die nicht versionierte `.env`, damit auch spätere lokale Starts
dieselbe Konfiguration verwenden. Nach erfolgreicher Einrichtung ist `/setup`
dauerhaft geschlossen und der Token kann aus `.env` entfernt werden. Der Seed
enthält bewusst kein Konto und kein Standardpasswort.
## Konfiguration
| Variable | Vorgabe | Bedeutung |
| ---------------------- | ------------------------ | ----------------------------------- |
| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei |
| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis |
| `HOST` | adapterabhängig | Bind-Adresse des Produktionsservers |
| `PORT` | `3000` | Port des Produktionsservers |
| Variable | Vorgabe | Bedeutung |
| --------------------------- | ------------------------ | ---------------------------------------------------- |
| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei |
| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis |
| `SENDEWERK_MAIL_OUTBOX_DIR` | `.data/mail-outbox` | Lokale, nicht öffentlich ausgelieferte Testmail |
| `SENDEWERK_SETUP_TOKEN` | keine | einmaliges Betreibergeheimnis, mindestens 32 Zeichen |
| `SENDEWERK_APP_ORIGIN` | `http://localhost:5173` | feste Origin für Links und Cookie-Sicherheit |
| `SENDEWERK_MAIL_TRANSPORT` | `local` | `local` oder ausdrücklich `smtp` |
| `SENDEWERK_MAIL_FROM` | lokaler Absender | Absender; bei SMTP verpflichtend |
| `SENDEWERK_SMTP_HOST` | keine | SMTP-Server, nur im SMTP-Modus |
| `SENDEWERK_SMTP_PORT` | `587` | SMTP-Port |
| `SENDEWERK_SMTP_SECURE` | `false` | `true` für implizites TLS, üblicherweise Port 465 |
| `SENDEWERK_SMTP_USER` | keine | optionaler SMTP-Benutzer |
| `SENDEWERK_SMTP_PASSWORD` | keine | optionales SMTP-Passwort |
| `HOST` | adapterabhängig | Bind-Adresse des Produktionsservers |
| `PORT` | `3000` | Port des Produktionsservers |
Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In
Cloudron liegen Datenbank und Uploads unter `/app/data`.
Der Vorgabemodus `local` baut keine Netzwerkverbindung auf. Einladungen und
Passwort-Resets landen als JSON-Dateien in `.data/mail-outbox`; die enthaltenen
Links sind Geheimnisse und dürfen nicht veröffentlicht werden. Echter Versand
findet nur statt, wenn `SENDEWERK_MAIL_TRANSPORT=smtp` und die SMTP-Werte
explizit gesetzt sind.
## Befehle
| Befehl | Zweck |
@@ -48,7 +74,7 @@ Cloudron liegen Datenbank und Uploads unter `/app/data`.
| `npm run setup` | Verzeichnisse, Migrationen und Seed für lokale Entwicklung |
| `npm run db:migrate` | Ausstehende Migrationen atomar ausführen |
| `npm run db:seed` | Reproduzierbare Beispieldaten laden |
| `npm run dev` | Entwicklungsserver starten |
| `npm run dev` | Migrationen ausführen und Entwicklungsserver starten |
| `npm run check` | Svelte- und TypeScript-Prüfung |
| `npm run lint` | Formatierung und ESLint prüfen |
| `npm test` | Verhaltenstests mit temporärer, getrennter Datenbank |
@@ -61,6 +87,7 @@ Cloudron liegen Datenbank und Uploads unter `/app/data`.
```sh
npm ci
npm run setup
export SENDEWERK_APP_ORIGIN=http://localhost:3000
npm run build
HOST=127.0.0.1 PORT=3000 npm start
```
@@ -68,6 +95,12 @@ HOST=127.0.0.1 PORT=3000 npm start
Der Healthcheck liegt unter <http://localhost:3000/healthz> und prüft auch den
Datenbankzugriff.
Für lokales HTTP wird das Sitzungscookie gezielt ohne `Secure` gesetzt, bleibt
aber `HttpOnly` und `SameSite=Lax`. Bei einer `https:`-Origin ist `Secure`
automatisch aktiv. Die Build-Origin muss beim lokalen Produktionsbuild mit der
später verwendeten Browser-Origin übereinstimmen; SvelteKits Origin-Prüfung
bleibt aktiv.
Vor der ersten Browserprüfung wird der Testbrowser einmalig installiert:
```sh
@@ -82,9 +115,17 @@ Das frühe Cloudron-Paket besteht aus `Dockerfile`, `CloudronManifest.json` und
```sh
docker build -t sendewerk:local .
docker run --rm -p 3000:3000 -v sendewerk-data:/app/data sendewerk:local
docker run --rm -p 3000:3000 \
-e SENDEWERK_APP_ORIGIN=http://localhost:3000 \
-e SENDEWERK_SETUP_TOKEN="$(openssl rand -hex 32)" \
-v sendewerk-data:/app/data sendewerk:local
```
Auf Cloudron muss das Betreiber-Setup-Token vor dem ersten Browseraufruf als
Umgebungsvariable gesetzt werden. `CLOUDRON_APP_ORIGIN` liefert dort die feste
HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als
Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben.
Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup,
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
abschließend geprüft werden.
@@ -101,5 +142,6 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert.
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
`better-sqlite3` sind MIT-lizenziert; TypeScript und Playwright stehen unter
Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum.
`better-sqlite3` und `@node-rs/argon2` sind MIT-lizenziert; Nodemailer verwendet
MIT-0, TypeScript und Playwright stehen unter Apache-2.0. Die Lockdatei fixiert
den tatsächlich installierten Baum.
+43 -13
View File
@@ -2,18 +2,19 @@
## Etappe 1
| Kriterium | Nachweis | Stand |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Frischer Checkout startet ohne Cloudron | `npm ci` erfolgreich; README-Setup und lokaler Produktionsstart ausgeführt | erfüllt am 21.10.2026 |
| Typprüfung und Build erfolgreich | `npm run check`, `npm run build` | erfüllt am 21.10.2026 |
| Theme bleibt nach Neuladen erhalten | Playwright: System auf Hell wechseln, Reload, DOM und Schalterzustand prüfen | erfüllt am 21.10.2026 |
| Testdaten überleben Neustart | Gebauten Server zweimal gegen `.data/sendewerk.sqlite` gestartet; Fundusdaten und `/healthz` jeweils vorhanden | erfüllt am 21.10.2026 |
| Migration wiederholt ausführbar | lokal mehrfach ausgeführt; Verhaltenstest führt Migration zweimal in temporärer DB aus | erfüllt am 21.10.2026 |
| Seed reproduzierbar | lokal und im Verhaltenstest zweimal ausgeführt; danach 4 Pieces, 1 Sendung und 3 Verwendungen | erfüllt am 21.10.2026 |
| Tests verwenden getrennte Daten | Vitest und Playwright erzeugen je eigene Datenbank im Betriebssystem-Tempdir | erfüllt am 21.10.2026 |
| Desktop und Smartphone ohne horizontales Scrollen | 4 Playwright-Abläufe; 1440x1000, 1280x900 und 390x844; Screenshots visuell kontrolliert | erfüllt am 21.10.2026 |
| Cloudron-Pfade und Healthcheck vorbereitet | Manifestpfad `/app/data/sendewerk.sqlite`, `/healthz`, `start.sh`; JSON-Kernfelder und Bash-Syntax geprüft | vorbereitet; Container offen |
| Echte Cloudron-Prüfung | Installation, Backup/Restore, Proxy und read-only Laufzeit | offen, kein Serverzugang |
| Kriterium | Nachweis | Stand |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Frischer Checkout startet ohne Cloudron | `npm ci` erfolgreich; README-Setup und lokaler Produktionsstart ausgeführt | erfüllt am 21.10.2026 |
| Typprüfung und Build erfolgreich | `npm run check`, `npm run build` | erfüllt am 21.10.2026 |
| Theme bleibt nach Neuladen erhalten | Playwright: System auf Hell wechseln, Reload, DOM und Schalterzustand prüfen | erfüllt am 21.10.2026 |
| Testdaten überleben Neustart | Gebauten Server zweimal gegen `.data/sendewerk.sqlite` gestartet; Fundusdaten und `/healthz` jeweils vorhanden | erfüllt am 21.10.2026 |
| Migration wiederholt ausführbar | lokal mehrfach ausgeführt; Verhaltenstest führt Migration zweimal in temporärer DB aus | erfüllt am 21.10.2026 |
| Seed reproduzierbar | lokal und im Verhaltenstest zweimal ausgeführt; danach 4 Pieces, 1 Sendung und 3 Verwendungen | erfüllt am 21.10.2026 |
| Tests verwenden getrennte Daten | Vitest und Playwright erzeugen je eigene Datenbank im Betriebssystem-Tempdir | erfüllt am 21.10.2026 |
| Desktop und Smartphone ohne horizontales Scrollen | 8 Playwright-Abläufe bei 390, 768, 960, 1280 und 1440 Pixeln; Screenshots visuell kontrolliert | erfüllt am 21.10.2026 |
| Kompakte Arbeitsoberfläche in beiden Farbmodi | 390, 768 und 1440 Pixel jeweils in Light/Dark; einspaltige Mobilkarten, lange Titel, Metadatenumbrüche und Leerzustände | erfüllt am 21.10.2026 |
| Cloudron-Pfade und Healthcheck vorbereitet | Manifestpfad `/app/data/sendewerk.sqlite`, `/healthz`, `start.sh`; JSON-Kernfelder und Bash-Syntax geprüft | vorbereitet; Container offen |
| Echte Cloudron-Prüfung | Installation, Backup/Restore, Proxy und read-only Laufzeit | offen, kein Serverzugang |
## Ausgeführte Prüfungen
@@ -23,7 +24,9 @@
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 4 von 4 Browsertests erfolgreich
- `npm run test:e2e`: 8 von 8 Browsertests erfolgreich; 390, 768 und 1440 Pixel
jeweils in Light- und Darkmode sowie Tastaturbedienung, feste Navigation,
einspaltige Funduskarten, lange Inhalte und Überläufe geprüft
- Zwei getrennte `npm start`-Läufe: Healthcheck und persistente Daten erfolgreich
- `bash -n start.sh` und programmatische Manifest-Kernfeldprüfung: erfolgreich
@@ -36,3 +39,30 @@
- Ein zusätzlicher Python-Playwright-Lauf: nicht gestartet, weil das optionale
Python-Modul `playwright` nicht installiert war. Die projektgebundene
TypeScript-Playwright-Suite lief stattdessen vollständig erfolgreich.
## Etappe 2
| Kriterium | Nachweis | Stand |
| ------------------------------- | ----------------------------------------------------------------------------------- | --------------------- |
| Erstadmin danach geschlossen | Browser-Setup sowie wiederholter Service-Aufruf geprüft; Seed enthält kein Konto | erfüllt am 21.10.2026 |
| Login, Logout und lokales HTTP | Browserablauf; `HttpOnly`, `SameSite=Lax`, ohne `Secure` bei expliziter HTTP-Origin | erfüllt am 21.10.2026 |
| Einladung und Reset | lokale Outbox bis Kontoaktivierung und neuem Login im Browser durchlaufen | erfüllt am 21.10.2026 |
| Einmalige und abgelaufene Links | Verhaltenstests für Wiederverwendung und Ablauf von Einladung und Reset | erfüllt am 21.10.2026 |
| Direkte Admin-Aktion verweigert | Thekencrew-POST auf Einladungsaction liefert 403 | erfüllt am 21.10.2026 |
| Sperrung entzieht Zugriff | aktive Thekencrew-Sitzung wird gelöscht und beim nächsten Request abgewiesen | erfüllt am 21.10.2026 |
| Keine echte Mail ungefragt | Browser- und Verhaltenstests nutzen ausschließlich temporäre lokale Outbox | erfüllt am 21.10.2026 |
| Echter SMTP-Versand | nicht ausgeführt, keine Zugangsdaten bereitgestellt | offen |
## Ausgeführte Prüfungen Etappe 2
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 9 Auth-, Migrations-, Mail- und Dauer-Verhaltenstests erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 9 von 9 Browsertests erfolgreich; Erstadmin,
Login/Logout, fremder Origin, Einladung, direkte Rollenprüfung, Sperrung,
Reset sowie bestehende responsive Arbeitsansichten geprüft
- Lokale Outbox in isolierten temporären Verzeichnissen geprüft
- Lokaler Produktionsserver der Browsertests über HTTP gestartet; Healthcheck
und Migration vor Start erfolgreich
- `bash -n start.sh`: erfolgreich
+35 -5
View File
@@ -38,7 +38,7 @@ Kern. Folgende Grenzen sind für spätere Migrationen bereits festgelegt.
| Bereich | Geplante Entitäten und Beziehungen |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens, Sperren und technische Zugänge; Tokens nur gehasht |
| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später |
| Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft |
| Inhalt | `pieces` mit Status und Dauer; Quellen, Medien, private Recherche, Moderation, Präsentation und Vorlagen als getrennte Daten |
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz |
@@ -63,11 +63,41 @@ oder eine explizite Abschnittsdauer. Beides wird nie addiert. Reserveelemente
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
getrennt.
## Anmeldung und Rechte
Etappe 2 ergänzt lokale Konten mit den festen Rollen `barkeeper` und
`thekencrew`. Passwörter werden mit Argon2id gespeichert. Sitzungs-, Einladungs-
und Reset-Tokens entstehen aus kryptografischem Zufall; in SQLite liegt jeweils
nur ihr SHA-256-Hash. Der Browser erhält ausschließlich das rohe Sitzungstoken
in einem `HttpOnly`-, `SameSite=Lax`-Cookie.
`hooks.server.ts` löst die Sitzung für jeden Request auf. Die Datenbankabfrage
verbindet die Session immer mit dem aktuellen Nutzerzustand, sodass Sperren und
Rollenwechsel ohne verzögerte Token-Erneuerung wirken. Geschützte Routen
erfordern eine Anmeldung; Verwaltungs-Loads und jede Verwaltungs-Action prüfen
zusätzlich die Barkeeper-Rolle. Sperrung und Passwortwechsel löschen bestehende
Sessions transaktional.
Der Erstadmin benötigt sowohl eine leere Nutzertabelle als auch ein extern
konfiguriertes Setup-Token. Einladungs- und Reset-Links sind 24 Stunden gültig,
werden erst beim bestätigenden POST verbraucht und sind genau einmal nutzbar.
Fehlgeschlagene Loginversuche werden pro E-Mail-Schlüssel und Clientadresse in
einem Zeitfenster begrenzt, ohne das Konto dauerhaft zu sperren.
Mail verwendet einen Adapter: `local` schreibt geschützte Dateien in den
konfigurierten Outbox-Pfad, `smtp` nutzt Nodemailer nur nach expliziter Auswahl.
Die feste Anwendungs-Origin erzeugt Links und bestimmt den `Secure`-Status der
Cookies; sie wird nicht aus ungeprüften Request-Headern übernommen.
## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
Auf breiten Ansichten stehen Fundus und Ablauf nebeneinander. Auf schmalen
Ansichten werden sie über eine gut erreichbare Umschaltung einzeln gezeigt.
Auf dem Desktop bleibt eine feste Seitenleiste sichtbar; Verwaltung liegt an
ihrem unteren Ende. Bis einschließlich `52rem` ersetzen eine obere Leiste und
die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Der Live-Modus ist eine eigene, fokussierte Ansicht; die spätere
Präsentationsausgabe erhält eine separate, strikt reduzierte Datenansicht.
@@ -77,8 +107,8 @@ Fachabläufe werden jedoch nicht vorgetäuscht.
## Sicherheitsgrenzen ab den Folgeetappen
Authentifizierung und Autorisierung werden serverseitig an jeder Route und
Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt.
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt.
Uploadzugriff folgt denselben Rechten. Netzwerkabrufe erhalten Schutz gegen
interne Ziele, Größen-/Zeitlimits und kontrollierte Redirects. Diese Regeln
sind geplant, aber nicht Bestandteil der Etappe-1-Vorschauen.
+54
View File
@@ -81,3 +81,57 @@ Vorschauseiten lesen echte persistente SQLite-Seed-Daten, bieten aber keine
funktionslosen Speichern- oder Live-Schaltflächen. Ein sichtbarer Hinweis
grenzt Gestaltungsvorschauen von den späteren Fachfunktionen ab. Damit wird
keine noch nicht implementierte Funktion als fertig dargestellt.
## D-007: Arbeitsansicht nach verfügbarem Platz
Die feste Desktop-Seitenleiste wird bis `52rem` verwendet und dann vollständig
durch mobile Kopf- und Fußnavigation ersetzt. Der Sendungsbau schaltet bereits
bis `72rem` von zwei Spalten auf eine bedienbare Pane-Umschaltung um. Dadurch
bleibt die Desktopnavigation in schmalen Fenstern erhalten, ohne Fundus und
Ablauf zusammenzudrücken.
Der Fundus erscheint auf dem Desktop als kompakte Liste und auf kleinen
Bildschirmen als Kartenfolge. Vorschau- und Schreibschutzinformationen sind in
einem nativen, tastaturbedienbaren `details`-Element gebündelt. Diese
Darstellungsentscheidung ändert weder Datenmodell noch Funktionsumfang.
## D-008: Widerrufbare Datenbanksitzungen und Argon2id
Sendewerk verwendet Datenbanksitzungen statt JWT. SvelteKit empfiehlt diesen
Ansatz, wenn sofortiger Widerruf wichtiger als eine zusätzliche Datenbankabfrage
ist. Ein zufälliges Sitzungstoken liegt nur als SHA-256-Hash in SQLite; der
Browser erhält es als `HttpOnly`-, `SameSite=Lax`-Cookie. Nutzerstatus und Rolle
werden bei jedem Request erneut verbunden. Lokal erlaubt eine explizite
`http:`-Origin Cookies ohne `Secure`, bei `https:` wird `Secure` gesetzt.
Passwörter hasht `@node-rs/argon2` 2.2.2 mit Argon2id und den dokumentierten
Paketparametern. Zufallstokens und SHA-256 stammen aus `node:crypto`; eigene
Kryptoprimitive werden nicht implementiert.
Quellen:
- <https://svelte.dev/docs/kit/auth>
- <https://svelte.dev/docs/kit/hooks>
- <https://github.com/napi-rs/node-rs/tree/main/packages/argon2>
## D-009: Form-Actions, geschlossenes Setup und Rollenprüfung
Alle Auth-Schreibvorgänge verwenden POST-Form-Actions beziehungsweise einen
POST-Endpunkt. SvelteKits gleichursprüngliche Formularprüfung bleibt aktiv. Der
Erstadmin erfordert ein mindestens 32 Zeichen langes Betreiber-Setup-Token und
eine transaktional geprüfte leere Nutzertabelle. Danach bleibt `/setup`
geschlossen. Barkeeper-Rechte werden in jeder Verwaltungsaktion erneut geprüft;
UI-Sichtbarkeit ist keine Sicherheitsgrenze.
Quelle: <https://svelte.dev/docs/kit/form-actions>
## D-010: Lokale Mail-Outbox als sichere Vorgabe
Der Mailmodus ist standardmäßig `local` und schreibt Einladungs-/Resetnachrichten
in einen konfigurierten persistenten Pfad. Dadurch wird lokal keine echte Mail
ungefragt versandt. Nur die explizite Auswahl `smtp` initialisiert Nodemailer;
bei SMTP sind Host und Absender verpflichtend. Outbox-Dateien sind private
Geheimnisträger und werden weder versioniert noch über eine Route ausgeliefert.
Nodemailer 10.1.7 unterstützt Node 20 oder neuer und ist MIT-0-lizenziert.
Quelle: <https://github.com/nodemailer/nodemailer>
+30 -4
View File
@@ -2,8 +2,9 @@
## Aktueller Umfang
Etappe 1 ist abgeschlossen. Architektur, Gesamtdatenmodell, Entscheidungen und
lokaler Betriebsweg sind dokumentiert. Etappe 2 wurde nicht begonnen.
Etappe 1 und Etappe 2 sind abgeschlossen. Architektur, Gesamtdatenmodell,
Anmeldung, Rollen und lokaler Betriebsweg sind dokumentiert. Etappe 3 wurde
nicht begonnen.
## Umgesetzter Stand Etappe 1
@@ -13,6 +14,8 @@ lokaler Betriebsweg sind dokumentiert. Etappe 2 wurde nicht begonnen.
- persistentes Light-/Dark-/System-Theme
- responsive Navigation und gekennzeichnete Vorschauen für Fundus,
Sendungsbau und Live-Modus
- kompakte Desktop-Arbeitsansicht mit fester Seitenleiste, Fundusliste und
zweigeteiltem Sendungsbau; auf schmalen Ansichten Umschaltung statt Engpass
- Healthcheck, Produktionsstart und frühes Cloudron-Paket
- getrennte Unit-/Integrations- und Browser-Testdaten
@@ -20,13 +23,36 @@ Die Vorschauen lesen echte Daten aus SQLite. Suche und mobile Pane-Umschaltung
funktionieren lokal; redaktionelle Schreiboperationen bleiben bewusst den
späteren Etappen vorbehalten.
Die abschließende Oberflächenüberarbeitung von Etappe 1 reduziert große
Seitenköpfe und verschachtelte Karten, bündelt den Schreibschutzhinweis
aufklappbar und hält Entwicklungsinformationen aus der Arbeitsoberfläche heraus.
Eingang und Archiv zeigen kurze Leerzustände, die Verwaltung trennt
Abschnittskategorien, Formate und Themen kompakt.
Die abschließende responsive Prüfung verdichtet Seitenleiste, Navigation und
Funduszeilen weiter. Mobile Funduskarten sind intern einspaltig; lange Titel,
Beschreibungen und Metadaten bleiben ohne feste Kartenhöhe sichtbar. Suche und
Statusfilter sind gleich hoch, und die Filtergruppe wird nur als Ganzes
umgebrochen.
## Nicht Bestandteil dieser Etappe
Anmeldung, Rollen, echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung,
Live-Steuerung, Präsentationsfenster, Archivabschluss, Freigaben, RSS und API.
Echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung, Live-Steuerung,
Präsentationsfenster, Archivabschluss, Freigaben, RSS und API.
Die Vorschauen dürfen nicht als Implementierung dieser Funktionen verstanden
werden.
## Umgesetzter Stand Etappe 2
- einmalige Erstadmin-Einrichtung mit externem Betreiber-Token
- Login und POST-Logout mit widerrufbaren Datenbanksitzungen
- Rollen Barkeeper und Thekencrew mit serverseitiger Prüfung
- Einladungen und Passwortreset über befristete, einmalige Links
- Kontensperrung mit sofortigem Entzug bestehender Sitzungen
- zeitfensterbasiertes Login-Rate-Limit ohne dauerhafte Kontensperre
- lokale Mail-Outbox als Vorgabe und explizit konfigurierbarer SMTP-Versand
- responsive Authentifizierungs- und Kontenverwaltungsoberflächen
## Bekannte offene Betriebsprüfung
Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten
+58
View File
@@ -0,0 +1,58 @@
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
email_normalized TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('barkeeper', 'thekencrew')),
locked_at_utc TEXT,
password_changed_at_utc TEXT NOT NULL,
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL
);
CREATE INDEX sessions_user_idx ON sessions(user_id);
CREATE INDEX sessions_expiry_idx ON sessions(expires_at_utc);
CREATE TABLE invitations (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
email_normalized TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('barkeeper', 'thekencrew')),
token_hash TEXT NOT NULL UNIQUE,
created_by_user_id TEXT NOT NULL REFERENCES users(id),
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL,
accepted_at_utc TEXT,
revoked_at_utc TEXT
);
CREATE INDEX invitations_email_idx ON invitations(email_normalized, created_at_utc);
CREATE TABLE password_reset_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_at_utc TEXT NOT NULL,
expires_at_utc TEXT NOT NULL,
consumed_at_utc TEXT
);
CREATE INDEX password_resets_user_idx ON password_reset_tokens(user_id, created_at_utc);
CREATE TABLE login_attempts (
id TEXT PRIMARY KEY,
email_key TEXT NOT NULL,
address_key TEXT NOT NULL,
attempted_at_utc TEXT NOT NULL
);
CREATE INDEX login_attempts_email_idx ON login_attempts(email_key, attempted_at_utc);
CREATE INDEX login_attempts_address_idx ON login_attempts(address_key, attempted_at_utc);
+246 -3
View File
@@ -1,14 +1,16 @@
{
"name": "sendewerk",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"better-sqlite3": "^13.0.3"
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
"nodemailer": "^10.1.7"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -327,6 +329,238 @@
"dev": true,
"license": "MIT"
},
"node_modules/@node-rs/argon2": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz",
"integrity": "sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@node-rs/argon2-android-arm-eabi": "2.2.2",
"@node-rs/argon2-android-arm64": "2.2.2",
"@node-rs/argon2-darwin-arm64": "2.2.2",
"@node-rs/argon2-darwin-x64": "2.2.2",
"@node-rs/argon2-freebsd-x64": "2.2.2",
"@node-rs/argon2-linux-arm-gnueabihf": "2.2.2",
"@node-rs/argon2-linux-arm64-gnu": "2.2.2",
"@node-rs/argon2-linux-arm64-musl": "2.2.2",
"@node-rs/argon2-linux-x64-gnu": "2.2.2",
"@node-rs/argon2-linux-x64-musl": "2.2.2",
"@node-rs/argon2-win32-arm64-msvc": "2.2.2",
"@node-rs/argon2-win32-ia32-msvc": "2.2.2",
"@node-rs/argon2-win32-x64-msvc": "2.2.2"
}
},
"node_modules/@node-rs/argon2-android-arm-eabi": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.2.2.tgz",
"integrity": "sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-android-arm64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.2.2.tgz",
"integrity": "sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-darwin-arm64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.2.2.tgz",
"integrity": "sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-darwin-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.2.2.tgz",
"integrity": "sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-freebsd-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.2.2.tgz",
"integrity": "sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm-gnueabihf": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.2.2.tgz",
"integrity": "sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm64-gnu": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.2.2.tgz",
"integrity": "sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm64-musl": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.2.2.tgz",
"integrity": "sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-x64-gnu": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.2.2.tgz",
"integrity": "sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-x64-musl": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.2.2.tgz",
"integrity": "sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-arm64-msvc": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.2.2.tgz",
"integrity": "sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-ia32-msvc": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.2.2.tgz",
"integrity": "sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-x64-msvc": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.2.2.tgz",
"integrity": "sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@oxc-project/types": {
"version": "0.154.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
@@ -2209,6 +2443,15 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/nodemailer": {
"version": "10.1.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz",
"integrity": "sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+7 -5
View File
@@ -1,15 +1,15 @@
{
"name": "sendewerk",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"engines": {
"node": ">=22.17 <25"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"dev": "npm run db:migrate && node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js dev",
"build": "node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js build",
"preview": "node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js preview",
"start": "npm run db:migrate && node --env-file-if-exists=.env build",
"setup": "npm run db:migrate && npm run db:seed",
"db:migrate": "node --env-file-if-exists=.env scripts/migrate.mjs",
@@ -24,7 +24,9 @@
"test:e2e": "playwright test"
},
"dependencies": {
"better-sqlite3": "^13.0.3"
"@node-rs/argon2": "^2.2.2",
"better-sqlite3": "^13.0.3",
"nodemailer": "^10.1.7"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+10 -1
View File
@@ -3,7 +3,11 @@ import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const dataDirectory = mkdtempSync(path.join(tmpdir(), 'sendewerk-e2e-'));
const dataDirectory =
process.env.SENDEWERK_E2E_DATA_DIR || mkdtempSync(path.join(tmpdir(), 'sendewerk-e2e-'));
const mailOutboxDirectory = path.join(dataDirectory, 'mail-outbox');
process.env.SENDEWERK_E2E_DATA_DIR = dataDirectory;
process.env.SENDEWERK_E2E_OUTBOX_DIR = mailOutboxDirectory;
export default defineConfig({
testDir: 'tests',
@@ -18,6 +22,11 @@ export default defineConfig({
env: {
SENDEWERK_DB_PATH: path.join(dataDirectory, 'e2e.sqlite'),
SENDEWERK_UPLOAD_DIR: path.join(dataDirectory, 'uploads'),
SENDEWERK_MAIL_OUTBOX_DIR: mailOutboxDirectory,
SENDEWERK_MAIL_TRANSPORT: 'local',
SENDEWERK_APP_ORIGIN: 'http://127.0.0.1:4173',
SENDEWERK_SETUP_TOKEN: 'e2e-only-setup-token-32-characters',
ORIGIN: 'http://127.0.0.1:4173',
HOST: '127.0.0.1',
PORT: '4173'
}
+620 -239
View File
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -1,5 +1,14 @@
import type { AuthUser } from '#lib/server/auth.js';
declare global {
namespace App {}
namespace App {
interface Locals {
user: AuthUser | null;
}
interface PageData {
user?: AuthUser | null;
}
}
}
export {};
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit/hooks';
import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js';
const publicPaths = new Set([
'/',
'/healthz',
'/login',
'/setup',
'/einladung',
'/passwort-vergessen',
'/passwort-zuruecksetzen'
]);
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token);
if (token && !event.locals.user) deleteSession(event.cookies);
if (!publicPaths.has(event.url.pathname) && !event.locals.user) {
redirect(303, hasUsers() ? '/login' : '/setup');
}
const response = await resolve(event);
response.headers.set('Referrer-Policy', 'same-origin');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') {
response.headers.set('Cache-Control', 'no-store');
}
return response;
};
+10 -9
View File
@@ -2,12 +2,13 @@
let { area }: { area: string } = $props();
</script>
<aside class="preview-notice" aria-label="Hinweis zur Gestaltungsvorschau">
<span class="preview-dot" aria-hidden="true"></span>
<div>
<strong>Gestaltungsvorschau: {area}</strong>
<p>
In Etappe 1 schreibgeschützt. Die gezeigten Inhalte stammen aus der lokalen SQLite-Datenbank.
</p>
</div>
</aside>
<details class="preview-notice">
<summary>
<span class="preview-dot" aria-hidden="true"></span>
<strong>Vorschau · {area}</strong>
<span class="preview-chevron" aria-hidden="true">⌄</span>
</summary>
<p>
Diese Ansicht ist schreibgeschützt. Bearbeiten und Speichern folgen in einer späteren Etappe.
</p>
</details>
+255
View File
@@ -0,0 +1,255 @@
import Database from 'better-sqlite3';
import { execFileSync } from 'node:child_process';
import { readFileSync, readdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Cookies } from '@sveltejs/kit';
const temporaryDirectories: string[] = [];
let directory: string;
let databasePath: string;
function testCookies(): Cookies {
const values = new Map<string, string>();
return {
get: (name: string) => values.get(name),
getAll: () => Array.from(values, ([name, value]) => ({ name, value })),
set: (name: string, value: string) => {
values.set(name, value);
},
delete: (name: string) => {
values.delete(name);
},
serialize: () => ''
} as unknown as Cookies;
}
function tokenFromLatestMail(): string {
const outbox = path.join(directory, 'outbox');
const files = readdirSync(outbox).sort();
const message = JSON.parse(readFileSync(path.join(outbox, files.at(-1)!), 'utf8')) as {
text: string;
};
const match = message.text.match(/[?&]token=([^\s]+)/);
if (!match) throw new Error('Testmail enthält keinen Token.');
return decodeURIComponent(match[1]);
}
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-auth-test-'));
temporaryDirectories.push(directory);
databasePath = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_DB_PATH = databasePath;
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
process.env.SENDEWERK_MAIL_OUTBOX_DIR = path.join(directory, 'outbox');
process.env.SENDEWERK_MAIL_TRANSPORT = 'local';
process.env.SENDEWERK_APP_ORIGIN = 'http://localhost:5173';
process.env.SENDEWERK_SETUP_TOKEN = 'a'.repeat(32);
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(async () => {
const { closeDatabase } = await import('./database.js');
closeDatabase();
for (const name of [
'SENDEWERK_DB_PATH',
'SENDEWERK_UPLOAD_DIR',
'SENDEWERK_MAIL_OUTBOX_DIR',
'SENDEWERK_MAIL_TRANSPORT',
'SENDEWERK_MAIL_FROM',
'SENDEWERK_SMTP_HOST',
'SENDEWERK_APP_ORIGIN',
'SENDEWERK_SETUP_TOKEN'
]) {
delete process.env[name];
}
for (const temporaryDirectory of temporaryDirectories.splice(0)) {
rmSync(temporaryDirectory, { recursive: true, force: true });
}
});
describe('authentication', () => {
it('creates the first Barkeeper once and stores no plaintext secret', async () => {
const { createFirstAdmin } = await import('./auth.js');
const password = 'Ein-sehr-langes-Testpasswort';
const user = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'Bar@example.org',
displayName: 'Erster Barkeeper',
password
});
const database = new Database(databasePath, { readonly: true });
const row = database.prepare('SELECT * FROM users').get() as {
role: string;
email_normalized: string;
password_hash: string;
};
database.close();
expect(user.role).toBe('barkeeper');
expect(row.email_normalized).toBe('bar@example.org');
expect(row.password_hash).not.toContain(password);
await expect(
createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'second@example.org',
displayName: 'Zweiter Admin',
password
})
).rejects.toThrow('SETUP_CLOSED');
});
it('accepts an invitation only once and rejects an expired invitation', async () => {
const { acceptInvitation, createFirstAdmin, inviteUser } = await import('./auth.js');
const admin = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'admin@example.org',
displayName: 'Admin',
password: 'Admin-Passwort-1234'
});
await inviteUser(admin, 'crew@example.org', 'thekencrew');
const firstToken = tokenFromLatestMail();
const crew = await acceptInvitation({
token: firstToken,
displayName: 'Crew',
password: 'Crew-Passwort-1234'
});
expect(crew.role).toBe('thekencrew');
await expect(
acceptInvitation({
token: firstToken,
displayName: 'Crew',
password: 'Crew-Passwort-1234'
})
).rejects.toThrow('TOKEN_INVALID');
await inviteUser(admin, 'late@example.org', 'thekencrew');
const expiredToken = tokenFromLatestMail();
const database = new Database(databasePath);
database.prepare("UPDATE invitations SET expires_at_utc = '2000-01-01T00:00:00.000Z'").run();
database.close();
await expect(
acceptInvitation({
token: expiredToken,
displayName: 'Zu spät',
password: 'Crew-Passwort-1234'
})
).rejects.toThrow('TOKEN_INVALID');
});
it('revokes active sessions immediately when a user is locked', async () => {
const { createFirstAdmin, createSession, getSessionUser, setUserLocked, SESSION_COOKIE } =
await import('./auth.js');
const admin = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'admin@example.org',
displayName: 'Admin',
password: 'Admin-Passwort-1234'
});
const database = new Database(databasePath);
const crewId = crypto.randomUUID();
database
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc)
SELECT ?, 'crew@example.org', 'crew@example.org', 'Crew', password_hash, 'thekencrew',
password_changed_at_utc, created_at_utc, updated_at_utc FROM users WHERE id = ?`
)
.run(crewId, admin.id);
database.close();
const cookies = testCookies();
createSession(crewId, cookies);
const token = cookies.get(SESSION_COOKIE);
expect(getSessionUser(token)?.id).toBe(crewId);
setUserLocked(admin, crewId, true);
expect(getSessionUser(token)).toBeNull();
});
it('uses password reset links once and invalidates prior sessions', async () => {
const {
authenticate,
createFirstAdmin,
createSession,
getSessionUser,
requestPasswordReset,
resetPassword,
SESSION_COOKIE
} = await import('./auth.js');
const admin = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'admin@example.org',
displayName: 'Admin',
password: 'Altes-Passwort-1234'
});
const cookies = testCookies();
createSession(admin.id, cookies);
const sessionToken = cookies.get(SESSION_COOKIE);
await requestPasswordReset(admin.email);
const expiredToken = tokenFromLatestMail();
const database = new Database(databasePath);
database
.prepare("UPDATE password_reset_tokens SET expires_at_utc = '2000-01-01T00:00:00.000Z'")
.run();
database.close();
await expect(resetPassword(expiredToken, 'Neues-Passwort-1234')).rejects.toThrow(
'TOKEN_INVALID'
);
await requestPasswordReset(admin.email);
const resetToken = tokenFromLatestMail();
await resetPassword(resetToken, 'Neues-Passwort-1234');
expect(getSessionUser(sessionToken)).toBeNull();
expect(await authenticate(admin.email, 'Altes-Passwort-1234')).toBeNull();
expect(await authenticate(admin.email, 'Neues-Passwort-1234')).not.toBeNull();
await expect(resetPassword(resetToken, 'Noch-ein-Passwort-1234')).rejects.toThrow(
'TOKEN_INVALID'
);
});
it('rate limits repeated failed login attempts without locking the account', async () => {
const { authenticate, createFirstAdmin, loginIsRateLimited, reserveLoginAttempt } =
await import('./auth.js');
await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'admin@example.org',
displayName: 'Admin',
password: 'Admin-Passwort-1234'
});
for (let attempt = 0; attempt < 5; attempt += 1) {
expect(reserveLoginAttempt('admin@example.org', '127.0.0.1')).toBe(true);
expect(await authenticate('admin@example.org', 'falsch')).toBeNull();
}
expect(loginIsRateLimited('admin@example.org', '127.0.0.1')).toBe(true);
const database = new Database(databasePath, { readonly: true });
const user = database.prepare('SELECT locked_at_utc FROM users').get() as {
locked_at_utc: string | null;
};
database.close();
expect(user.locked_at_utc).toBeNull();
});
it('denies administrative operations to Thekencrew', async () => {
const { requireBarkeeper } = await import('./auth.js');
expect(() =>
requireBarkeeper({
id: 'crew',
email: 'crew@example.org',
displayName: 'Crew',
role: 'thekencrew',
lockedAtUtc: null
})
).toThrow();
});
it('never falls back to local delivery when SMTP was selected', async () => {
process.env.SENDEWERK_MAIL_TRANSPORT = 'smtp';
process.env.SENDEWERK_MAIL_FROM = '';
process.env.SENDEWERK_SMTP_HOST = '';
const { sendMail } = await import('./mail.js');
await expect(
sendMail({ to: 'test@example.org', subject: 'Test', text: 'Keine echte Nachricht' })
).rejects.toThrow('SMTP-Konfiguration');
});
});
+610
View File
@@ -0,0 +1,610 @@
import { hash, verify } from '@node-rs/argon2';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { error } from '@sveltejs/kit';
import type { Cookies } from '@sveltejs/kit';
import { getAuthConfig } from './config.js';
import { getDatabase } from './database.js';
import { sendMail } from './mail.js';
export const SESSION_COOKIE = 'sendewerk_session';
export const INVITATION_COOKIE = 'sendewerk_invitation';
export const RESET_COOKIE = 'sendewerk_password_reset';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
const LINK_DURATION_MS = 24 * 60 * 60 * 1000;
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const EMAIL_ATTEMPT_LIMIT = 5;
const ADDRESS_ATTEMPT_LIMIT = 20;
const RESET_WINDOW_MS = 60 * 60 * 1000;
const RESET_EMAIL_LIMIT = 3;
const RESET_ADDRESS_LIMIT = 10;
const PASSWORD_OPTIONS = {
algorithm: 2,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1,
outputLen: 32
} as const;
export type Role = 'barkeeper' | 'thekencrew';
export type AuthUser = {
id: string;
email: string;
displayName: string;
role: Role;
lockedAtUtc: string | null;
};
type UserRow = {
id: string;
email: string;
display_name: string;
password_hash: string;
role: Role;
locked_at_utc: string | null;
};
type InvitationRow = {
id: string;
email: string;
role: Role;
expires_at_utc: string;
accepted_at_utc: string | null;
revoked_at_utc: string | null;
};
function toUser(row: UserRow): AuthUser {
return {
id: row.id,
email: row.email,
displayName: row.display_name,
role: row.role,
lockedAtUtc: row.locked_at_utc
};
}
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function validEmail(email: string): boolean {
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function validDisplayName(name: string): boolean {
return name.trim().length >= 2 && name.trim().length <= 80;
}
export function validPassword(password: string): boolean {
return password.length >= 12 && Buffer.byteLength(password, 'utf8') <= 256;
}
export function isRole(value: string): value is Role {
return value === 'barkeeper' || value === 'thekencrew';
}
export async function hashPassword(password: string): Promise<string> {
return hash(password, PASSWORD_OPTIONS);
}
export async function verifyPassword(passwordHash: string, password: string): Promise<boolean> {
return verify(passwordHash, password, PASSWORD_OPTIONS);
}
const dummyPasswordHash = hashPassword(randomBytes(32).toString('hex'));
function createToken(): string {
return randomBytes(32).toString('base64url');
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function nowUtc(): string {
return new Date().toISOString();
}
function futureUtc(durationMs: number): string {
return new Date(Date.now() + durationMs).toISOString();
}
export function hasUsers(): boolean {
const row = getDatabase().prepare('SELECT count(*) AS count FROM users').get() as {
count: number;
};
return row.count > 0;
}
export function setupIsConfigured(): boolean {
return getAuthConfig().setupToken.length >= 32;
}
function setupTokenMatches(candidate: string): boolean {
const configured = getAuthConfig().setupToken;
if (configured.length < 32 || candidate.length !== configured.length) return false;
return timingSafeEqual(Buffer.from(candidate), Buffer.from(configured));
}
export async function createFirstAdmin(input: {
setupToken: string;
email: string;
displayName: string;
password: string;
}): Promise<AuthUser> {
if (!setupTokenMatches(input.setupToken)) throw new Error('SETUP_TOKEN_INVALID');
const email = input.email.trim();
const emailNormalized = normalizeEmail(email);
const displayName = input.displayName.trim();
if (!validEmail(email) || !validDisplayName(displayName) || !validPassword(input.password)) {
throw new Error('VALIDATION');
}
const passwordHash = await hashPassword(input.password);
const database = getDatabase();
const create = database.transaction(() => {
const count = database.prepare('SELECT count(*) AS count FROM users').get() as {
count: number;
};
if (count.count !== 0) throw new Error('SETUP_CLOSED');
const id = randomUUID();
const timestamp = nowUtc();
database
.prepare(
`INSERT INTO users (
id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc
) VALUES (?, ?, ?, ?, ?, 'barkeeper', ?, ?, ?)`
)
.run(id, email, emailNormalized, displayName, passwordHash, timestamp, timestamp, timestamp);
return { id, email, displayName, role: 'barkeeper', lockedAtUtc: null } satisfies AuthUser;
});
return create();
}
function sessionCookieOptions(expires?: Date) {
return {
path: '/',
httpOnly: true,
sameSite: 'lax' as const,
secure: getAuthConfig().secureCookies,
expires
};
}
function linkCookieOptions() {
return {
path: '/',
httpOnly: true,
sameSite: 'strict' as const,
secure: getAuthConfig().secureCookies,
maxAge: 10 * 60
};
}
export function storeLinkToken(cookies: Cookies, name: string, token: string): void {
cookies.set(name, token, linkCookieOptions());
}
export function readLinkToken(cookies: Cookies, name: string): string {
return cookies.get(name) || '';
}
export function deleteLinkToken(cookies: Cookies, name: string): void {
cookies.delete(name, { ...linkCookieOptions(), maxAge: 0 });
}
export function createSession(userId: string, cookies: Cookies): void {
const token = createToken();
const expires = new Date(Date.now() + SESSION_DURATION_MS);
getDatabase()
.prepare(
'INSERT INTO sessions (token_hash, user_id, created_at_utc, expires_at_utc) VALUES (?, ?, ?, ?)'
)
.run(hashToken(token), userId, nowUtc(), expires.toISOString());
cookies.set(SESSION_COOKIE, token, sessionCookieOptions(expires));
}
export function deleteSession(cookies: Cookies): void {
const token = cookies.get(SESSION_COOKIE);
if (token)
getDatabase().prepare('DELETE FROM sessions WHERE token_hash = ?').run(hashToken(token));
cookies.delete(SESSION_COOKIE, sessionCookieOptions());
}
export function getSessionUser(token: string | undefined): AuthUser | null {
if (!token) return null;
const database = getDatabase();
const row = database
.prepare(
`SELECT u.id, u.email, u.display_name, u.password_hash, u.role, u.locked_at_utc
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at_utc > ? AND u.locked_at_utc IS NULL`
)
.get(hashToken(token), nowUtc()) as UserRow | undefined;
if (!row) return null;
return toUser(row);
}
function rateKey(value: string): string {
return hashToken(value || 'unknown');
}
export function loginIsRateLimited(email: string, address: string): boolean {
const database = getDatabase();
const since = new Date(Date.now() - LOGIN_WINDOW_MS).toISOString();
database.prepare('DELETE FROM login_attempts WHERE attempted_at_utc < ?').run(since);
const emailCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE email_key = ? AND attempted_at_utc >= ?'
)
.get(rateKey(normalizeEmail(email)), since) as { count: number };
const addressCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE address_key = ? AND attempted_at_utc >= ?'
)
.get(rateKey(address), since) as { count: number };
return emailCount.count >= EMAIL_ATTEMPT_LIMIT || addressCount.count >= ADDRESS_ATTEMPT_LIMIT;
}
export function reserveLoginAttempt(email: string, address: string): boolean {
const database = getDatabase();
const reserve = database.transaction(() => {
if (loginIsRateLimited(email, address)) return false;
database
.prepare(
'INSERT INTO login_attempts (id, email_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)'
)
.run(randomUUID(), rateKey(normalizeEmail(email)), rateKey(address), nowUtc());
return true;
});
return reserve();
}
export async function authenticate(email: string, password: string): Promise<AuthUser | null> {
const database = getDatabase();
const normalized = normalizeEmail(email);
const row = database
.prepare(
`SELECT id, email, display_name, password_hash, role, locked_at_utc
FROM users WHERE email_normalized = ?`
)
.get(normalized) as UserRow | undefined;
const matches = row
? await verifyPassword(row.password_hash, password)
: await verifyPassword(await dummyPasswordHash, password);
if (!row || !matches || row.locked_at_utc) {
return null;
}
database.prepare('DELETE FROM login_attempts WHERE email_key = ?').run(rateKey(normalized));
return toUser(row);
}
export function requireUser(user: AuthUser | null): AuthUser {
if (!user) error(401, 'Anmeldung erforderlich');
return user;
}
export function requireBarkeeper(user: AuthUser | null): AuthUser {
const authenticated = requireUser(user);
if (authenticated.role !== 'barkeeper') error(403, 'Diese Aktion erfordert Barkeeper-Rechte.');
return authenticated;
}
function requireCurrentBarkeeper(actor: AuthUser): AuthUser {
const row = getDatabase()
.prepare(
`SELECT id, email, display_name, password_hash, role, locked_at_utc
FROM users WHERE id = ? AND role = 'barkeeper' AND locked_at_utc IS NULL`
)
.get(actor.id) as UserRow | undefined;
if (!row) error(403, 'Diese Aktion erfordert aktive Barkeeper-Rechte.');
return toUser(row);
}
export function listUsers(): AuthUser[] {
const rows = getDatabase()
.prepare(
`SELECT id, email, display_name, password_hash, role, locked_at_utc
FROM users ORDER BY display_name COLLATE NOCASE`
)
.all() as UserRow[];
return rows.map(toUser);
}
export function listOpenInvitations() {
return getDatabase()
.prepare(
`SELECT id, email, role, expires_at_utc
FROM invitations
WHERE accepted_at_utc IS NULL AND revoked_at_utc IS NULL AND expires_at_utc > ?
ORDER BY created_at_utc DESC`
)
.all(nowUtc()) as Array<{ id: string; email: string; role: Role; expires_at_utc: string }>;
}
export async function inviteUser(
actor: AuthUser,
emailValue: string,
role: Role
): Promise<'local' | 'smtp'> {
actor = requireCurrentBarkeeper(actor);
const email = emailValue.trim();
const normalized = normalizeEmail(email);
if (!validEmail(email) || !isRole(role)) throw new Error('VALIDATION');
const database = getDatabase();
if (database.prepare('SELECT 1 FROM users WHERE email_normalized = ?').get(normalized)) {
throw new Error('USER_EXISTS');
}
const token = createToken();
const id = randomUUID();
const timestamp = nowUtc();
const expiresAt = futureUtc(LINK_DURATION_MS);
const store = database.transaction(() => {
database
.prepare(
`UPDATE invitations SET revoked_at_utc = ?
WHERE email_normalized = ? AND accepted_at_utc IS NULL AND revoked_at_utc IS NULL`
)
.run(timestamp, normalized);
database
.prepare(
`INSERT INTO invitations (
id, email, email_normalized, role, token_hash, created_by_user_id,
created_at_utc, expires_at_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(id, email, normalized, role, hashToken(token), actor.id, timestamp, expiresAt);
});
store();
const link = new URL(
`/einladung?token=${encodeURIComponent(token)}`,
getAuthConfig().origin
).toString();
try {
return await sendMail({
to: email,
subject: 'Einladung zu Sendewerk',
text: `Du wurdest zu Sendewerk eingeladen. Der Link ist 24 Stunden gültig und einmalig nutzbar:\n\n${link}`
});
} catch (mailError) {
database.prepare('UPDATE invitations SET revoked_at_utc = ? WHERE id = ?').run(nowUtc(), id);
throw mailError;
}
}
export function getInvitation(token: string): InvitationRow | null {
if (!token || token.length > 100) return null;
return (
(getDatabase()
.prepare(
`SELECT id, email, role, expires_at_utc, accepted_at_utc, revoked_at_utc
FROM invitations WHERE token_hash = ?`
)
.get(hashToken(token)) as InvitationRow | undefined) ?? null
);
}
export async function acceptInvitation(input: {
token: string;
displayName: string;
password: string;
}): Promise<AuthUser> {
const invitation = getInvitation(input.token);
const displayName = input.displayName.trim();
if (
!invitation ||
invitation.accepted_at_utc ||
invitation.revoked_at_utc ||
invitation.expires_at_utc <= nowUtc()
) {
throw new Error('TOKEN_INVALID');
}
if (!validDisplayName(displayName) || !validPassword(input.password))
throw new Error('VALIDATION');
const passwordHash = await hashPassword(input.password);
const database = getDatabase();
const accept = database.transaction(() => {
const current = database
.prepare(
`SELECT id, email, role, expires_at_utc, accepted_at_utc, revoked_at_utc
FROM invitations WHERE id = ?`
)
.get(invitation.id) as InvitationRow;
if (current.accepted_at_utc || current.revoked_at_utc || current.expires_at_utc <= nowUtc()) {
throw new Error('TOKEN_INVALID');
}
const id = randomUUID();
const timestamp = nowUtc();
database
.prepare(
`INSERT INTO users (
id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
current.email,
normalizeEmail(current.email),
displayName,
passwordHash,
current.role,
timestamp,
timestamp,
timestamp
);
database
.prepare('UPDATE invitations SET accepted_at_utc = ? WHERE id = ?')
.run(timestamp, current.id);
return {
id,
email: current.email,
displayName,
role: current.role,
lockedAtUtc: null
} satisfies AuthUser;
});
return accept();
}
export function reservePasswordResetRequest(email: string, address: string): boolean {
const database = getDatabase();
const since = new Date(Date.now() - RESET_WINDOW_MS).toISOString();
const emailKey = rateKey(`reset-email:${normalizeEmail(email)}`);
const addressKey = rateKey(`reset-address:${address}`);
const reserve = database.transaction(() => {
database.prepare('DELETE FROM login_attempts WHERE attempted_at_utc < ?').run(since);
const emailCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE email_key = ? AND attempted_at_utc >= ?'
)
.get(emailKey, since) as { count: number };
const addressCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE address_key = ? AND attempted_at_utc >= ?'
)
.get(addressKey, since) as { count: number };
if (emailCount.count >= RESET_EMAIL_LIMIT || addressCount.count >= RESET_ADDRESS_LIMIT)
return false;
database
.prepare(
'INSERT INTO login_attempts (id, email_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)'
)
.run(randomUUID(), emailKey, addressKey, nowUtc());
return true;
});
return reserve();
}
export async function requestPasswordReset(emailValue: string): Promise<void> {
const normalized = normalizeEmail(emailValue);
const database = getDatabase();
const row = database
.prepare('SELECT id, email FROM users WHERE email_normalized = ?')
.get(normalized) as { id: string; email: string } | undefined;
if (!row) return;
const token = createToken();
const id = randomUUID();
const timestamp = nowUtc();
database
.prepare(
`INSERT INTO password_reset_tokens
(id, user_id, token_hash, created_at_utc, expires_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(id, row.id, hashToken(token), timestamp, futureUtc(LINK_DURATION_MS));
const link = new URL(
`/passwort-zuruecksetzen?token=${encodeURIComponent(token)}`,
getAuthConfig().origin
).toString();
try {
await sendMail({
to: row.email,
subject: 'Sendewerk-Passwort zurücksetzen',
text: `Mit diesem 24 Stunden gültigen, einmaligen Link kannst du dein Passwort zurücksetzen:\n\n${link}`
});
} catch (mailError) {
database.prepare('DELETE FROM password_reset_tokens WHERE id = ?').run(id);
throw mailError;
}
}
export function passwordResetIsValid(token: string): boolean {
if (!token || token.length > 100) return false;
return Boolean(
getDatabase()
.prepare(
`SELECT 1 FROM password_reset_tokens
WHERE token_hash = ? AND consumed_at_utc IS NULL AND expires_at_utc > ?`
)
.get(hashToken(token), nowUtc())
);
}
export async function resetPassword(token: string, password: string): Promise<void> {
if (!passwordResetIsValid(token)) throw new Error('TOKEN_INVALID');
if (!validPassword(password)) throw new Error('VALIDATION');
const passwordHash = await hashPassword(password);
const database = getDatabase();
const reset = database.transaction(() => {
const row = database
.prepare(
`SELECT id, user_id FROM password_reset_tokens
WHERE token_hash = ? AND consumed_at_utc IS NULL AND expires_at_utc > ?`
)
.get(hashToken(token), nowUtc()) as { id: string; user_id: string } | undefined;
if (!row) throw new Error('TOKEN_INVALID');
const timestamp = nowUtc();
database
.prepare(
`UPDATE users SET password_hash = ?, password_changed_at_utc = ?, updated_at_utc = ?
WHERE id = ?`
)
.run(passwordHash, timestamp, timestamp, row.user_id);
database
.prepare(
'UPDATE password_reset_tokens SET consumed_at_utc = ? WHERE user_id = ? AND consumed_at_utc IS NULL'
)
.run(timestamp, row.user_id);
database.prepare('DELETE FROM sessions WHERE user_id = ?').run(row.user_id);
});
reset();
}
function activeBarkeeperCount(): number {
const row = getDatabase()
.prepare(
"SELECT count(*) AS count FROM users WHERE role = 'barkeeper' AND locked_at_utc IS NULL"
)
.get() as { count: number };
return row.count;
}
export function setUserLocked(actor: AuthUser, userId: string, locked: boolean): void {
actor = requireCurrentBarkeeper(actor);
const database = getDatabase();
const row = database.prepare('SELECT role, locked_at_utc FROM users WHERE id = ?').get(userId) as
{ role: Role; locked_at_utc: string | null } | undefined;
if (!row) throw new Error('NOT_FOUND');
if (locked && actor.id === userId) throw new Error('SELF_LOCK');
if (locked && row.role === 'barkeeper' && !row.locked_at_utc && activeBarkeeperCount() <= 1) {
throw new Error('LAST_BARKEEPER');
}
const update = database.transaction(() => {
database
.prepare('UPDATE users SET locked_at_utc = ?, updated_at_utc = ? WHERE id = ?')
.run(locked ? nowUtc() : null, nowUtc(), userId);
if (locked) database.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
});
update();
}
export function setUserRole(actor: AuthUser, userId: string, role: Role): void {
requireCurrentBarkeeper(actor);
if (!isRole(role)) throw new Error('VALIDATION');
const database = getDatabase();
const row = database.prepare('SELECT role, locked_at_utc FROM users WHERE id = ?').get(userId) as
{ role: Role; locked_at_utc: string | null } | undefined;
if (!row) throw new Error('NOT_FOUND');
if (
row.role === 'barkeeper' &&
role === 'thekencrew' &&
!row.locked_at_utc &&
activeBarkeeperCount() <= 1
) {
throw new Error('LAST_BARKEEPER');
}
database
.prepare('UPDATE users SET role = ?, updated_at_utc = ? WHERE id = ?')
.run(role, nowUtc(), userId);
}
export function revokeInvitation(actor: AuthUser, invitationId: string): void {
requireCurrentBarkeeper(actor);
getDatabase()
.prepare(
`UPDATE invitations SET revoked_at_utc = ?
WHERE id = ? AND accepted_at_utc IS NULL AND revoked_at_utc IS NULL`
)
.run(nowUtc(), invitationId);
}
+33 -1
View File
@@ -7,6 +7,38 @@ function configuredPath(value: string | undefined, fallback: string): string {
export function getServerPaths() {
return {
database: configuredPath(process.env.SENDEWERK_DB_PATH, '.data/sendewerk.sqlite'),
uploads: configuredPath(process.env.SENDEWERK_UPLOAD_DIR, '.data/uploads')
uploads: configuredPath(process.env.SENDEWERK_UPLOAD_DIR, '.data/uploads'),
mailOutbox: configuredPath(process.env.SENDEWERK_MAIL_OUTBOX_DIR, '.data/mail-outbox')
};
}
export function getAuthConfig() {
const configuredOrigin = process.env.SENDEWERK_APP_ORIGIN || process.env.CLOUDRON_APP_ORIGIN;
if (process.env.NODE_ENV === 'production' && !configuredOrigin) {
throw new Error('SENDEWERK_APP_ORIGIN muss im Produktionsbetrieb gesetzt sein.');
}
const origin = new URL(configuredOrigin || 'http://localhost:5173');
return {
origin,
setupToken: process.env.SENDEWERK_SETUP_TOKEN || '',
secureCookies: origin.protocol === 'https:'
};
}
export function getMailConfig() {
const transport = process.env.SENDEWERK_MAIL_TRANSPORT || 'local';
if (transport !== 'local' && transport !== 'smtp') {
throw new Error('SENDEWERK_MAIL_TRANSPORT muss local oder smtp sein.');
}
return {
transport,
from:
process.env.SENDEWERK_MAIL_FROM ||
(transport === 'local' ? 'Sendewerk <sendewerk@localhost>' : ''),
host: process.env.SENDEWERK_SMTP_HOST || '',
port: Number(process.env.SENDEWERK_SMTP_PORT || '587'),
secure: process.env.SENDEWERK_SMTP_SECURE === 'true',
user: process.env.SENDEWERK_SMTP_USER || '',
password: process.env.SENDEWERK_SMTP_PASSWORD || ''
};
}
+1 -1
View File
@@ -52,7 +52,7 @@ describe('database setup', () => {
.get() as { migrations: number; pieces: number; shows: number; usages: number };
database.close();
expect(counts).toEqual({ migrations: 1, pieces: 4, shows: 1, usages: 3 });
expect(counts).toEqual({ migrations: 2, pieces: 4, shows: 1, usages: 3 });
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
});
+49
View File
@@ -0,0 +1,49 @@
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import nodemailer from 'nodemailer';
import { getMailConfig, getServerPaths } from './config.js';
export type MailMessage = {
to: string;
subject: string;
text: string;
};
export async function sendMail(message: MailMessage): Promise<'local' | 'smtp'> {
const config = getMailConfig();
if (config.transport === 'local') {
const outbox = getServerPaths().mailOutbox;
await mkdir(outbox, { recursive: true, mode: 0o700 });
const filename = `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}.json`;
await writeFile(
path.join(outbox, filename),
JSON.stringify({ from: config.from, ...message }, null, 2),
{ encoding: 'utf8', mode: 0o600 }
);
return 'local';
}
if (
!config.host ||
!config.from ||
!Number.isInteger(config.port) ||
config.port < 1 ||
config.port > 65_535 ||
Boolean(config.user) !== Boolean(config.password)
) {
throw new Error('Die SMTP-Konfiguration ist unvollständig.');
}
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
requireTLS: !config.secure,
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
auth: config.user ? { user: config.user, pass: config.password } : undefined
});
await transporter.sendMail({ from: config.from, ...message });
return 'smtp';
}
+3
View File
@@ -0,0 +1,3 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user });
+78 -44
View File
@@ -5,14 +5,17 @@
import NavIcon from '#lib/components/NavIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
let { children } = $props();
const navigation = [
let { children, data } = $props();
const primaryNavigation = [
{ href: '/eingang', label: 'Eingang', icon: 'inbox' },
{ href: '/fundus', label: 'Fundus', icon: 'library' },
{ href: '/sendungen', label: 'Sendungen', icon: 'shows' },
{ href: '/archiv', label: 'Archiv', icon: 'archive' },
{ href: '/verwaltung', label: 'Verwaltung', icon: 'settings' }
{ href: '/archiv', label: 'Archiv', icon: 'archive' }
] as const;
const administration = { href: '/verwaltung', label: 'Verwaltung', icon: 'settings' } as const;
const mobileNavigation = $derived(
data.user?.role === 'barkeeper' ? [...primaryNavigation, administration] : primaryNavigation
);
function isActive(href: string) {
return (
@@ -28,15 +31,66 @@
/>
</svelte:head>
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
{#if data.user}
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<nav class="main-navigation" aria-label="Hauptnavigation">
{#each navigation as item (item.href)}
<nav class="main-navigation" aria-label="Hauptnavigation">
{#each primaryNavigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
aria-current={isActive(item.href) ? 'page' : undefined}
>
<NavIcon name={item.icon} />
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="sidebar-footer">
{#if data.user.role === 'barkeeper'}
<nav class="utility-navigation" aria-label="Verwaltung">
<a
href={resolve(administration.href)}
class:active={isActive(administration.href)}
aria-current={isActive(administration.href) ? 'page' : undefined}
>
<NavIcon name={administration.icon} />
<span>{administration.label}</span>
</a>
</nav>
{/if}
<div class="account-summary">
<span>{data.user.displayName}</span>
<small>{data.user.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
</div>
<form method="POST" action="/logout">
<button class="logout-button" type="submit">Abmelden</button>
</form>
<ThemeToggle />
</div>
</aside>
<header class="mobile-header">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<form method="POST" action="/logout">
<button class="mobile-logout" type="submit" aria-label="Abmelden">↗</button>
</form>
<ThemeToggle />
</header>
<main id="main-content">{@render children()}</main>
<nav class="mobile-navigation" aria-label="Hauptnavigation">
{#each mobileNavigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
@@ -47,36 +101,16 @@
</a>
{/each}
</nav>
<div class="sidebar-footer">
<ThemeToggle />
<div class="stage-indicator">
<span></span>
<div><strong>Etappe 1</strong><small>Lokaler Entwurf</small></div>
</div>
</div>
</aside>
<header class="mobile-header">
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
<ThemeToggle />
</header>
<main id="main-content">{@render children()}</main>
<nav class="mobile-navigation" aria-label="Hauptnavigation">
{#each navigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
aria-current={isActive(item.href) ? 'page' : undefined}
>
<NavIcon name={item.icon} />
<span>{item.label}</span>
</div>
{:else}
<div class="auth-shell">
<header class="auth-header">
<a class="brand" href={resolve('/login')} aria-label="Sendewerk Anmeldung">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>Sendewerk</span>
</a>
{/each}
</nav>
</div>
<ThemeToggle />
</header>
<main class="auth-main">{@render children()}</main>
</div>
{/if}
+4 -2
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { hasUsers } from '#lib/server/auth.js';
export function load() {
redirect(307, '/fundus');
export function load({ locals }) {
if (!hasUsers()) redirect(307, '/setup');
redirect(307, locals.user ? '/fundus' : '/login');
}
+3 -10
View File
@@ -2,19 +2,12 @@
<div class="page-wrap narrow-page">
<header class="page-heading">
<p class="eyebrow">Bewahrte Sendungen</p>
<h1>Archiv</h1>
<p class="page-intro">
Abgeschlossene Abläufe bleiben hier in ihrem damaligen Stand nachvollziehbar.
</p>
<p class="page-intro">Abgeschlossene Sendungen in ihrem damaligen Stand.</p>
</header>
<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">□</div>
<p class="eyebrow">Geplant für Etappe 6</p>
<h2>Historie braucht klare Regeln.</h2>
<p>
Stabile Snapshots, Verwendungshistorie und bewusste Archivierung werden gemeinsam
implementiert, statt jetzt nur eine leere Liste vorzutäuschen.
</p>
<h2>Noch keine Sendung archiviert</h2>
<p>Abgeschlossene Sendungen werden hier später dauerhaft auffindbar.</p>
</section>
</div>
+3 -11
View File
@@ -2,20 +2,12 @@
<div class="page-wrap narrow-page">
<header class="page-heading">
<p class="eyebrow">Sammelstelle</p>
<h1>Eingang</h1>
<p class="page-intro">
Neue Gedanken und spätere RSS-Fundstücke landen hier, bevor sie in den Fundus übernommen
werden.
</p>
<p class="page-intro">Neue Gedanken und Fundstücke vor der Übernahme in den Fundus.</p>
</header>
<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">+</div>
<p class="eyebrow">Geplant für Etappe 3 und 7</p>
<h2>Noch ist der Tresen leer.</h2>
<p>
Schnellaufnahme, Feed-Vorschau und bewusste Übernahme werden erst mit den dazugehörigen
Speicher- und Sicherheitsregeln umgesetzt.
</p>
<h2>Keine neuen Inhalte</h2>
<p>Hier erscheinen später neue Gedanken und ausgewählte Feed-Fundstücke.</p>
</section>
</div>
+57
View File
@@ -0,0 +1,57 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
acceptInvitation,
createSession,
deleteLinkToken,
getInvitation,
INVITATION_COOKIE,
readLinkToken,
storeLinkToken,
validDisplayName,
validPassword
} from '#lib/server/auth.js';
export const load: PageServerLoad = ({ url, cookies }) => {
const queryToken = url.searchParams.get('token');
if (queryToken) {
if (queryToken.length <= 100) storeLinkToken(cookies, INVITATION_COOKIE, queryToken);
else deleteLinkToken(cookies, INVITATION_COOKIE);
redirect(303, '/einladung');
}
const token = readLinkToken(cookies, INVITATION_COOKIE);
const invitation = getInvitation(token);
const valid = Boolean(
invitation &&
!invitation.accepted_at_utc &&
!invitation.revoked_at_utc &&
invitation.expires_at_utc > new Date().toISOString()
);
return { valid, email: valid ? invitation?.email : null };
};
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const token = readLinkToken(cookies, INVITATION_COOKIE);
const displayName = String(data.get('displayName') || '').trim();
const password = String(data.get('password') || '');
if (!validDisplayName(displayName) || !validPassword(password)) {
return fail(400, {
displayName,
error: 'Bitte Namen und ein Passwort mit mindestens 12 Zeichen prüfen.'
});
}
try {
const user = await acceptInvitation({ token, displayName, password });
deleteLinkToken(cookies, INVITATION_COOKIE);
createSession(user.id, cookies);
} catch {
return fail(400, {
displayName,
error: 'Die Einladung ist ungültig, abgelaufen oder bereits verwendet.'
});
}
redirect(303, '/fundus');
}
} satisfies Actions;
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Einladung annehmen | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Einladung</p>
<h1>Sendewerk-Konto aktivieren</h1>
{#if !data.valid && !form?.error}
<div class="form-message error" role="alert">
Die Einladung ist ungültig, abgelaufen oder bereits verwendet.
</div>
{:else}
<p>Die Einladung gilt für <strong>{data.email}</strong>.</p>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form class="auth-form" method="POST">
<label
>Name<input
name="displayName"
required
maxlength="80"
value={form?.displayName ?? ''}
autocomplete="name"
/></label
>
<label
>Passwort<input
name="password"
type="password"
required
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<button class="primary-button" type="submit">Konto aktivieren</button>
</form>
{/if}
</section>
+17 -16
View File
@@ -27,11 +27,8 @@
<div class="page-wrap">
<header class="page-heading split-heading">
<div>
<p class="eyebrow">Redaktioneller Fundus</p>
<h1>Gute Stücke, sofort griffbereit.</h1>
<p class="page-intro">
Vorbereitete Ideen, Gespräche und Fundstücke für die nächste Sendung.
</p>
<h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
</div>
<div class="heading-stat" aria-label={`${data.pieces.length} Inhalte im Beispieldatensatz`}>
<strong>{data.pieces.length}</strong><span>Inhalte</span>
@@ -65,22 +62,26 @@
<div class="piece-grid">
{#each filteredPieces as piece (piece.id)}
<article class="piece-card" style={`--category: ${piece.categoryColor}`}>
<div class="piece-card-topline">
<div class="piece-primary">
<h2>{piece.title}</h2>
<p>{piece.summary}</p>
</div>
<div class="piece-classification">
<span class="category-label"><i></i>{piece.category}</span>
<div class="piece-tags">
<span>{piece.format}</span>
{#each piece.topics as topic (topic)}<span>{topic}</span>{/each}
</div>
</div>
<div class="piece-state">
<span class:ready={piece.status === 'ready'} class="status-label"
>{statusLabel[piece.status]}</span
>
<span class="piece-facts">
<span><b>{formatClock(piece.durationSeconds)}</b> geplant</span>
<span>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span>
</span>
</div>
<h2>{piece.title}</h2>
<p>{piece.summary}</p>
<div class="piece-tags">
<span>{piece.format}</span>
{#each piece.topics as topic (topic)}<span>{topic}</span>{/each}
</div>
<footer>
<span><b>{formatClock(piece.durationSeconds)}</b> geplant</span>
<span>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span>
</footer>
</article>
{/each}
</div>
+36
View File
@@ -0,0 +1,36 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
authenticate,
createSession,
hasUsers,
reserveLoginAttempt,
validEmail
} from '#lib/server/auth.js';
export const load: PageServerLoad = ({ locals }) => {
if (!hasUsers()) redirect(303, '/setup');
if (locals.user) redirect(303, '/fundus');
};
export const actions = {
default: async ({ request, cookies, getClientAddress }) => {
const data = await request.formData();
const email = String(data.get('email') || '').trim();
const password = String(data.get('password') || '');
const address = getClientAddress();
if (!validEmail(email) || !password || Buffer.byteLength(password, 'utf8') > 256) {
return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' });
}
if (!reserveLoginAttempt(email, address)) {
return fail(429, {
email,
error: 'Zu viele Anmeldeversuche. Bitte in 15 Minuten erneut versuchen.'
});
}
const user = await authenticate(email, password);
if (!user) return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' });
createSession(user.id, cookies);
redirect(303, '/fundus');
}
} satisfies Actions;
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { form }: PageProps = $props();
</script>
<svelte:head><title>Anmelden | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Redaktionszugang</p>
<h1>Bei Sendewerk anmelden</h1>
<p>Für Barkeeper und Thekencrew. Eine öffentliche Registrierung gibt es nicht.</p>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form class="auth-form" method="POST">
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.email ?? ''}
autocomplete="email"
/></label
>
<label
>Passwort<input
name="password"
type="password"
required
maxlength="256"
autocomplete="current-password"
/></label
>
<button class="primary-button" type="submit">Anmelden</button>
</form>
<a class="auth-link" href={resolve('/passwort-vergessen')}>Passwort vergessen?</a>
</section>
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { deleteSession } from '#lib/server/auth.js';
export const POST: RequestHandler = ({ cookies }) => {
deleteSession(cookies);
redirect(303, '/login');
};
@@ -0,0 +1,22 @@
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import { requestPasswordReset, reservePasswordResetRequest, validEmail } from '#lib/server/auth.js';
export const actions = {
default: async ({ request, getClientAddress }) => {
const data = await request.formData();
const email = String(data.get('email') || '').trim();
if (!validEmail(email))
return fail(400, { email, error: 'Bitte eine gültige E-Mail eingeben.' });
if (!reservePasswordResetRequest(email, getClientAddress())) {
return { sent: true };
}
try {
await requestPasswordReset(email);
} catch {
// Die sichtbare Antwort bleibt identisch, damit der Mailzustand kein Konto verrät.
console.error('Eine Passwortreset-Nachricht konnte nicht bereitgestellt werden.');
}
return { sent: true };
}
} satisfies Actions;
@@ -0,0 +1,31 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { form }: PageProps = $props();
</script>
<svelte:head><title>Passwort vergessen | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Kontozugang</p>
<h1>Passwort zurücksetzen</h1>
<p>Wenn das Konto existiert, wird ein 24 Stunden gültiger, einmaliger Link bereitgestellt.</p>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.sent}<div class="form-message success" role="status">
Anfrage verarbeitet. Bitte Postfach beziehungsweise lokale Outbox prüfen.
</div>{/if}
<form class="auth-form" method="POST">
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.email ?? ''}
autocomplete="email"
/></label
>
<button class="primary-button" type="submit">Reset-Link anfordern</button>
</form>
<a class="auth-link" href={resolve('/login')}>Zurück zur Anmeldung</a>
</section>
@@ -0,0 +1,39 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
deleteLinkToken,
passwordResetIsValid,
readLinkToken,
RESET_COOKIE,
resetPassword,
storeLinkToken,
validPassword
} from '#lib/server/auth.js';
export const load: PageServerLoad = ({ url, cookies }) => {
const queryToken = url.searchParams.get('token');
if (queryToken) {
if (queryToken.length <= 100) storeLinkToken(cookies, RESET_COOKIE, queryToken);
else deleteLinkToken(cookies, RESET_COOKIE);
redirect(303, '/passwort-zuruecksetzen');
}
return { valid: passwordResetIsValid(readLinkToken(cookies, RESET_COOKIE)) };
};
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const token = readLinkToken(cookies, RESET_COOKIE);
const password = String(data.get('password') || '');
if (!validPassword(password)) {
return fail(400, { error: 'Das neue Passwort muss mindestens 12 Zeichen lang sein.' });
}
try {
await resetPassword(token, password);
deleteLinkToken(cookies, RESET_COOKIE);
} catch {
return fail(400, { error: 'Der Link ist ungültig, abgelaufen oder bereits verwendet.' });
}
redirect(303, '/login?reset=erfolgreich');
}
} satisfies Actions;
@@ -0,0 +1,33 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Neues Passwort | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Kontozugang</p>
<h1>Neues Passwort setzen</h1>
{#if !data.valid && !form?.error}
<div class="form-message error" role="alert">
Der Link ist ungültig, abgelaufen oder bereits verwendet.
</div>
<a class="auth-link" href={resolve('/passwort-vergessen')}>Neuen Link anfordern</a>
{:else}
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form class="auth-form" method="POST">
<label
>Neues Passwort<input
name="password"
type="password"
required
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<button class="primary-button" type="submit">Passwort speichern</button>
</form>
{/if}
</section>
+6 -17
View File
@@ -18,9 +18,10 @@
<div class="page-wrap builder-page">
<header class="page-heading show-heading">
<div>
<p class="eyebrow">Nächste Sendung · {data.show.startsAtBerlin} Uhr</p>
<h1>{data.show.title}</h1>
<div class="show-heading-copy">
<h1>Sendungen</h1>
<p class="show-title">{data.show.title}</p>
<p class="show-date">{data.show.startsAtBerlin} Uhr</p>
</div>
<div class="show-summary">
<div><strong>{plannedCount}</strong><span>Stücke</span></div>
@@ -57,15 +58,6 @@
</div>
<span>{data.pieces.filter((piece) => piece.status === 'ready').length}</span>
</header>
<label class="compact-search">
<span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span>
<input
type="search"
placeholder="Fundus durchsuchen"
disabled
title="Die vollständige Suche folgt in Etappe 3"
/>
</label>
<div class="source-list">
{#each data.pieces.filter((piece) => piece.status === 'ready') as piece (piece.id)}
<article class="source-card" style={`--category: ${piece.categoryColor}`}>
@@ -116,10 +108,9 @@
<div class="usage-list">
{#each section.usages.filter((usage) => !usage.isReserve) as usage (usage.id)}
<div class="usage-row">
<span class="drag-hint" aria-hidden="true">⠿</span>
<div>
<strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)} · Eigene Sendungskopie</small
>{formatClock(usage.durationSeconds)} · Stand für diese Sendung</small
>
</div>
<span class:current={usage.status === 'current'} class="usage-status"
@@ -129,9 +120,7 @@
{/each}
</div>
{:else}
<div class="section-placeholder">
Eigenständiger Spieleblock · keine Piece-Kopie nötig
</div>
<div class="section-placeholder">Spieleblock ohne vorbereiteten Fundusinhalt</div>
{/if}
</article>
{/each}
+47
View File
@@ -0,0 +1,47 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
createFirstAdmin,
createSession,
hasUsers,
setupIsConfigured,
validDisplayName,
validEmail,
validPassword
} from '#lib/server/auth.js';
export const load: PageServerLoad = () => {
if (hasUsers()) redirect(303, '/login');
return { configured: setupIsConfigured() };
};
export const actions = {
default: async ({ request, cookies }) => {
if (hasUsers()) redirect(303, '/login');
const data = await request.formData();
const setupToken = String(data.get('setupToken') || '');
const displayName = String(data.get('displayName') || '').trim();
const email = String(data.get('email') || '').trim();
const password = String(data.get('password') || '');
if (!validDisplayName(displayName) || !validEmail(email) || !validPassword(password)) {
return fail(400, {
error: 'Bitte Name, gültige E-Mail und ein Passwort mit mindestens 12 Zeichen prüfen.',
displayName,
email
});
}
try {
const user = await createFirstAdmin({ setupToken, displayName, email, password });
createSession(user.id, cookies);
} catch (cause) {
const code = cause instanceof Error ? cause.message : '';
if (code === 'SETUP_CLOSED') redirect(303, '/login');
return fail(403, {
error: 'Das Betreiber-Setup-Token ist ungültig.',
displayName,
email
});
}
redirect(303, '/fundus');
}
} satisfies Actions;
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Erstadmin einrichten | Sendewerk</title></svelte:head>
<section class="auth-card">
<p class="eyebrow">Einmalige Einrichtung</p>
<h1>Ersten Barkeeper anlegen</h1>
<p>
Dieses Konto verwaltet Einladungen, Rollen und Sperren. Danach wird die Einrichtung geschlossen.
</p>
{#if !data.configured}
<div class="form-message error" role="alert">
Vor dem Start muss ein mindestens 32 Zeichen langes <code>SENDEWERK_SETUP_TOKEN</code>
konfiguriert werden.
</div>
{:else}
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form class="auth-form" method="POST">
<label
>Betreiber-Setup-Token<input
name="setupToken"
type="password"
required
autocomplete="off"
/></label
>
<label
>Name<input
name="displayName"
required
maxlength="80"
value={form?.displayName ?? ''}
autocomplete="name"
/></label
>
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.email ?? ''}
autocomplete="email"
/></label
>
<label
>Passwort<input
name="password"
type="password"
required
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<p class="field-hint">
Mindestens 12 Zeichen. Das Passwort wird ausschließlich als Argon2id-Hash gespeichert.
</p>
<button class="primary-button" type="submit">Barkeeper anlegen</button>
</form>
{/if}
</section>
+94 -2
View File
@@ -1,5 +1,97 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { getTaxonomy } from '#lib/server/previews.js';
import { getMailConfig } from '#lib/server/config.js';
import {
inviteUser,
isRole,
listOpenInvitations,
listUsers,
requireBarkeeper,
revokeInvitation,
setUserLocked,
setUserRole,
validEmail
} from '#lib/server/auth.js';
export function load() {
return { taxonomy: getTaxonomy() };
export const load: PageServerLoad = ({ locals }) => {
requireBarkeeper(locals.user);
return {
taxonomy: getTaxonomy(),
users: listUsers(),
invitations: listOpenInvitations(),
mailTransport: getMailConfig().transport
};
};
function actionError(cause: unknown): string {
const code = cause instanceof Error ? cause.message : '';
if (code === 'USER_EXISTS') return 'Für diese E-Mail existiert bereits ein Konto.';
if (code === 'SELF_LOCK') return 'Das eigene Konto kann nicht gesperrt werden.';
if (code === 'LAST_BARKEEPER') return 'Mindestens ein aktiver Barkeeper muss erhalten bleiben.';
if (code === 'NOT_FOUND') return 'Das Konto wurde nicht gefunden.';
return 'Die Aktion konnte nicht ausgeführt werden.';
}
export const actions = {
invite: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
const email = String(data.get('email') || '').trim();
const role = String(data.get('role') || '');
if (!validEmail(email) || !isRole(role)) {
return fail(400, { intent: 'invite', email, error: 'E-Mail und Rolle bitte prüfen.' });
}
try {
const transport = await inviteUser(actor, email, role);
return {
intent: 'invite',
success:
transport === 'local'
? 'Einladung wurde in der lokalen Mail-Outbox abgelegt.'
: 'Einladung wurde versandt.'
};
} catch (cause) {
return fail(400, { intent: 'invite', email, error: actionError(cause) });
}
},
role: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
const userId = String(data.get('userId') || '');
const role = String(data.get('role') || '');
if (!userId || !isRole(role)) return fail(400, { intent: 'role', error: 'Ungültige Rolle.' });
try {
setUserRole(actor, userId, role);
return { intent: 'role', success: 'Rolle aktualisiert.' };
} catch (cause) {
return fail(400, { intent: 'role', error: actionError(cause) });
}
},
lock: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
setUserLocked(actor, String(data.get('userId') || ''), true);
return { intent: 'lock', success: 'Konto gesperrt und bestehende Sitzungen beendet.' };
} catch (cause) {
return fail(400, { intent: 'lock', error: actionError(cause) });
}
},
unlock: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
setUserLocked(actor, String(data.get('userId') || ''), false);
return { intent: 'unlock', success: 'Konto entsperrt.' };
} catch (cause) {
return fail(400, { intent: 'unlock', error: actionError(cause) });
}
},
revokeInvitation: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
revokeInvitation(actor, String(data.get('invitationId') || ''));
return { intent: 'revokeInvitation', success: 'Einladung widerrufen.' };
}
} satisfies Actions;
+103 -15
View File
@@ -1,19 +1,115 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Verwaltung | Sendewerk</title></svelte:head>
<div class="page-wrap narrow-page">
<header class="page-heading">
<p class="eyebrow">System & Struktur</p>
<h1>Verwaltung</h1>
<p class="page-intro">
Die bereits angelegte Taxonomie ist sichtbar; Bearbeitung und Benutzerverwaltung folgen mit
serverseitigen Rechten.
</p>
<p class="page-intro">Konten, Rollen und bestehende Taxonomie verwalten.</p>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<section class="quiet-panel account-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Konten & Rollen</p>
<h2>Redaktionszugänge</h2>
</div>
<span class="mail-mode"
>Mail: {data.mailTransport === 'local' ? 'lokale Outbox' : 'SMTP'}</span
>
</div>
<div class="account-list">
{#each data.users as user (user.id)}
<article class="account-row">
<div><strong>{user.displayName}</strong><small>{user.email}</small></div>
<span class:locked={user.lockedAtUtc} class="account-status"
>{user.lockedAtUtc ? 'Gesperrt' : 'Aktiv'}</span
>
<form method="POST" action="?/role" class="inline-account-form">
<input type="hidden" name="userId" value={user.id} />
<label class="sr-only" for={`role-${user.id}`}>Rolle für {user.displayName}</label>
<select id={`role-${user.id}`} name="role">
<option value="barkeeper" selected={user.role === 'barkeeper'}>Barkeeper</option>
<option value="thekencrew" selected={user.role === 'thekencrew'}>Thekencrew</option>
</select>
<button class="text-button" type="submit">Rolle speichern</button>
</form>
<form method="POST" action={user.lockedAtUtc ? '?/unlock' : '?/lock'}>
<input type="hidden" name="userId" value={user.id} />
<button
class="secondary-button"
type="submit"
disabled={user.id === data.user?.id}
title={user.id === data.user?.id
? 'Das eigene Konto kann nicht gesperrt werden.'
: undefined}
>{user.lockedAtUtc
? 'Entsperren'
: user.id === data.user?.id
? 'Eigenes Konto'
: 'Sperren'}</button
>
</form>
</article>
{/each}
</div>
</section>
<div class="management-grid">
<section class="quiet-panel invite-panel">
<p class="eyebrow">Einladung</p>
<h2>Neues Konto einladen</h2>
<form class="compact-form" method="POST" action="?/invite">
<label
>E-Mail<input
name="email"
type="email"
required
maxlength="254"
value={form?.intent === 'invite' ? (form.email ?? '') : ''}
/></label
>
<label
>Rolle<select name="role"
><option value="thekencrew">Thekencrew</option><option value="barkeeper"
>Barkeeper</option
></select
></label
>
<button class="primary-button" type="submit">Einladung erstellen</button>
</form>
</section>
<section class="quiet-panel invite-panel">
<p class="eyebrow">Offene Einladungen</p>
<h2>Noch nicht angenommen</h2>
{#if data.invitations.length}
<div class="invitation-list">
{#each data.invitations as invitation (invitation.id)}
<div>
<span
><strong>{invitation.email}</strong><small
>{invitation.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small
></span
>
<form method="POST" action="?/revokeInvitation">
<input type="hidden" name="invitationId" value={invitation.id} /><button
class="text-button"
type="submit">Widerrufen</button
>
</form>
</div>
{/each}
</div>
{:else}<p class="quiet-copy">Keine offenen Einladungen.</p>{/if}
</section>
</div>
<h2 class="section-heading">Taxonomie</h2>
<div class="settings-grid">
<section class="quiet-panel taxonomy-panel">
<p class="eyebrow">Abschnittskategorien</p>
@@ -38,13 +134,5 @@
{#each data.taxonomy.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
</div>
</section>
<section class="quiet-panel planned-settings">
<p class="eyebrow">Etappe 2</p>
<h2>Konten & Rollen</h2>
<p>
Barkeeper, Thekencrew und technische Zugänge werden erst mit vollständiger Authentifizierung
verwaltbar.
</p>
</section>
</div>
</div>
+9 -1
View File
@@ -2,11 +2,19 @@
set -eu
mkdir -p /app/data/uploads
mkdir -p /app/data/uploads /app/data/mail-outbox
chown -R cloudron:cloudron /app/data
export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}"
export SENDEWERK_UPLOAD_DIR="${SENDEWERK_UPLOAD_DIR:-/app/data/uploads}"
export SENDEWERK_MAIL_OUTBOX_DIR="${SENDEWERK_MAIL_OUTBOX_DIR:-/app/data/mail-outbox}"
export SENDEWERK_APP_ORIGIN="${SENDEWERK_APP_ORIGIN:-${CLOUDRON_APP_ORIGIN:-}}"
if [ -n "${CLOUDRON_APP_ORIGIN:-}" ]; then
export PROTOCOL_HEADER="${PROTOCOL_HEADER:-x-forwarded-proto}"
export HOST_HEADER="${HOST_HEADER:-x-forwarded-host}"
export ADDRESS_HEADER="${ADDRESS_HEADER:-x-forwarded-for}"
export XFF_DEPTH="${XFF_DEPTH:-1}"
fi
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-3000}"
+328 -34
View File
@@ -1,77 +1,370 @@
import { expect, test } from '@playwright/test';
import { expect, test, type Page } from '@playwright/test';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
test('desktop fundus is usable, filterable and keeps its theme', async ({ page }, testInfo) => {
const adminEmail = 'barkeeper@example.test';
const adminPassword = 'Nur-fuer-E2E-Admin-1234';
const crewEmail = 'crew@example.test';
const crewPassword = 'Nur-fuer-E2E-Crew-1234';
const routes = ['/fundus', '/sendungen', '/eingang', '/archiv', '/verwaltung', '/live'];
const viewports = [
{ width: 390, height: 844, theme: 'light' },
{ width: 390, height: 844, theme: 'dark' },
{ width: 768, height: 900, theme: 'light' },
{ width: 768, height: 900, theme: 'dark' },
{ width: 1440, height: 1000, theme: 'dark' },
{ width: 1440, height: 1000, theme: 'light' }
] as const;
async function expectNoHorizontalOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
}
async function setTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
}
async function login(page: Page, email = adminEmail, password = adminPassword) {
await page.goto('/login');
await page.getByLabel('E-Mail').fill(email);
await page.getByLabel('Passwort', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL(/\/fundus$/);
}
function latestMailLink(subject: string): string {
const outbox = process.env.SENDEWERK_E2E_OUTBOX_DIR;
if (!outbox) throw new Error('E2E-Outbox ist nicht konfiguriert.');
const messages = readdirSync(outbox)
.sort()
.map(
(file) =>
JSON.parse(readFileSync(path.join(outbox, file), 'utf8')) as {
subject: string;
text: string;
}
)
.filter((message) => message.subject === subject);
const match = messages.at(-1)?.text.match(/https?:\/\/[^\s]+/);
if (!match) throw new Error(`Keine Testmail mit Betreff ${subject} gefunden.`);
return match[0];
}
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await page.goto('/');
if (await page.getByRole('heading', { name: 'Ersten Barkeeper anlegen' }).isVisible()) {
await page.getByLabel('Betreiber-Setup-Token').fill('e2e-only-setup-token-32-characters');
await page.getByLabel('Name').fill('E2E Barkeeper');
await page.getByLabel('E-Mail').fill(adminEmail);
await page.getByLabel('Passwort').fill(adminPassword);
await page.getByRole('button', { name: 'Barkeeper anlegen' }).click();
} else {
await page.getByLabel('E-Mail').fill(adminEmail);
await page.getByLabel('Passwort').fill(adminPassword);
await page.getByRole('button', { name: 'Anmelden' }).click();
}
await expect(page).toHaveURL(/\/fundus$/);
const sessionCookie = (await page.context().cookies()).find(
(cookie) => cookie.name === 'sendewerk_session'
);
expect(sessionCookie?.httpOnly).toBe(true);
expect(sessionCookie?.secure).toBe(false);
expect(sessionCookie?.sameSite).toBe('Lax');
await page.goto('/setup');
await expect(page).toHaveURL(/\/fundus$/);
await page.getByRole('button', { name: 'Abmelden', exact: true }).click();
await expect(page).toHaveURL(/\/login$/);
await page.close();
});
test.beforeEach(async ({ page }) => {
await login(page);
});
test('invitations, direct authorization, locking and password reset work end to end', async ({
page,
browser
}, testInfo) => {
await page.goto('/verwaltung');
await page.screenshot({
path: testInfo.outputPath('verwaltung-konten-desktop.png'),
fullPage: true
});
const crossSite = await page.request.post('/verwaltung?/invite', {
form: { email: 'cross-site@example.test', role: 'barkeeper' },
headers: { origin: 'https://example.invalid' }
});
expect(crossSite.status()).toBe(403);
const inviteForm = page.locator('.invite-panel').filter({ hasText: 'Neues Konto einladen' });
await inviteForm.getByLabel('E-Mail').fill(crewEmail);
await inviteForm.getByRole('button', { name: 'Einladung erstellen' }).click();
await expect(
page.getByText('Einladung wurde in der lokalen Mail-Outbox abgelegt.')
).toBeVisible();
const crewContext = await browser.newContext();
const crewPage = await crewContext.newPage();
await crewPage.goto(latestMailLink('Einladung zu Sendewerk'));
await expect(crewPage).toHaveURL(/\/einladung$/);
await crewPage.getByLabel('Name').fill('E2E Crew');
await crewPage.getByLabel('Passwort').fill(crewPassword);
await crewPage.getByRole('button', { name: 'Konto aktivieren' }).click();
await expect(crewPage).toHaveURL(/\/fundus$/);
await expect(crewPage.getByText('Verwaltung', { exact: true })).toHaveCount(0);
const forbidden = await crewPage.request.post('/verwaltung?/invite', {
form: { email: 'verboten@example.test', role: 'barkeeper' },
headers: { origin: 'http://127.0.0.1:4173' }
});
expect(forbidden.status()).toBe(403);
await page.reload();
const crewRow = page.locator('.account-row').filter({ hasText: crewEmail });
await crewRow.getByRole('button', { name: 'Sperren' }).click();
await crewPage.goto('/fundus');
await expect(crewPage).toHaveURL(/\/login$/);
await page.reload();
await page
.locator('.account-row')
.filter({ hasText: crewEmail })
.getByRole('button', { name: 'Entsperren' })
.click();
await crewPage.goto('/passwort-vergessen');
await crewPage.getByLabel('E-Mail').fill(crewEmail);
await crewPage.getByRole('button', { name: 'Reset-Link anfordern' }).click();
await expect(crewPage.getByText('Anfrage verarbeitet.')).toBeVisible();
await crewPage.goto(latestMailLink('Sendewerk-Passwort zurücksetzen'));
await expect(crewPage).toHaveURL(/\/passwort-zuruecksetzen$/);
await crewPage.getByLabel('Neues Passwort').fill('Neues-E2E-Crew-Passwort-1234');
await crewPage.getByRole('button', { name: 'Passwort speichern' }).click();
await login(crewPage, crewEmail, 'Neues-E2E-Crew-Passwort-1234');
await crewContext.close();
const anonymousContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
const loginPage = await anonymousContext.newPage();
await loginPage.goto('/login');
await setTheme(loginPage, 'dark');
await loginPage.reload();
await expectNoHorizontalOverflow(loginPage);
await loginPage.screenshot({
path: testInfo.outputPath('login-mobile-dark.png'),
fullPage: true
});
await anonymousContext.close();
});
test('desktop fundus is a compact, filterable list with persistent themes', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/fundus');
await expect(
page.getByRole('heading', { name: 'Gute Stücke, sofort griffbereit.' })
).toBeVisible();
await expect(page.getByText('Gestaltungsvorschau: Fundus')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Fundus', exact: true })).toBeVisible();
await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung');
await expect(page.locator('.piece-card')).toHaveCount(4);
const firstRow = await page.locator('.piece-card').nth(0).boundingBox();
const secondRow = await page.locator('.piece-card').nth(1).boundingBox();
expect(firstRow?.x).toBe(secondRow?.x);
expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0);
expect(firstRow?.height ?? 999).toBeLessThan(130);
const searchBox = await page.locator('.search-field').boundingBox();
const filtersBox = await page.locator('.filter-tabs').boundingBox();
expect(searchBox?.height).toBe(filtersBox?.height);
await expect(page.getByRole('button', { name: 'In Arbeit' })).toHaveCSS('white-space', 'nowrap');
const notice = page.locator('.preview-notice');
await expect(page.getByText('Vorschau · Fundus', { exact: true })).toBeVisible();
await expect(notice.getByText('Diese Ansicht ist schreibgeschützt.')).toBeHidden();
await notice.locator('summary').focus();
await expect(notice.locator('summary')).toBeFocused();
await page.keyboard.press('Enter');
await expect(notice.getByText('Diese Ansicht ist schreibgeschützt.')).toBeVisible();
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart');
await expect(page.locator('.piece-card')).toHaveCount(1);
await expect(
page.getByRole('heading', { name: 'Pixelart, die nicht nostalgisch sein will' })
).toBeVisible();
await page.getByRole('button', { name: 'In Arbeit' }).click();
await expect(page.getByRole('heading', { name: 'Kein passendes Stück' })).toBeVisible();
await page.getByRole('button', { name: 'Filter zurücksetzen' }).click();
await expect(page.locator('.piece-card')).toHaveCount(4);
await page.getByRole('button', { name: 'Darstellung: System' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await expect(page.getByRole('button', { name: 'Darstellung: Hell' })).toBeVisible();
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
await page.screenshot({ path: testInfo.outputPath('fundus-desktop.png'), fullPage: true });
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true });
});
test('desktop builder shows fundus and run side by side', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
await setTheme(page, 'dark');
await page.goto('/sendungen');
await expect(page.getByText('Gestaltungsvorschau: Sendungsbau')).toBeVisible();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByRole('heading', { name: 'Sendungen', exact: true })).toBeVisible();
await expect(page.getByText('Herbstsendung: Kleine Welten', { exact: true })).toBeVisible();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible();
await expect(page.locator('.mobile-pane-switch')).toBeHidden();
await expect(
page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' })
).toBeVisible();
const boxes = await Promise.all([
page.locator('.source-pane').boundingBox(),
page.locator('.run-pane').boundingBox()
]);
expect(boxes[0]?.x).toBeLessThan(boxes[1]?.x ?? 0);
const sourceBox = await page.locator('.source-pane').boundingBox();
const runBox = await page.locator('.run-pane').boundingBox();
expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0);
expect(sourceBox?.width ?? 9999).toBeLessThan(runBox?.width ?? 0);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-desktop-dark.png'),
fullPage: true
});
});
test('mobile builder switches panes without horizontal scrolling', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
test('narrow desktop retains the sidebar and switches the builder panes', async ({ page }) => {
await page.setViewportSize({ width: 960, height: 850 });
await page.goto('/sendungen');
await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.mobile-pane-switch')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.locator('.source-pane')).toBeHidden();
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
});
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
test('mobile and tablet navigation do not obscure switchable content', async ({
page
}, testInfo) => {
for (const width of [390, 768]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('/sendungen');
await expect(page.locator('.sidebar')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const verticalSpace = await page.evaluate(() => {
const navigation = document.querySelector('.mobile-navigation')?.getBoundingClientRect();
const content = document.querySelector('.source-pane')?.getBoundingClientRect();
return { contentBottom: content?.bottom ?? 0, navigationTop: navigation?.top ?? 0 };
});
expect(verticalSpace.contentBottom).toBeLessThanOrEqual(verticalSpace.navigationTop + 1);
}
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/sendungen');
await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true });
});
test('live preview exposes readable prompts and working text controls', async ({ page }) => {
test('all work areas fit at required widths in light and dark mode', async ({ page }, testInfo) => {
for (const viewport of viewports) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/fundus');
await setTheme(page, viewport.theme);
for (const route of routes) {
await page.goto(route);
await expect(page.locator('html')).toHaveAttribute('data-theme', viewport.theme);
await expectNoHorizontalOverflow(page);
}
}
await page.setViewportSize({ width: 768, height: 900 });
await page.goto('/fundus');
await setTheme(page, 'dark');
await page.goto('/verwaltung');
await page.screenshot({
path: testInfo.outputPath('verwaltung-tablet-dark.png'),
fullPage: true
});
});
test('long titles and longer fundus lists reflow without overflow', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
await expect(page.locator('.piece-card')).toHaveCount(4);
await page.evaluate(() => {
const list = document.querySelector('.piece-grid');
const rows = Array.from(document.querySelectorAll('.piece-card'));
const title = rows[0]?.querySelector('h2');
if (title) {
title.textContent =
'Ein außergewöhnlich langer redaktioneller Titel über kleine Konsolen, große Ideen und unerwartete Wendungen';
}
for (let repetition = 0; repetition < 3; repetition += 1) {
for (const row of rows) list?.append(row.cloneNode(true));
}
});
await expect(page.locator('.piece-card')).toHaveCount(16);
await expectNoHorizontalOverflow(page);
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await expect(page.locator('.piece-card').first()).toHaveCSS('flex-direction', 'column');
const mobileCard = await page.locator('.piece-card').first().boundingBox();
const mobilePrimary = await page.locator('.piece-primary').first().boundingBox();
expect(mobilePrimary?.width ?? 0).toBeGreaterThan((mobileCard?.width ?? 0) - 40);
await expect(page.locator('.piece-card').first().getByRole('heading')).toContainText(
'Ein außergewöhnlich langer redaktioneller Titel'
);
await page.screenshot({
path: testInfo.outputPath('fundus-mobile-long-content.png'),
fullPage: true
});
});
test('empty states and administration stay concise', async ({ page }) => {
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 });
for (const route of ['/eingang', '/archiv']) {
await page.goto(route);
const emptyState = page.locator('.planned-panel');
await expect(emptyState).toBeVisible();
const box = await emptyState.boundingBox();
expect(box?.height ?? 999).toBeLessThan(150);
await expectNoHorizontalOverflow(page);
}
}
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/verwaltung');
const panels = page.locator('.taxonomy-panel');
await expect(panels).toHaveCount(3);
const boxes = await Promise.all([
panels.nth(0).boundingBox(),
panels.nth(1).boundingBox(),
panels.nth(2).boundingBox()
]);
expect(boxes[0]?.y).toBe(boxes[1]?.y);
expect(boxes[1]?.y).toBe(boxes[2]?.y);
});
test('live preview retains its existing readable controls', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/live');
await expect(page.getByText('Gestaltungsvorschau: Live-Modus')).toBeVisible();
await expect(page.getByText('Vorschau · Live-Modus', { exact: true })).toBeVisible();
await expect(page.getByText('Eine Kurbel, ein gelber Knopf und sehr viel Charme')).toBeVisible();
const before = await page
.locator('.live-current')
@@ -83,4 +376,5 @@ test('live preview exposes readable prompts and working text controls', async ({
expect(after).not.toBe(before);
await page.getByText('Recherche öffnen').click();
await expect(page.locator('.research-details')).toHaveAttribute('open', '');
await expectNoHorizontalOverflow(page);
});
+4 -1
View File
@@ -9,7 +9,10 @@ export default defineConfig({
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
adapter: adapter()
adapter: adapter(),
paths: {
origin: process.env.SENDEWERK_APP_ORIGIN
}
})
],
test: {