Release 0.2.0
This commit is contained in:
1 parent
7a49e66c37
commit
08e8875cb5
38 files changed
+2645
-93
No files matched your search
@@ -1,3 +1,19 @@
|
|||||||
# Relative paths are resolved from the project directory.
|
# Relative paths are resolved from the project directory.
|
||||||
SENDEWERK_DB_PATH=.data/sendewerk.sqlite
|
SENDEWERK_DB_PATH=.data/sendewerk.sqlite
|
||||||
SENDEWERK_UPLOAD_DIR=.data/uploads
|
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=
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"title": "Sendewerk",
|
"title": "Sendewerk",
|
||||||
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
|
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
|
||||||
"tagline": "Vom Fundstück bis zur Livesendung",
|
"tagline": "Vom Fundstück bis zur Livesendung",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"healthCheckPath": "/healthz",
|
"healthCheckPath": "/healthz",
|
||||||
"httpPort": 3000,
|
"httpPort": 3000,
|
||||||
"addons": {
|
"addons": {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
|
Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten
|
||||||
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
|
Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst
|
||||||
ausschließlich Etappe 1: technisches Grundgerüst, persistente Beispieldaten
|
Etappe 1 und 2: technisches Grundgerüst, persistente Beispieldaten,
|
||||||
und gekennzeichnete Gestaltungsvorschauen. Anmeldung, Bearbeitung und echte
|
Gestaltungsvorschauen sowie echte Anmeldung mit Barkeeper-/Thekencrew-Rollen.
|
||||||
Live-Steuerung folgen in späteren Etappen.
|
Inhaltsbearbeitung und Live-Steuerung folgen in späteren Etappen.
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
@@ -20,6 +20,9 @@ nicht erforderlich.
|
|||||||
```sh
|
```sh
|
||||||
npm ci
|
npm ci
|
||||||
cp .env.example .env
|
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 setup
|
||||||
npm run dev
|
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
|
und lädt den reproduzierbaren Beispieldatensatz. Der Seed darf beliebig oft
|
||||||
ausgeführt werden und ersetzt nur die von ihm verwalteten Beispieldaten.
|
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
|
## Konfiguration
|
||||||
|
|
||||||
| Variable | Vorgabe | Bedeutung |
|
| Variable | Vorgabe | Bedeutung |
|
||||||
| ---------------------- | ------------------------ | ----------------------------------- |
|
| --------------------------- | ------------------------ | ---------------------------------------------------- |
|
||||||
| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei |
|
| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei |
|
||||||
| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis |
|
| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis |
|
||||||
| `HOST` | adapterabhängig | Bind-Adresse des Produktionsservers |
|
| `SENDEWERK_MAIL_OUTBOX_DIR` | `.data/mail-outbox` | Lokale, nicht öffentlich ausgelieferte Testmail |
|
||||||
| `PORT` | `3000` | Port des Produktionsservers |
|
| `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
|
Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In
|
||||||
Cloudron liegen Datenbank und Uploads unter `/app/data`.
|
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
|
## Befehle
|
||||||
|
|
||||||
| Befehl | Zweck |
|
| 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 setup` | Verzeichnisse, Migrationen und Seed für lokale Entwicklung |
|
||||||
| `npm run db:migrate` | Ausstehende Migrationen atomar ausführen |
|
| `npm run db:migrate` | Ausstehende Migrationen atomar ausführen |
|
||||||
| `npm run db:seed` | Reproduzierbare Beispieldaten laden |
|
| `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 check` | Svelte- und TypeScript-Prüfung |
|
||||||
| `npm run lint` | Formatierung und ESLint prüfen |
|
| `npm run lint` | Formatierung und ESLint prüfen |
|
||||||
| `npm test` | Verhaltenstests mit temporärer, getrennter Datenbank |
|
| `npm test` | Verhaltenstests mit temporärer, getrennter Datenbank |
|
||||||
@@ -61,6 +87,7 @@ Cloudron liegen Datenbank und Uploads unter `/app/data`.
|
|||||||
```sh
|
```sh
|
||||||
npm ci
|
npm ci
|
||||||
npm run setup
|
npm run setup
|
||||||
|
export SENDEWERK_APP_ORIGIN=http://localhost:3000
|
||||||
npm run build
|
npm run build
|
||||||
HOST=127.0.0.1 PORT=3000 npm start
|
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
|
Der Healthcheck liegt unter <http://localhost:3000/healthz> und prüft auch den
|
||||||
Datenbankzugriff.
|
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:
|
Vor der ersten Browserprüfung wird der Testbrowser einmalig installiert:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -82,9 +115,17 @@ Das frühe Cloudron-Paket besteht aus `Dockerfile`, `CloudronManifest.json` und
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t sendewerk:local .
|
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,
|
Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup,
|
||||||
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
|
Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort
|
||||||
abschließend geprüft werden.
|
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
|
Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder
|
||||||
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
|
Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und
|
||||||
`better-sqlite3` sind MIT-lizenziert; TypeScript und Playwright stehen unter
|
`better-sqlite3` und `@node-rs/argon2` sind MIT-lizenziert; Nodemailer verwendet
|
||||||
Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum.
|
MIT-0, TypeScript und Playwright stehen unter Apache-2.0. Die Lockdatei fixiert
|
||||||
|
den tatsächlich installierten Baum.
|
||||||
@@ -39,3 +39,30 @@
|
|||||||
- Ein zusätzlicher Python-Playwright-Lauf: nicht gestartet, weil das optionale
|
- Ein zusätzlicher Python-Playwright-Lauf: nicht gestartet, weil das optionale
|
||||||
Python-Modul `playwright` nicht installiert war. Die projektgebundene
|
Python-Modul `playwright` nicht installiert war. Die projektgebundene
|
||||||
TypeScript-Playwright-Suite lief stattdessen vollständig erfolgreich.
|
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
|
||||||
+29
-3
@@ -38,7 +38,7 @@ Kern. Folgende Grenzen sind für spätere Migrationen bereits festgelegt.
|
|||||||
|
|
||||||
| Bereich | Geplante Entitäten und Beziehungen |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz |
|
||||||
@@ -63,6 +63,32 @@ oder eine explizite Abschnittsdauer. Beides wird nie addiert. Reserveelemente
|
|||||||
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
|
zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben
|
||||||
getrennt.
|
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
|
## Oberflächenstruktur
|
||||||
|
|
||||||
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
|
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
|
||||||
@@ -81,8 +107,8 @@ Fachabläufe werden jedoch nicht vorgetäuscht.
|
|||||||
|
|
||||||
## Sicherheitsgrenzen ab den Folgeetappen
|
## Sicherheitsgrenzen ab den Folgeetappen
|
||||||
|
|
||||||
Authentifizierung und Autorisierung werden serverseitig an jeder Route und
|
Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder
|
||||||
Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt.
|
betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt.
|
||||||
Uploadzugriff folgt denselben Rechten. Netzwerkabrufe erhalten Schutz gegen
|
Uploadzugriff folgt denselben Rechten. Netzwerkabrufe erhalten Schutz gegen
|
||||||
interne Ziele, Größen-/Zeitlimits und kontrollierte Redirects. Diese Regeln
|
interne Ziele, Größen-/Zeitlimits und kontrollierte Redirects. Diese Regeln
|
||||||
sind geplant, aber nicht Bestandteil der Etappe-1-Vorschauen.
|
sind geplant, aber nicht Bestandteil der Etappe-1-Vorschauen.
|
||||||
@@ -94,3 +94,44 @@ Der Fundus erscheint auf dem Desktop als kompakte Liste und auf kleinen
|
|||||||
Bildschirmen als Kartenfolge. Vorschau- und Schreibschutzinformationen sind in
|
Bildschirmen als Kartenfolge. Vorschau- und Schreibschutzinformationen sind in
|
||||||
einem nativen, tastaturbedienbaren `details`-Element gebündelt. Diese
|
einem nativen, tastaturbedienbaren `details`-Element gebündelt. Diese
|
||||||
Darstellungsentscheidung ändert weder Datenmodell noch Funktionsumfang.
|
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>
|
||||||
+16
-4
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
## Aktueller Umfang
|
## Aktueller Umfang
|
||||||
|
|
||||||
Etappe 1 ist abgeschlossen. Architektur, Gesamtdatenmodell, Entscheidungen und
|
Etappe 1 und Etappe 2 sind abgeschlossen. Architektur, Gesamtdatenmodell,
|
||||||
lokaler Betriebsweg sind dokumentiert. Etappe 2 wurde nicht begonnen.
|
Anmeldung, Rollen und lokaler Betriebsweg sind dokumentiert. Etappe 3 wurde
|
||||||
|
nicht begonnen.
|
||||||
|
|
||||||
## Umgesetzter Stand Etappe 1
|
## Umgesetzter Stand Etappe 1
|
||||||
|
|
||||||
@@ -36,11 +37,22 @@ umgebrochen.
|
|||||||
|
|
||||||
## Nicht Bestandteil dieser Etappe
|
## Nicht Bestandteil dieser Etappe
|
||||||
|
|
||||||
Anmeldung, Rollen, echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung,
|
Echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung, Live-Steuerung,
|
||||||
Live-Steuerung, Präsentationsfenster, Archivabschluss, Freigaben, RSS und API.
|
Präsentationsfenster, Archivabschluss, Freigaben, RSS und API.
|
||||||
Die Vorschauen dürfen nicht als Implementierung dieser Funktionen verstanden
|
Die Vorschauen dürfen nicht als Implementierung dieser Funktionen verstanden
|
||||||
werden.
|
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
|
## Bekannte offene Betriebsprüfung
|
||||||
|
|
||||||
Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten
|
Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten
|
||||||
|
|||||||
@@ -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);
|
||||||
Generated
+246
-3
@@ -1,14 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^13.0.3"
|
"@node-rs/argon2": "^2.2.2",
|
||||||
|
"better-sqlite3": "^13.0.3",
|
||||||
|
"nodemailer": "^10.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -327,6 +329,238 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.154.0",
|
"version": "0.154.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
|
||||||
@@ -2209,6 +2443,15 @@
|
|||||||
"node": "^18 || ^20 || >= 21"
|
"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": {
|
"node_modules/obug": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||||
|
|||||||
+7
-5
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "sendewerk",
|
"name": "sendewerk",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.17 <25"
|
"node": ">=22.17 <25"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "npm run db:migrate && node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js dev",
|
||||||
"build": "vite build",
|
"build": "node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js build",
|
||||||
"preview": "vite preview",
|
"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",
|
"start": "npm run db:migrate && node --env-file-if-exists=.env build",
|
||||||
"setup": "npm run db:migrate && npm run db:seed",
|
"setup": "npm run db:migrate && npm run db:seed",
|
||||||
"db:migrate": "node --env-file-if-exists=.env scripts/migrate.mjs",
|
"db:migrate": "node --env-file-if-exists=.env scripts/migrate.mjs",
|
||||||
@@ -24,7 +24,9 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^13.0.3"
|
"@node-rs/argon2": "^2.2.2",
|
||||||
|
"better-sqlite3": "^13.0.3",
|
||||||
|
"nodemailer": "^10.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
+10
-1
@@ -3,7 +3,11 @@ import { mkdtempSync } from 'node:fs';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
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({
|
export default defineConfig({
|
||||||
testDir: 'tests',
|
testDir: 'tests',
|
||||||
@@ -18,6 +22,11 @@ export default defineConfig({
|
|||||||
env: {
|
env: {
|
||||||
SENDEWERK_DB_PATH: path.join(dataDirectory, 'e2e.sqlite'),
|
SENDEWERK_DB_PATH: path.join(dataDirectory, 'e2e.sqlite'),
|
||||||
SENDEWERK_UPLOAD_DIR: path.join(dataDirectory, 'uploads'),
|
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',
|
HOST: '127.0.0.1',
|
||||||
PORT: '4173'
|
PORT: '4173'
|
||||||
}
|
}
|
||||||
|
|||||||
+303
-2
@@ -78,7 +78,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input {
|
input,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,6 +1180,290 @@ a {
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at 12% 8%,
|
||||||
|
color-mix(in srgb, var(--accent) 12%, transparent),
|
||||||
|
transparent 28rem
|
||||||
|
),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 4.25rem;
|
||||||
|
padding: 0.65rem clamp(1rem, 4vw, 3rem);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--bg-elevated) 86%, transparent);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header .theme-toggle {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-main {
|
||||||
|
display: grid;
|
||||||
|
min-height: calc(100vh - 4.25rem);
|
||||||
|
padding: clamp(1.25rem, 6vw, 4rem) 1rem;
|
||||||
|
place-items: start center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: min(100%, 30rem);
|
||||||
|
padding: clamp(1.25rem, 4vw, 2rem);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-large);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card h1 {
|
||||||
|
margin: 0.3rem 0 0.5rem;
|
||||||
|
font-size: clamp(1.6rem, 5vw, 2.1rem);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card > p:not(.eyebrow),
|
||||||
|
.quiet-copy {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form,
|
||||||
|
.compact-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form label,
|
||||||
|
.compact-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.32rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form input,
|
||||||
|
.compact-form input,
|
||||||
|
.compact-form select,
|
||||||
|
.inline-account-form select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.65rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button,
|
||||||
|
.logout-button,
|
||||||
|
.mobile-logout,
|
||||||
|
.text-button {
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
min-height: 2.7rem;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--panel-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button,
|
||||||
|
.logout-button {
|
||||||
|
min-height: 2.35rem;
|
||||||
|
padding: 0.48rem 0.7rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
width: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-logout {
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-button {
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
margin: -0.35rem 0 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-message {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-message.error {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 45%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--danger) 8%, var(--panel));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-message.success {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 38%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary {
|
||||||
|
display: grid;
|
||||||
|
padding: 0 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary small {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-panel,
|
||||||
|
.invite-panel {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-panel {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading h2,
|
||||||
|
.invite-panel h2,
|
||||||
|
.section-heading {
|
||||||
|
margin: 0.18rem 0 0.7rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-mode,
|
||||||
|
.account-status {
|
||||||
|
padding: 0.22rem 0.45rem;
|
||||||
|
border-radius: 2rem;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-status.locked {
|
||||||
|
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-list {
|
||||||
|
display: grid;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(10rem, 1fr) auto minmax(11rem, auto) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.7rem 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-row > div,
|
||||||
|
.invitation-list span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-row small,
|
||||||
|
.invitation-list small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-account-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-account-form select {
|
||||||
|
min-height: 2.2rem;
|
||||||
|
padding-block: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-list {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-list > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.55rem 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 72rem) {
|
@media (max-width: 72rem) {
|
||||||
.page-wrap {
|
.page-wrap {
|
||||||
padding-inline: 2rem;
|
padding-inline: 2rem;
|
||||||
@@ -1250,12 +1535,15 @@ a {
|
|||||||
z-index: 30;
|
z-index: 30;
|
||||||
display: grid;
|
display: grid;
|
||||||
height: calc(var(--mobile-nav) + env(safe-area-inset-bottom));
|
height: calc(var(--mobile-nav) + env(safe-area-inset-bottom));
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
padding: 0.45rem 0.35rem env(safe-area-inset-bottom);
|
padding: 0.45rem 0.35rem env(safe-area-inset-bottom);
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
|
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
|
||||||
backdrop-filter: blur(22px) saturate(1.3);
|
backdrop-filter: blur(22px) saturate(1.3);
|
||||||
}
|
}
|
||||||
|
.mobile-navigation:has(a:nth-child(5)) {
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
}
|
||||||
.mobile-navigation a {
|
.mobile-navigation a {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1399,6 +1687,19 @@ a {
|
|||||||
.settings-grid {
|
.settings-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.management-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.account-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
.inline-account-form,
|
||||||
|
.account-row > form:last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.inline-account-form select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 24rem) {
|
@media (max-width: 24rem) {
|
||||||
|
|||||||
Vendored
+10
-1
@@ -1,5 +1,14 @@
|
|||||||
|
import type { AuthUser } from '#lib/server/auth.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {}
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
user: AuthUser | null;
|
||||||
|
}
|
||||||
|
interface PageData {
|
||||||
|
user?: AuthUser | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -7,6 +7,38 @@ function configuredPath(value: string | undefined, fallback: string): string {
|
|||||||
export function getServerPaths() {
|
export function getServerPaths() {
|
||||||
return {
|
return {
|
||||||
database: configuredPath(process.env.SENDEWERK_DB_PATH, '.data/sendewerk.sqlite'),
|
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 || ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ describe('database setup', () => {
|
|||||||
.get() as { migrations: number; pieces: number; shows: number; usages: number };
|
.get() as { migrations: number; pieces: number; shows: number; usages: number };
|
||||||
database.close();
|
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'));
|
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user });
|
||||||
+75
-48
@@ -5,7 +5,7 @@
|
|||||||
import NavIcon from '#lib/components/NavIcon.svelte';
|
import NavIcon from '#lib/components/NavIcon.svelte';
|
||||||
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children, data } = $props();
|
||||||
const primaryNavigation = [
|
const primaryNavigation = [
|
||||||
{ href: '/eingang', label: 'Eingang', icon: 'inbox' },
|
{ href: '/eingang', label: 'Eingang', icon: 'inbox' },
|
||||||
{ href: '/fundus', label: 'Fundus', icon: 'library' },
|
{ href: '/fundus', label: 'Fundus', icon: 'library' },
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
{ href: '/archiv', label: 'Archiv', icon: 'archive' }
|
{ href: '/archiv', label: 'Archiv', icon: 'archive' }
|
||||||
] as const;
|
] as const;
|
||||||
const administration = { href: '/verwaltung', label: 'Verwaltung', icon: 'settings' } as const;
|
const administration = { href: '/verwaltung', label: 'Verwaltung', icon: 'settings' } as const;
|
||||||
const mobileNavigation = [...primaryNavigation, administration];
|
const mobileNavigation = $derived(
|
||||||
|
data.user?.role === 'barkeeper' ? [...primaryNavigation, administration] : primaryNavigation
|
||||||
|
);
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
return (
|
return (
|
||||||
@@ -29,15 +31,66 @@
|
|||||||
/>
|
/>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="app-shell">
|
{#if data.user}
|
||||||
<aside class="sidebar">
|
<div class="app-shell">
|
||||||
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
|
<aside class="sidebar">
|
||||||
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
<a class="brand" href={resolve('/fundus')} aria-label="Sendewerk Startseite">
|
||||||
<span>Sendewerk</span>
|
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
</a>
|
<span>Sendewerk</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<nav class="main-navigation" aria-label="Hauptnavigation">
|
<nav class="main-navigation" aria-label="Hauptnavigation">
|
||||||
{#each primaryNavigation as item (item.href)}
|
{#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
|
<a
|
||||||
href={resolve(item.href)}
|
href={resolve(item.href)}
|
||||||
class:active={isActive(item.href)}
|
class:active={isActive(item.href)}
|
||||||
@@ -48,42 +101,16 @@
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
<div class="sidebar-footer">
|
{:else}
|
||||||
<nav class="utility-navigation" aria-label="Verwaltung">
|
<div class="auth-shell">
|
||||||
<a
|
<header class="auth-header">
|
||||||
href={resolve(administration.href)}
|
<a class="brand" href={resolve('/login')} aria-label="Sendewerk Anmeldung">
|
||||||
class:active={isActive(administration.href)}
|
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
aria-current={isActive(administration.href) ? 'page' : undefined}
|
<span>Sendewerk</span>
|
||||||
>
|
|
||||||
<NavIcon name={administration.icon} />
|
|
||||||
<span>{administration.label}</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
<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>
|
|
||||||
<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)}
|
|
||||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
<NavIcon name={item.icon} />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
<ThemeToggle />
|
||||||
</nav>
|
</header>
|
||||||
</div>
|
<main class="auth-main">{@render children()}</main>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { hasUsers } from '#lib/server/auth.js';
|
||||||
|
|
||||||
export function load() {
|
export function load({ locals }) {
|
||||||
redirect(307, '/fundus');
|
if (!hasUsers()) redirect(307, '/setup');
|
||||||
|
redirect(307, locals.user ? '/fundus' : '/login');
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -1,5 +1,97 @@
|
|||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { getTaxonomy } from '#lib/server/previews.js';
|
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() {
|
export const load: PageServerLoad = ({ locals }) => {
|
||||||
return { taxonomy: getTaxonomy() };
|
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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageProps } from './$types';
|
||||||
let { data }: { data: PageData } = $props();
|
let { data, form }: PageProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Verwaltung | Sendewerk</title></svelte:head>
|
<svelte:head><title>Verwaltung | Sendewerk</title></svelte:head>
|
||||||
@@ -8,8 +8,108 @@
|
|||||||
<div class="page-wrap narrow-page">
|
<div class="page-wrap narrow-page">
|
||||||
<header class="page-heading">
|
<header class="page-heading">
|
||||||
<h1>Verwaltung</h1>
|
<h1>Verwaltung</h1>
|
||||||
<p class="page-intro">Abschnittskategorien, Formate und Themen im Überblick.</p>
|
<p class="page-intro">Konten, Rollen und bestehende Taxonomie verwalten.</p>
|
||||||
</header>
|
</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">
|
<div class="settings-grid">
|
||||||
<section class="quiet-panel taxonomy-panel">
|
<section class="quiet-panel taxonomy-panel">
|
||||||
<p class="eyebrow">Abschnittskategorien</p>
|
<p class="eyebrow">Abschnittskategorien</p>
|
||||||
@@ -35,5 +135,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<p class="settings-footnote">Konten und Rollen sind noch nicht verfügbar.</p>
|
|
||||||
</div>
|
</div>
|
||||||
@@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
mkdir -p /app/data/uploads
|
mkdir -p /app/data/uploads /app/data/mail-outbox
|
||||||
chown -R cloudron:cloudron /app/data
|
chown -R cloudron:cloudron /app/data
|
||||||
|
|
||||||
export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}"
|
export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}"
|
||||||
export SENDEWERK_UPLOAD_DIR="${SENDEWERK_UPLOAD_DIR:-/app/data/uploads}"
|
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 HOST="${HOST:-0.0.0.0}"
|
||||||
export PORT="${PORT:-3000}"
|
export PORT="${PORT:-3000}"
|
||||||
|
|
||||||
|
|||||||
+138
@@ -1,4 +1,11 @@
|
|||||||
import { expect, test, type Page } from '@playwright/test';
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
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 routes = ['/fundus', '/sendungen', '/eingang', '/archiv', '/verwaltung', '/live'];
|
||||||
const viewports = [
|
const viewports = [
|
||||||
@@ -22,6 +29,137 @@ async function setTheme(page: Page, theme: 'light' | 'dark') {
|
|||||||
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
|
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 ({
|
test('desktop fundus is a compact, filterable list with persistent themes', async ({
|
||||||
page
|
page
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
|
|||||||
+4
-1
@@ -9,7 +9,10 @@ export default defineConfig({
|
|||||||
runes: ({ filename }) =>
|
runes: ({ filename }) =>
|
||||||
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||||
},
|
},
|
||||||
adapter: adapter()
|
adapter: adapter(),
|
||||||
|
paths: {
|
||||||
|
origin: process.env.SENDEWERK_APP_ORIGIN
|
||||||
|
}
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in new issue
Block a user