diff --git a/.env.example b/.env.example index 8d8e95a..59353e8 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,19 @@ # Relative paths are resolved from the project directory. SENDEWERK_DB_PATH=.data/sendewerk.sqlite SENDEWERK_UPLOAD_DIR=.data/uploads +SENDEWERK_MAIL_OUTBOX_DIR=.data/mail-outbox + +# Generate this locally and insert it here as described in README.md. Never commit .env. +SENDEWERK_SETUP_TOKEN= +SENDEWERK_APP_ORIGIN=http://localhost:5173 + +# "local" writes messages to the outbox and never contacts a mail server. +SENDEWERK_MAIL_TRANSPORT=local +SENDEWERK_MAIL_FROM=Sendewerk + +# 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= diff --git a/CloudronManifest.json b/CloudronManifest.json index 0ef0fba..6a2826e 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -3,7 +3,7 @@ "title": "Sendewerk", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "tagline": "Vom Fundstück bis zur Livesendung", - "version": "0.1.1", + "version": "0.2.0", "healthCheckPath": "/healthz", "httpPort": 3000, "addons": { diff --git a/README.md b/README.md index bc37690..b5d380e 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst -ausschließlich Etappe 1: technisches Grundgerüst, persistente Beispieldaten -und gekennzeichnete Gestaltungsvorschauen. Anmeldung, Bearbeitung und echte -Live-Steuerung folgen in späteren Etappen. +Etappe 1 und 2: technisches Grundgerüst, persistente Beispieldaten, +Gestaltungsvorschauen sowie echte Anmeldung mit Barkeeper-/Thekencrew-Rollen. +Inhaltsbearbeitung und Live-Steuerung folgen in späteren Etappen. ## Voraussetzungen @@ -20,6 +20,9 @@ nicht erforderlich. ```sh npm ci cp .env.example .env +SETUP_TOKEN="$(openssl rand -hex 32)" +sed -i "s/^SENDEWERK_SETUP_TOKEN=.*/SENDEWERK_SETUP_TOKEN=$SETUP_TOKEN/" .env +printf 'Betreiber-Setup-Token: %s\n' "$SETUP_TOKEN" npm run setup npm run dev ``` @@ -29,18 +32,41 @@ erstellt die konfigurierten Verzeichnisse, führt ausstehende Migrationen aus und lädt den reproduzierbaren Beispieldatensatz. Der Seed darf beliebig oft ausgeführt werden und ersetzt nur die von ihm verwalteten Beispieldaten. +Beim ersten Aufruf verlangt `/setup` den ausgegebenen Wert aus +`SENDEWERK_SETUP_TOKEN` und legt genau einen Barkeeper an. Der Befehl schreibt +den Wert in die nicht versionierte `.env`, damit auch spätere lokale Starts +dieselbe Konfiguration verwenden. Nach erfolgreicher Einrichtung ist `/setup` +dauerhaft geschlossen und der Token kann aus `.env` entfernt werden. Der Seed +enthält bewusst kein Konto und kein Standardpasswort. + ## Konfiguration -| Variable | Vorgabe | Bedeutung | -| ---------------------- | ------------------------ | ----------------------------------- | -| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei | -| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis | -| `HOST` | adapterabhängig | Bind-Adresse des Produktionsservers | -| `PORT` | `3000` | Port des Produktionsservers | +| Variable | Vorgabe | Bedeutung | +| --------------------------- | ------------------------ | ---------------------------------------------------- | +| `SENDEWERK_DB_PATH` | `.data/sendewerk.sqlite` | Pfad zur persistenten SQLite-Datei | +| `SENDEWERK_UPLOAD_DIR` | `.data/uploads` | Persistentes Upload-Verzeichnis | +| `SENDEWERK_MAIL_OUTBOX_DIR` | `.data/mail-outbox` | Lokale, nicht öffentlich ausgelieferte Testmail | +| `SENDEWERK_SETUP_TOKEN` | keine | einmaliges Betreibergeheimnis, mindestens 32 Zeichen | +| `SENDEWERK_APP_ORIGIN` | `http://localhost:5173` | feste Origin für Links und Cookie-Sicherheit | +| `SENDEWERK_MAIL_TRANSPORT` | `local` | `local` oder ausdrücklich `smtp` | +| `SENDEWERK_MAIL_FROM` | lokaler Absender | Absender; bei SMTP verpflichtend | +| `SENDEWERK_SMTP_HOST` | keine | SMTP-Server, nur im SMTP-Modus | +| `SENDEWERK_SMTP_PORT` | `587` | SMTP-Port | +| `SENDEWERK_SMTP_SECURE` | `false` | `true` für implizites TLS, üblicherweise Port 465 | +| `SENDEWERK_SMTP_USER` | keine | optionaler SMTP-Benutzer | +| `SENDEWERK_SMTP_PASSWORD` | keine | optionales SMTP-Passwort | +| `HOST` | adapterabhängig | Bind-Adresse des Produktionsservers | +| `PORT` | `3000` | Port des Produktionsservers | Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In Cloudron liegen Datenbank und Uploads unter `/app/data`. +Der Vorgabemodus `local` baut keine Netzwerkverbindung auf. Einladungen und +Passwort-Resets landen als JSON-Dateien in `.data/mail-outbox`; die enthaltenen +Links sind Geheimnisse und dürfen nicht veröffentlicht werden. Echter Versand +findet nur statt, wenn `SENDEWERK_MAIL_TRANSPORT=smtp` und die SMTP-Werte +explizit gesetzt sind. + ## Befehle | Befehl | Zweck | @@ -48,7 +74,7 @@ Cloudron liegen Datenbank und Uploads unter `/app/data`. | `npm run setup` | Verzeichnisse, Migrationen und Seed für lokale Entwicklung | | `npm run db:migrate` | Ausstehende Migrationen atomar ausführen | | `npm run db:seed` | Reproduzierbare Beispieldaten laden | -| `npm run dev` | Entwicklungsserver starten | +| `npm run dev` | Migrationen ausführen und Entwicklungsserver starten | | `npm run check` | Svelte- und TypeScript-Prüfung | | `npm run lint` | Formatierung und ESLint prüfen | | `npm test` | Verhaltenstests mit temporärer, getrennter Datenbank | @@ -61,6 +87,7 @@ Cloudron liegen Datenbank und Uploads unter `/app/data`. ```sh npm ci npm run setup +export SENDEWERK_APP_ORIGIN=http://localhost:3000 npm run build HOST=127.0.0.1 PORT=3000 npm start ``` @@ -68,6 +95,12 @@ HOST=127.0.0.1 PORT=3000 npm start Der Healthcheck liegt unter und prüft auch den Datenbankzugriff. +Für lokales HTTP wird das Sitzungscookie gezielt ohne `Secure` gesetzt, bleibt +aber `HttpOnly` und `SameSite=Lax`. Bei einer `https:`-Origin ist `Secure` +automatisch aktiv. Die Build-Origin muss beim lokalen Produktionsbuild mit der +später verwendeten Browser-Origin übereinstimmen; SvelteKits Origin-Prüfung +bleibt aktiv. + Vor der ersten Browserprüfung wird der Testbrowser einmalig installiert: ```sh @@ -82,9 +115,17 @@ Das frühe Cloudron-Paket besteht aus `Dockerfile`, `CloudronManifest.json` und ```sh docker build -t sendewerk:local . -docker run --rm -p 3000:3000 -v sendewerk-data:/app/data sendewerk:local +docker run --rm -p 3000:3000 \ + -e SENDEWERK_APP_ORIGIN=http://localhost:3000 \ + -e SENDEWERK_SETUP_TOKEN="$(openssl rand -hex 32)" \ + -v sendewerk-data:/app/data sendewerk:local ``` +Auf Cloudron muss das Betreiber-Setup-Token vor dem ersten Browseraufruf als +Umgebungsvariable gesetzt werden. `CLOUDRON_APP_ORIGIN` liefert dort die feste +HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als +Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben. + Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup, Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort abschließend geprüft werden. @@ -101,5 +142,6 @@ Medien und redaktionellen Inhalten werden dadurch nicht verändert. Die direkten Laufzeit- und Werkzeugabhängigkeiten verwenden MIT- oder Apache-2.0-Lizenzen: Svelte/SvelteKit, Vite, Vitest, ESLint und -`better-sqlite3` sind MIT-lizenziert; TypeScript und Playwright stehen unter -Apache-2.0. Die Lockdatei fixiert den tatsächlich installierten Baum. +`better-sqlite3` und `@node-rs/argon2` sind MIT-lizenziert; Nodemailer verwendet +MIT-0, TypeScript und Playwright stehen unter Apache-2.0. Die Lockdatei fixiert +den tatsächlich installierten Baum. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 33ec8b7..a6d2035 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -39,3 +39,30 @@ - Ein zusätzlicher Python-Playwright-Lauf: nicht gestartet, weil das optionale Python-Modul `playwright` nicht installiert war. Die projektgebundene TypeScript-Playwright-Suite lief stattdessen vollständig erfolgreich. + +## Etappe 2 + +| Kriterium | Nachweis | Stand | +| ------------------------------- | ----------------------------------------------------------------------------------- | --------------------- | +| Erstadmin danach geschlossen | Browser-Setup sowie wiederholter Service-Aufruf geprüft; Seed enthält kein Konto | erfüllt am 21.10.2026 | +| Login, Logout und lokales HTTP | Browserablauf; `HttpOnly`, `SameSite=Lax`, ohne `Secure` bei expliziter HTTP-Origin | erfüllt am 21.10.2026 | +| Einladung und Reset | lokale Outbox bis Kontoaktivierung und neuem Login im Browser durchlaufen | erfüllt am 21.10.2026 | +| Einmalige und abgelaufene Links | Verhaltenstests für Wiederverwendung und Ablauf von Einladung und Reset | erfüllt am 21.10.2026 | +| Direkte Admin-Aktion verweigert | Thekencrew-POST auf Einladungsaction liefert 403 | erfüllt am 21.10.2026 | +| Sperrung entzieht Zugriff | aktive Thekencrew-Sitzung wird gelöscht und beim nächsten Request abgewiesen | erfüllt am 21.10.2026 | +| Keine echte Mail ungefragt | Browser- und Verhaltenstests nutzen ausschließlich temporäre lokale Outbox | erfüllt am 21.10.2026 | +| Echter SMTP-Versand | nicht ausgeführt, keine Zugangsdaten bereitgestellt | offen | + +## Ausgeführte Prüfungen Etappe 2 + +- `npm run lint`: erfolgreich +- `npm run check`: 0 Fehler und 0 Warnungen +- `npm test`: 9 Auth-, Migrations-, Mail- und Dauer-Verhaltenstests erfolgreich +- `npm run build`: erfolgreich mit `@sveltejs/adapter-node` +- `npm run test:e2e`: 9 von 9 Browsertests erfolgreich; Erstadmin, + Login/Logout, fremder Origin, Einladung, direkte Rollenprüfung, Sperrung, + Reset sowie bestehende responsive Arbeitsansichten geprüft +- Lokale Outbox in isolierten temporären Verzeichnissen geprüft +- Lokaler Produktionsserver der Browsertests über HTTP gestartet; Healthcheck + und Migration vor Start erfolgreich +- `bash -n start.sh`: erfolgreich diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4fb1976..912c625 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,7 +38,7 @@ Kern. Folgende Grenzen sind für spätere Migrationen bereits festgelegt. | Bereich | Geplante Entitäten und Beziehungen | | ----------- | ---------------------------------------------------------------------------------------------------------------------------- | -| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens, Sperren und technische Zugänge; Tokens nur gehasht | +| Identität | `users`, Rollen, Sessions, Einladungen, Reset-Tokens und Sperren sind umgesetzt; technische Zugänge folgen später | | Taxonomie | getrennte `section_categories`, `formats`, `topics`; Pieces und Vorschläge über Zuordnungstabellen mehrfach verknüpft | | Inhalt | `pieces` mit Status und Dauer; Quellen, Medien, private Recherche, Moderation, Präsentation und Vorlagen als getrennte Daten | | Sendungsbau | `shows`, geordnete `show_sections`, kopierte `piece_usages`, Reservebank und Sendungsvorlagen; Original-ID bleibt Referenz | @@ -63,6 +63,32 @@ oder eine explizite Abschnittsdauer. Beides wird nie addiert. Reserveelemente zählen erst nach dem Einplanen. Geplante und tatsächliche Dauer bleiben getrennt. +## Anmeldung und Rechte + +Etappe 2 ergänzt lokale Konten mit den festen Rollen `barkeeper` und +`thekencrew`. Passwörter werden mit Argon2id gespeichert. Sitzungs-, Einladungs- +und Reset-Tokens entstehen aus kryptografischem Zufall; in SQLite liegt jeweils +nur ihr SHA-256-Hash. Der Browser erhält ausschließlich das rohe Sitzungstoken +in einem `HttpOnly`-, `SameSite=Lax`-Cookie. + +`hooks.server.ts` löst die Sitzung für jeden Request auf. Die Datenbankabfrage +verbindet die Session immer mit dem aktuellen Nutzerzustand, sodass Sperren und +Rollenwechsel ohne verzögerte Token-Erneuerung wirken. Geschützte Routen +erfordern eine Anmeldung; Verwaltungs-Loads und jede Verwaltungs-Action prüfen +zusätzlich die Barkeeper-Rolle. Sperrung und Passwortwechsel löschen bestehende +Sessions transaktional. + +Der Erstadmin benötigt sowohl eine leere Nutzertabelle als auch ein extern +konfiguriertes Setup-Token. Einladungs- und Reset-Links sind 24 Stunden gültig, +werden erst beim bestätigenden POST verbraucht und sind genau einmal nutzbar. +Fehlgeschlagene Loginversuche werden pro E-Mail-Schlüssel und Clientadresse in +einem Zeitfenster begrenzt, ohne das Konto dauerhaft zu sperren. + +Mail verwendet einen Adapter: `local` schreibt geschützte Dateien in den +konfigurierten Outbox-Pfad, `smtp` nutzt Nodemailer nur nach expliziter Auswahl. +Die feste Anwendungs-Origin erzeugt Links und bestimmt den `Secure`-Status der +Cookies; sie wird nicht aus ungeprüften Request-Headern übernommen. + ## Oberflächenstruktur Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung. @@ -81,8 +107,8 @@ Fachabläufe werden jedoch nicht vorgetäuscht. ## Sicherheitsgrenzen ab den Folgeetappen -Authentifizierung und Autorisierung werden serverseitig an jeder Route und -Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt. +Authentifizierung und Autorisierung werden seit Etappe 2 serverseitig an jeder +betroffenen Route und Action geprüft. Markdown und fremdes HTML werden vor Ausgabe bereinigt. Uploadzugriff folgt denselben Rechten. Netzwerkabrufe erhalten Schutz gegen interne Ziele, Größen-/Zeitlimits und kontrollierte Redirects. Diese Regeln sind geplant, aber nicht Bestandteil der Etappe-1-Vorschauen. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 752ad8d..2a85b99 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -94,3 +94,44 @@ Der Fundus erscheint auf dem Desktop als kompakte Liste und auf kleinen Bildschirmen als Kartenfolge. Vorschau- und Schreibschutzinformationen sind in einem nativen, tastaturbedienbaren `details`-Element gebündelt. Diese Darstellungsentscheidung ändert weder Datenmodell noch Funktionsumfang. + +## D-008: Widerrufbare Datenbanksitzungen und Argon2id + +Sendewerk verwendet Datenbanksitzungen statt JWT. SvelteKit empfiehlt diesen +Ansatz, wenn sofortiger Widerruf wichtiger als eine zusätzliche Datenbankabfrage +ist. Ein zufälliges Sitzungstoken liegt nur als SHA-256-Hash in SQLite; der +Browser erhält es als `HttpOnly`-, `SameSite=Lax`-Cookie. Nutzerstatus und Rolle +werden bei jedem Request erneut verbunden. Lokal erlaubt eine explizite +`http:`-Origin Cookies ohne `Secure`, bei `https:` wird `Secure` gesetzt. + +Passwörter hasht `@node-rs/argon2` 2.2.2 mit Argon2id und den dokumentierten +Paketparametern. Zufallstokens und SHA-256 stammen aus `node:crypto`; eigene +Kryptoprimitive werden nicht implementiert. + +Quellen: + +- +- +- + +## 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: + +## 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: diff --git a/docs/STATUS.md b/docs/STATUS.md index 7a9c63e..e8b1ffc 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,8 +2,9 @@ ## Aktueller Umfang -Etappe 1 ist abgeschlossen. Architektur, Gesamtdatenmodell, Entscheidungen und -lokaler Betriebsweg sind dokumentiert. Etappe 2 wurde nicht begonnen. +Etappe 1 und Etappe 2 sind abgeschlossen. Architektur, Gesamtdatenmodell, +Anmeldung, Rollen und lokaler Betriebsweg sind dokumentiert. Etappe 3 wurde +nicht begonnen. ## Umgesetzter Stand Etappe 1 @@ -36,11 +37,22 @@ umgebrochen. ## Nicht Bestandteil dieser Etappe -Anmeldung, Rollen, echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung, -Live-Steuerung, Präsentationsfenster, Archivabschluss, Freigaben, RSS und API. +Echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung, Live-Steuerung, +Präsentationsfenster, Archivabschluss, Freigaben, RSS und API. Die Vorschauen dürfen nicht als Implementierung dieser Funktionen verstanden werden. +## Umgesetzter Stand Etappe 2 + +- einmalige Erstadmin-Einrichtung mit externem Betreiber-Token +- Login und POST-Logout mit widerrufbaren Datenbanksitzungen +- Rollen Barkeeper und Thekencrew mit serverseitiger Prüfung +- Einladungen und Passwortreset über befristete, einmalige Links +- Kontensperrung mit sofortigem Entzug bestehender Sitzungen +- zeitfensterbasiertes Login-Rate-Limit ohne dauerhafte Kontensperre +- lokale Mail-Outbox als Vorgabe und explizit konfigurierbarer SMTP-Versand +- responsive Authentifizierungs- und Kontenverwaltungsoberflächen + ## Bekannte offene Betriebsprüfung Eine echte Cloudron-Installation samt Backup/Restore, Reverse-Proxy-Verhalten diff --git a/migrations/0002_authentication.sql b/migrations/0002_authentication.sql new file mode 100644 index 0000000..42c86a0 --- /dev/null +++ b/migrations/0002_authentication.sql @@ -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); diff --git a/package-lock.json b/package-lock.json index 903a7ce..39e149d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "sendewerk", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.1.1", + "version": "0.2.0", "dependencies": { - "better-sqlite3": "^13.0.3" + "@node-rs/argon2": "^2.2.2", + "better-sqlite3": "^13.0.3", + "nodemailer": "^10.1.7" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -327,6 +329,238 @@ "dev": true, "license": "MIT" }, + "node_modules/@node-rs/argon2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz", + "integrity": "sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@node-rs/argon2-android-arm-eabi": "2.2.2", + "@node-rs/argon2-android-arm64": "2.2.2", + "@node-rs/argon2-darwin-arm64": "2.2.2", + "@node-rs/argon2-darwin-x64": "2.2.2", + "@node-rs/argon2-freebsd-x64": "2.2.2", + "@node-rs/argon2-linux-arm-gnueabihf": "2.2.2", + "@node-rs/argon2-linux-arm64-gnu": "2.2.2", + "@node-rs/argon2-linux-arm64-musl": "2.2.2", + "@node-rs/argon2-linux-x64-gnu": "2.2.2", + "@node-rs/argon2-linux-x64-musl": "2.2.2", + "@node-rs/argon2-win32-arm64-msvc": "2.2.2", + "@node-rs/argon2-win32-ia32-msvc": "2.2.2", + "@node-rs/argon2-win32-x64-msvc": "2.2.2" + } + }, + "node_modules/@node-rs/argon2-android-arm-eabi": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.2.2.tgz", + "integrity": "sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-android-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.2.2.tgz", + "integrity": "sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.2.2.tgz", + "integrity": "sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.2.2.tgz", + "integrity": "sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-freebsd-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.2.2.tgz", + "integrity": "sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.2.2.tgz", + "integrity": "sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-gnu": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.2.2.tgz", + "integrity": "sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-musl": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.2.2.tgz", + "integrity": "sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-gnu": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.2.2.tgz", + "integrity": "sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-musl": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.2.2.tgz", + "integrity": "sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-arm64-msvc": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.2.2.tgz", + "integrity": "sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-ia32-msvc": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.2.2.tgz", + "integrity": "sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-x64-msvc": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.2.2.tgz", + "integrity": "sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@oxc-project/types": { "version": "0.154.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", @@ -2209,6 +2443,15 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/nodemailer": { + "version": "10.1.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz", + "integrity": "sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", diff --git a/package.json b/package.json index 1ebf5be..5712814 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { "name": "sendewerk", "private": true, - "version": "0.1.1", + "version": "0.2.0", "type": "module", "engines": { "node": ">=22.17 <25" }, "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", + "dev": "npm run db:migrate && node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js dev", + "build": "node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js build", + "preview": "node --env-file-if-exists=.env ./node_modules/vite/bin/vite.js preview", "start": "npm run db:migrate && node --env-file-if-exists=.env build", "setup": "npm run db:migrate && npm run db:seed", "db:migrate": "node --env-file-if-exists=.env scripts/migrate.mjs", @@ -24,7 +24,9 @@ "test:e2e": "playwright test" }, "dependencies": { - "better-sqlite3": "^13.0.3" + "@node-rs/argon2": "^2.2.2", + "better-sqlite3": "^13.0.3", + "nodemailer": "^10.1.7" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/playwright.config.ts b/playwright.config.ts index aa9802b..7535c88 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,7 +3,11 @@ import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; -const dataDirectory = mkdtempSync(path.join(tmpdir(), 'sendewerk-e2e-')); +const dataDirectory = + process.env.SENDEWERK_E2E_DATA_DIR || mkdtempSync(path.join(tmpdir(), 'sendewerk-e2e-')); +const mailOutboxDirectory = path.join(dataDirectory, 'mail-outbox'); +process.env.SENDEWERK_E2E_DATA_DIR = dataDirectory; +process.env.SENDEWERK_E2E_OUTBOX_DIR = mailOutboxDirectory; export default defineConfig({ testDir: 'tests', @@ -18,6 +22,11 @@ export default defineConfig({ env: { SENDEWERK_DB_PATH: path.join(dataDirectory, 'e2e.sqlite'), SENDEWERK_UPLOAD_DIR: path.join(dataDirectory, 'uploads'), + SENDEWERK_MAIL_OUTBOX_DIR: mailOutboxDirectory, + SENDEWERK_MAIL_TRANSPORT: 'local', + SENDEWERK_APP_ORIGIN: 'http://127.0.0.1:4173', + SENDEWERK_SETUP_TOKEN: 'e2e-only-setup-token-32-characters', + ORIGIN: 'http://127.0.0.1:4173', HOST: '127.0.0.1', PORT: '4173' } diff --git a/src/app.css b/src/app.css index 783c151..ae4b52f 100644 --- a/src/app.css +++ b/src/app.css @@ -78,7 +78,8 @@ body { } button, -input { +input, +select { font: inherit; } @@ -1179,6 +1180,290 @@ a { 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) { .page-wrap { padding-inline: 2rem; @@ -1250,12 +1535,15 @@ a { z-index: 30; display: grid; 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); border-top: 1px solid var(--line); background: color-mix(in srgb, var(--bg-elevated) 92%, transparent); backdrop-filter: blur(22px) saturate(1.3); } + .mobile-navigation:has(a:nth-child(5)) { + grid-template-columns: repeat(5, 1fr); + } .mobile-navigation a { display: flex; min-width: 0; @@ -1399,6 +1687,19 @@ a { .settings-grid { 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) { diff --git a/src/app.d.ts b/src/app.d.ts index 6285566..3297b73 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,5 +1,14 @@ +import type { AuthUser } from '#lib/server/auth.js'; + declare global { - namespace App {} + namespace App { + interface Locals { + user: AuthUser | null; + } + interface PageData { + user?: AuthUser | null; + } + } } export {}; diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..df33f4a --- /dev/null +++ b/src/hooks.server.ts @@ -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; +}; diff --git a/src/lib/server/auth.spec.ts b/src/lib/server/auth.spec.ts new file mode 100644 index 0000000..eca9637 --- /dev/null +++ b/src/lib/server/auth.spec.ts @@ -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(); + 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'); + }); +}); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..9083c85 --- /dev/null +++ b/src/lib/server/auth.ts @@ -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 { + return hash(password, PASSWORD_OPTIONS); +} + +export async function verifyPassword(passwordHash: string, password: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index d348095..61c9e63 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -7,6 +7,38 @@ function configuredPath(value: string | undefined, fallback: string): string { export function getServerPaths() { return { database: configuredPath(process.env.SENDEWERK_DB_PATH, '.data/sendewerk.sqlite'), - uploads: configuredPath(process.env.SENDEWERK_UPLOAD_DIR, '.data/uploads') + uploads: configuredPath(process.env.SENDEWERK_UPLOAD_DIR, '.data/uploads'), + mailOutbox: configuredPath(process.env.SENDEWERK_MAIL_OUTBOX_DIR, '.data/mail-outbox') + }; +} + +export function getAuthConfig() { + const configuredOrigin = process.env.SENDEWERK_APP_ORIGIN || process.env.CLOUDRON_APP_ORIGIN; + if (process.env.NODE_ENV === 'production' && !configuredOrigin) { + throw new Error('SENDEWERK_APP_ORIGIN muss im Produktionsbetrieb gesetzt sein.'); + } + const origin = new URL(configuredOrigin || 'http://localhost:5173'); + return { + origin, + setupToken: process.env.SENDEWERK_SETUP_TOKEN || '', + secureCookies: origin.protocol === 'https:' + }; +} + +export function getMailConfig() { + const transport = process.env.SENDEWERK_MAIL_TRANSPORT || 'local'; + if (transport !== 'local' && transport !== 'smtp') { + throw new Error('SENDEWERK_MAIL_TRANSPORT muss local oder smtp sein.'); + } + return { + transport, + from: + process.env.SENDEWERK_MAIL_FROM || + (transport === 'local' ? 'Sendewerk ' : ''), + 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 || '' }; } diff --git a/src/lib/server/database.spec.ts b/src/lib/server/database.spec.ts index 2c8d792..aeca841 100644 --- a/src/lib/server/database.spec.ts +++ b/src/lib/server/database.spec.ts @@ -52,7 +52,7 @@ describe('database setup', () => { .get() as { migrations: number; pieces: number; shows: number; usages: number }; database.close(); - expect(counts).toEqual({ migrations: 1, pieces: 4, shows: 1, usages: 3 }); + expect(counts).toEqual({ migrations: 2, pieces: 4, shows: 1, usages: 3 }); expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite')); }); diff --git a/src/lib/server/mail.ts b/src/lib/server/mail.ts new file mode 100644 index 0000000..46a7685 --- /dev/null +++ b/src/lib/server/mail.ts @@ -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'; +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..d8083fd --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,3 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user }); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3c5ec36..d0fea4c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,7 +5,7 @@ import NavIcon from '#lib/components/NavIcon.svelte'; import ThemeToggle from '#lib/components/ThemeToggle.svelte'; - let { children } = $props(); + let { children, data } = $props(); const primaryNavigation = [ { href: '/eingang', label: 'Eingang', icon: 'inbox' }, { href: '/fundus', label: 'Fundus', icon: 'library' }, @@ -13,7 +13,9 @@ { href: '/archiv', label: 'Archiv', icon: 'archive' } ] 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) { return ( @@ -29,15 +31,66 @@ /> -
- - -
- - - Sendewerk - - -
- -
{@render children()}
- -
+{:else} + + + +
{@render children()}
+ +{/if} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index e68757a..72e61c1 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,5 +1,7 @@ import { redirect } from '@sveltejs/kit'; +import { hasUsers } from '#lib/server/auth.js'; -export function load() { - redirect(307, '/fundus'); +export function load({ locals }) { + if (!hasUsers()) redirect(307, '/setup'); + redirect(307, locals.user ? '/fundus' : '/login'); } diff --git a/src/routes/einladung/+page.server.ts b/src/routes/einladung/+page.server.ts new file mode 100644 index 0000000..bcd66b9 --- /dev/null +++ b/src/routes/einladung/+page.server.ts @@ -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; diff --git a/src/routes/einladung/+page.svelte b/src/routes/einladung/+page.svelte new file mode 100644 index 0000000..961c2ad --- /dev/null +++ b/src/routes/einladung/+page.svelte @@ -0,0 +1,41 @@ + + +Einladung annehmen | Sendewerk + +
+

Einladung

+

Sendewerk-Konto aktivieren

+ {#if !data.valid && !form?.error} + + {:else} +

Die Einladung gilt für {data.email}.

+ {#if form?.error}{/if} +
+ + + +
+ {/if} +
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts new file mode 100644 index 0000000..23c3751 --- /dev/null +++ b/src/routes/login/+page.server.ts @@ -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; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..ea72f5b --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,37 @@ + + +Anmelden | Sendewerk + +
+

Redaktionszugang

+

Bei Sendewerk anmelden

+

Für Barkeeper und Thekencrew. Eine öffentliche Registrierung gibt es nicht.

+ {#if form?.error}{/if} +
+ + + +
+ Passwort vergessen? +
diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts new file mode 100644 index 0000000..642e5f2 --- /dev/null +++ b/src/routes/logout/+server.ts @@ -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'); +}; diff --git a/src/routes/passwort-vergessen/+page.server.ts b/src/routes/passwort-vergessen/+page.server.ts new file mode 100644 index 0000000..3ec7845 --- /dev/null +++ b/src/routes/passwort-vergessen/+page.server.ts @@ -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; diff --git a/src/routes/passwort-vergessen/+page.svelte b/src/routes/passwort-vergessen/+page.svelte new file mode 100644 index 0000000..547ec72 --- /dev/null +++ b/src/routes/passwort-vergessen/+page.svelte @@ -0,0 +1,31 @@ + + +Passwort vergessen | Sendewerk + +
+

Kontozugang

+

Passwort zurücksetzen

+

Wenn das Konto existiert, wird ein 24 Stunden gültiger, einmaliger Link bereitgestellt.

+ {#if form?.error}{/if} + {#if form?.sent}
+ Anfrage verarbeitet. Bitte Postfach beziehungsweise lokale Outbox prüfen. +
{/if} +
+ + +
+ Zurück zur Anmeldung +
diff --git a/src/routes/passwort-zuruecksetzen/+page.server.ts b/src/routes/passwort-zuruecksetzen/+page.server.ts new file mode 100644 index 0000000..e156794 --- /dev/null +++ b/src/routes/passwort-zuruecksetzen/+page.server.ts @@ -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; diff --git a/src/routes/passwort-zuruecksetzen/+page.svelte b/src/routes/passwort-zuruecksetzen/+page.svelte new file mode 100644 index 0000000..2d5a388 --- /dev/null +++ b/src/routes/passwort-zuruecksetzen/+page.svelte @@ -0,0 +1,33 @@ + + +Neues Passwort | Sendewerk + +
+

Kontozugang

+

Neues Passwort setzen

+ {#if !data.valid && !form?.error} + + Neuen Link anfordern + {:else} + {#if form?.error}{/if} +
+ + +
+ {/if} +
diff --git a/src/routes/setup/+page.server.ts b/src/routes/setup/+page.server.ts new file mode 100644 index 0000000..0c0fc2f --- /dev/null +++ b/src/routes/setup/+page.server.ts @@ -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; diff --git a/src/routes/setup/+page.svelte b/src/routes/setup/+page.svelte new file mode 100644 index 0000000..1e7acbd --- /dev/null +++ b/src/routes/setup/+page.svelte @@ -0,0 +1,65 @@ + + +Erstadmin einrichten | Sendewerk + +
+

Einmalige Einrichtung

+

Ersten Barkeeper anlegen

+

+ Dieses Konto verwaltet Einladungen, Rollen und Sperren. Danach wird die Einrichtung geschlossen. +

+ {#if !data.configured} + + {:else} + {#if form?.error}{/if} +
+ + + + +

+ Mindestens 12 Zeichen. Das Passwort wird ausschließlich als Argon2id-Hash gespeichert. +

+ +
+ {/if} +
diff --git a/src/routes/verwaltung/+page.server.ts b/src/routes/verwaltung/+page.server.ts index 2ef8585..1aa6889 100644 --- a/src/routes/verwaltung/+page.server.ts +++ b/src/routes/verwaltung/+page.server.ts @@ -1,5 +1,97 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; import { getTaxonomy } from '#lib/server/previews.js'; +import { getMailConfig } from '#lib/server/config.js'; +import { + inviteUser, + isRole, + listOpenInvitations, + listUsers, + requireBarkeeper, + revokeInvitation, + setUserLocked, + setUserRole, + validEmail +} from '#lib/server/auth.js'; -export function load() { - return { taxonomy: getTaxonomy() }; +export const load: PageServerLoad = ({ locals }) => { + requireBarkeeper(locals.user); + return { + taxonomy: getTaxonomy(), + users: listUsers(), + invitations: listOpenInvitations(), + mailTransport: getMailConfig().transport + }; +}; + +function actionError(cause: unknown): string { + const code = cause instanceof Error ? cause.message : ''; + if (code === 'USER_EXISTS') return 'Für diese E-Mail existiert bereits ein Konto.'; + if (code === 'SELF_LOCK') return 'Das eigene Konto kann nicht gesperrt werden.'; + if (code === 'LAST_BARKEEPER') return 'Mindestens ein aktiver Barkeeper muss erhalten bleiben.'; + if (code === 'NOT_FOUND') return 'Das Konto wurde nicht gefunden.'; + return 'Die Aktion konnte nicht ausgeführt werden.'; } + +export const actions = { + invite: async ({ locals, request }) => { + const actor = requireBarkeeper(locals.user); + const data = await request.formData(); + const email = String(data.get('email') || '').trim(); + const role = String(data.get('role') || ''); + if (!validEmail(email) || !isRole(role)) { + return fail(400, { intent: 'invite', email, error: 'E-Mail und Rolle bitte prüfen.' }); + } + try { + const transport = await inviteUser(actor, email, role); + return { + intent: 'invite', + success: + transport === 'local' + ? 'Einladung wurde in der lokalen Mail-Outbox abgelegt.' + : 'Einladung wurde versandt.' + }; + } catch (cause) { + return fail(400, { intent: 'invite', email, error: actionError(cause) }); + } + }, + role: async ({ locals, request }) => { + const actor = requireBarkeeper(locals.user); + const data = await request.formData(); + const userId = String(data.get('userId') || ''); + const role = String(data.get('role') || ''); + if (!userId || !isRole(role)) return fail(400, { intent: 'role', error: 'Ungültige Rolle.' }); + try { + setUserRole(actor, userId, role); + return { intent: 'role', success: 'Rolle aktualisiert.' }; + } catch (cause) { + return fail(400, { intent: 'role', error: actionError(cause) }); + } + }, + lock: async ({ locals, request }) => { + const actor = requireBarkeeper(locals.user); + const data = await request.formData(); + try { + setUserLocked(actor, String(data.get('userId') || ''), true); + return { intent: 'lock', success: 'Konto gesperrt und bestehende Sitzungen beendet.' }; + } catch (cause) { + return fail(400, { intent: 'lock', error: actionError(cause) }); + } + }, + unlock: async ({ locals, request }) => { + const actor = requireBarkeeper(locals.user); + const data = await request.formData(); + try { + setUserLocked(actor, String(data.get('userId') || ''), false); + return { intent: 'unlock', success: 'Konto entsperrt.' }; + } catch (cause) { + return fail(400, { intent: 'unlock', error: actionError(cause) }); + } + }, + revokeInvitation: async ({ locals, request }) => { + const actor = requireBarkeeper(locals.user); + const data = await request.formData(); + revokeInvitation(actor, String(data.get('invitationId') || '')); + return { intent: 'revokeInvitation', success: 'Einladung widerrufen.' }; + } +} satisfies Actions; diff --git a/src/routes/verwaltung/+page.svelte b/src/routes/verwaltung/+page.svelte index 2a33f1d..3ebcac9 100644 --- a/src/routes/verwaltung/+page.svelte +++ b/src/routes/verwaltung/+page.svelte @@ -1,6 +1,6 @@ Verwaltung | Sendewerk @@ -8,8 +8,108 @@

Verwaltung

-

Abschnittskategorien, Formate und Themen im Überblick.

+

Konten, Rollen und bestehende Taxonomie verwalten.

+ {#if form?.error}{/if} + {#if form?.success}
{form.success}
{/if} + + + +
+
+

Einladung

+

Neues Konto einladen

+
+ + + +
+
+
+

Offene Einladungen

+

Noch nicht angenommen

+ {#if data.invitations.length} +
+ {#each data.invitations as invitation (invitation.id)} +
+ {invitation.email}{invitation.role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'} +
+ +
+
+ {/each} +
+ {:else}

Keine offenen Einladungen.

{/if} +
+
+ +

Taxonomie

Abschnittskategorien

@@ -35,5 +135,4 @@
-

Konten und Rollen sind noch nicht verfügbar.

diff --git a/start.sh b/start.sh index 90342be..71e7187 100755 --- a/start.sh +++ b/start.sh @@ -2,11 +2,19 @@ set -eu -mkdir -p /app/data/uploads +mkdir -p /app/data/uploads /app/data/mail-outbox chown -R cloudron:cloudron /app/data export SENDEWERK_DB_PATH="${SENDEWERK_DB_PATH:-/app/data/sendewerk.sqlite}" export SENDEWERK_UPLOAD_DIR="${SENDEWERK_UPLOAD_DIR:-/app/data/uploads}" +export SENDEWERK_MAIL_OUTBOX_DIR="${SENDEWERK_MAIL_OUTBOX_DIR:-/app/data/mail-outbox}" +export SENDEWERK_APP_ORIGIN="${SENDEWERK_APP_ORIGIN:-${CLOUDRON_APP_ORIGIN:-}}" +if [ -n "${CLOUDRON_APP_ORIGIN:-}" ]; then + export PROTOCOL_HEADER="${PROTOCOL_HEADER:-x-forwarded-proto}" + export HOST_HEADER="${HOST_HEADER:-x-forwarded-host}" + export ADDRESS_HEADER="${ADDRESS_HEADER:-x-forwarded-for}" + export XFF_DEPTH="${XFF_DEPTH:-1}" +fi export HOST="${HOST:-0.0.0.0}" export PORT="${PORT:-3000}" diff --git a/tests/ui.e2e.ts b/tests/ui.e2e.ts index e2f2f4a..616bf0a 100644 --- a/tests/ui.e2e.ts +++ b/tests/ui.e2e.ts @@ -1,4 +1,11 @@ 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 viewports = [ @@ -22,6 +29,137 @@ async function setTheme(page: Page, theme: 'light' | 'dark') { await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme); } +async function login(page: Page, email = adminEmail, password = adminPassword) { + await page.goto('/login'); + await page.getByLabel('E-Mail').fill(email); + await page.getByLabel('Passwort', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Anmelden' }).click(); + await expect(page).toHaveURL(/\/fundus$/); +} + +function latestMailLink(subject: string): string { + const outbox = process.env.SENDEWERK_E2E_OUTBOX_DIR; + if (!outbox) throw new Error('E2E-Outbox ist nicht konfiguriert.'); + const messages = readdirSync(outbox) + .sort() + .map( + (file) => + JSON.parse(readFileSync(path.join(outbox, file), 'utf8')) as { + subject: string; + text: string; + } + ) + .filter((message) => message.subject === subject); + const match = messages.at(-1)?.text.match(/https?:\/\/[^\s]+/); + if (!match) throw new Error(`Keine Testmail mit Betreff ${subject} gefunden.`); + return match[0]; +} + +test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + await page.goto('/'); + if (await page.getByRole('heading', { name: 'Ersten Barkeeper anlegen' }).isVisible()) { + await page.getByLabel('Betreiber-Setup-Token').fill('e2e-only-setup-token-32-characters'); + await page.getByLabel('Name').fill('E2E Barkeeper'); + await page.getByLabel('E-Mail').fill(adminEmail); + await page.getByLabel('Passwort').fill(adminPassword); + await page.getByRole('button', { name: 'Barkeeper anlegen' }).click(); + } else { + await page.getByLabel('E-Mail').fill(adminEmail); + await page.getByLabel('Passwort').fill(adminPassword); + await page.getByRole('button', { name: 'Anmelden' }).click(); + } + await expect(page).toHaveURL(/\/fundus$/); + const sessionCookie = (await page.context().cookies()).find( + (cookie) => cookie.name === 'sendewerk_session' + ); + expect(sessionCookie?.httpOnly).toBe(true); + expect(sessionCookie?.secure).toBe(false); + expect(sessionCookie?.sameSite).toBe('Lax'); + await page.goto('/setup'); + await expect(page).toHaveURL(/\/fundus$/); + await page.getByRole('button', { name: 'Abmelden', exact: true }).click(); + await expect(page).toHaveURL(/\/login$/); + await page.close(); +}); + +test.beforeEach(async ({ page }) => { + await login(page); +}); + +test('invitations, direct authorization, locking and password reset work end to end', async ({ + page, + browser +}, testInfo) => { + await page.goto('/verwaltung'); + await page.screenshot({ + path: testInfo.outputPath('verwaltung-konten-desktop.png'), + fullPage: true + }); + const crossSite = await page.request.post('/verwaltung?/invite', { + form: { email: 'cross-site@example.test', role: 'barkeeper' }, + headers: { origin: 'https://example.invalid' } + }); + expect(crossSite.status()).toBe(403); + const inviteForm = page.locator('.invite-panel').filter({ hasText: 'Neues Konto einladen' }); + await inviteForm.getByLabel('E-Mail').fill(crewEmail); + await inviteForm.getByRole('button', { name: 'Einladung erstellen' }).click(); + await expect( + page.getByText('Einladung wurde in der lokalen Mail-Outbox abgelegt.') + ).toBeVisible(); + + const crewContext = await browser.newContext(); + const crewPage = await crewContext.newPage(); + await crewPage.goto(latestMailLink('Einladung zu Sendewerk')); + await expect(crewPage).toHaveURL(/\/einladung$/); + await crewPage.getByLabel('Name').fill('E2E Crew'); + await crewPage.getByLabel('Passwort').fill(crewPassword); + await crewPage.getByRole('button', { name: 'Konto aktivieren' }).click(); + await expect(crewPage).toHaveURL(/\/fundus$/); + await expect(crewPage.getByText('Verwaltung', { exact: true })).toHaveCount(0); + + const forbidden = await crewPage.request.post('/verwaltung?/invite', { + form: { email: 'verboten@example.test', role: 'barkeeper' }, + headers: { origin: 'http://127.0.0.1:4173' } + }); + expect(forbidden.status()).toBe(403); + + await page.reload(); + const crewRow = page.locator('.account-row').filter({ hasText: crewEmail }); + await crewRow.getByRole('button', { name: 'Sperren' }).click(); + await crewPage.goto('/fundus'); + await expect(crewPage).toHaveURL(/\/login$/); + + await page.reload(); + await page + .locator('.account-row') + .filter({ hasText: crewEmail }) + .getByRole('button', { name: 'Entsperren' }) + .click(); + await crewPage.goto('/passwort-vergessen'); + await crewPage.getByLabel('E-Mail').fill(crewEmail); + await crewPage.getByRole('button', { name: 'Reset-Link anfordern' }).click(); + await expect(crewPage.getByText('Anfrage verarbeitet.')).toBeVisible(); + await crewPage.goto(latestMailLink('Sendewerk-Passwort zurücksetzen')); + await expect(crewPage).toHaveURL(/\/passwort-zuruecksetzen$/); + await crewPage.getByLabel('Neues Passwort').fill('Neues-E2E-Crew-Passwort-1234'); + await crewPage.getByRole('button', { name: 'Passwort speichern' }).click(); + await login(crewPage, crewEmail, 'Neues-E2E-Crew-Passwort-1234'); + await crewContext.close(); + + const anonymousContext = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const loginPage = await anonymousContext.newPage(); + await loginPage.goto('/login'); + await setTheme(loginPage, 'dark'); + await loginPage.reload(); + await expectNoHorizontalOverflow(loginPage); + await loginPage.screenshot({ + path: testInfo.outputPath('login-mobile-dark.png'), + fullPage: true + }); + await anonymousContext.close(); +}); + test('desktop fundus is a compact, filterable list with persistent themes', async ({ page }, testInfo) => { diff --git a/vite.config.ts b/vite.config.ts index befc935..41790fb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,10 @@ export default defineConfig({ runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true }, - adapter: adapter() + adapter: adapter(), + paths: { + origin: process.env.SENDEWERK_APP_ORIGIN + } }) ], test: {