Compare commits

...

70 Commits

Author SHA1 Message Date
elpatron 79a54fdfc2 chore: release v0.1.0.43 2026-05-30 20:56:13 +02:00
elpatron e73c078463 fix(seo): replaceState nur bei abweichendem lng und Manifest auf Deutsch
Vermeidet unnötige History-Änderungen beim Seitenaufbau und stellt die
PWA-Beschreibung konsistent zu lang: 'de' bereit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:55:14 +02:00
elpatron 2eb6551200 chore: release v0.1.0.42 2026-05-30 20:53:29 +02:00
elpatron 9baaccf239 feat(settings): Warnhinweis zum privaten Teilen des Logbuch-Links
Nutzer sollen den Share-Link nur privat teilen, nicht in sozialen Medien.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:52:57 +02:00
elpatron df53420f3b feat(seo): Zweisprachige Meta-Tags und hreflang für DE/EN
SEO-Texte in i18n, dynamische Meta-Updates beim Sprachwechsel, hreflang-Links und ?lng-Parameter; PWA-Manifest zweisprachig.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:52:39 +02:00
elpatron 5271ed90c1 fix(marketing): Union-Jack-ClipPath im Beta-Flyer wiederherstellen
Der vorherige Fix hatte den SVG-Pfad durch fragmentierte Subpaths ersetzt; korrekter Pfad und PDF neu generiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:48:20 +02:00
elpatron a8ba998444 fix(marketing): Ungültigen Union-Jack-ClipPath im Beta-Flyer korrigieren
Der SVG-Pfad für die roten Diagonalen war syntaktisch fehlerhaft; PDF neu generiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:44:40 +02:00
elpatron 67d169080e docs(marketing): Landesflaggen für DE/EN im Beta-Flyer
Deutsch- und Englisch-Hinweis mit Inline-SVG-Flaggen und aktualisiertem PDF.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 20:39:44 +02:00
elpatron c67c1425df chore: release v0.1.0.41 2026-05-30 19:32:37 +02:00
elpatron d231a7fb40 feat(logs): Maschinenstunden pro Reisetag und Verbrauch pro Stunde
Maschinenstunden sind im Journal erfassbar; der Kraftstoffverbrauch pro Maschinenstunde wird aus Tagesverbrauch und Maschinenstunden berechnet und in Journal sowie Statistik als Read-only angezeigt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:29:38 +02:00
elpatron 4acb9b1290 fix(logs): Crew-Unterschrift mit Benutzerzuordnung und Owner-Crew-Signatur
Klassische Crew-Signaturen speichern Unterzeichner und Datum; Export und UI zeigen die Zuordnung. Eigner ohne WRITE-Collaborators dürfen wieder als Crew per Passkey signieren.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:24:46 +02:00
elpatron 4484724d38 fix(logs): Skipper- und Crew-Unterschrift rollenbasiert trennen
Jede Rolle darf nur das eigene Signaturfeld bearbeiten; Passkey-Freigabe auf dem Server entsprechend eingeschränkt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:21:51 +02:00
elpatron 5ea5111ec3 fix(auth): Schiffsdaten und Skipper-Profil nur für Logbuch-Eigner
Eingeladene Crew (WRITE) sieht Schiffsdaten und Skipper-Profil schreibgeschützt; Server-Sync lehnt entsprechende Änderungen ab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:17:45 +02:00
elpatron 7ab0ec6061 fix(logs): Ereignis-Bearbeitung sichern und Warnung bei ungespeicherten Änderungen
Normalisiert partielle Logbuch-Events beim Speichern (z. B. Besegelung) und warnt beim Verlassen von Editor, Tabs und Browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:15:49 +02:00
elpatron 258fee31ab fix(logs): Ereignisprotokoll chronologisch nach Uhrzeit sortieren
Einträge werden beim Laden, Speichern und Export älteste-oben angezeigt (sortLogEventsByTime).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:05:46 +02:00
elpatron 2e83f1c6bb fix(logs): Galerie-Upload für Foto-Anhänge auf Mobilgeräten ermöglichen
Entfernt capture="environment", damit Nutzer neben der Kamera auch Bilder aus der Gerätegalerie wählen können.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 18:59:48 +02:00
elpatron fcb76d1305 docs(marketing): Update beta flyer layout and content
Modified the screenshot layout to a three-column grid with adjusted gaps, reduced screenshot height for better fit, and refined feature descriptions for clarity. Added a new screenshot for vessel data. Updated the corresponding PDF to reflect these changes.
2026-05-30 18:22:09 +02:00
elpatron 7d96bbcfd8 docs(marketing): Beta-Flyer mit App-Screenshots und größerer Typografie
Zwei Screenshots nebeneinander, Schriftgrößen für bessere Seitennutzung auf A4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 17:22:52 +02:00
elpatron a586fcbfba fix(ui): autocomplete und Formulare für Passwort-Felder
PIN, Backup-Export/Import und API-Key entsprechen Chrome-DOM-Empfehlungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 16:36:38 +02:00
elpatron 0ed9ac6941 chore: release v0.1.0.40 2026-05-30 16:31:10 +02:00
elpatron b4fff04ee1 docs(marketing): Beta-Flyer-PDF neu generieren
Aktualisierte PDF-Version aus dem überarbeiteten HTML-Flyer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 16:30:55 +02:00
elpatron 7e01106801 fix(ui): Mobile-Layout für Tour, Header, Toolbars und Dialoge
Onboarding-Tooltip bleibt im Viewport; PWA-Banner während Tour aus.
Kopfzeilen, Listen-Toolbars, Link-Zeilen und Modals für iPhone optimiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 16:30:33 +02:00
elpatron caf6e395cd docs(marketing): Revise beta flyer feature descriptions for clarity and detail
Updated the feature descriptions to enhance clarity, including separating passwordless login and end-to-end encryption, and specifying GPS track upload with map representation. Added new feature for photo attachments for skipper and crew. Updated the corresponding PDF to reflect these changes.
2026-05-30 15:17:43 +02:00
elpatron a67575f4d2 chore: release v0.1.0.39 2026-05-30 15:10:26 +02:00
elpatron c2d620025e feat(ui): Beta-Badge in Login-, Dashboard- und Logbuch-Titelzeile
Wiederverwendbare BetaBadge-Komponente mit i18n-Tooltip.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 15:10:11 +02:00
elpatron 1524321afd docs(marketing): Update beta flyer feature description for passwordless login
Revised the feature description to specify "Passwortlose Passkey-Anmeldung" instead of "Passkey-Anmeldung" for clarity. Updated the corresponding PDF to reflect this change.
2026-05-30 14:58:40 +02:00
elpatron ab8a188fa0 chore: release v0.1.0.38 2026-05-30 14:49:49 +02:00
elpatron bb98af040e feat(analytics): Plausible-Events für öffentliche Logbuch-Freigabe
Trackt Aktivierung des Freigabelinks und erfolgreiches Öffnen unter /share.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:49:19 +02:00
elpatron 333c36db21 docs(marketing): Correct typo in beta flyer feature description
Updated the description in the beta flyer to correct the phrase "Crew und Schiffsdaten" to "Crew- und Schiffsdaten" for grammatical accuracy.
2026-05-30 14:36:31 +02:00
elpatron 3bd1970c59 docs(marketing): Update GPS feature description in beta flyer
Revised the GPS feature description from "GPS-Tracks" to "GPS-Track Upload" for improved clarity. Updated the corresponding PDF to reflect this change.
2026-05-30 14:35:58 +02:00
elpatron 75c1369c75 docs(marketing): Split PDF & CSV export and encryption features for clarity
Updated the beta flyer to separate the PDF & CSV export feature from the encrypted backup and recovery feature, enhancing clarity in the feature list.
2026-05-30 14:35:07 +02:00
elpatron 9ce1e384b7 docs(marketing): Update beta flyer feature list for improved detail
Enhanced the feature description to include 'Crew' in the nautical logbook format. Updated the PDF to reflect these changes.
2026-05-30 14:32:28 +02:00
elpatron 3eee42a30c docs(marketing): Beta-Flyer Feature-Liste erweitern
Neue Punkte für Teilen, mehrere Logbücher, Sprachen und Kiel-Herkunft;
PDF neu erzeugt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:31:52 +02:00
elpatron 90ffff0da6 fix(beta-flyer): update feature descriptions for clarity and accuracy
Revised the descriptions of offline capabilities and encryption methods in the beta flyer to enhance clarity. The PWA is now described as functioning on any smartphone and tablet, and the encryption method is specified as end-to-end.
2026-05-30 14:23:58 +02:00
elpatron 5c815caf8a chore: release v0.1.0.37 2026-05-30 14:21:30 +02:00
elpatron c3836eb07d fix(invitation): Fehlertexte bei Sprachwechsel und Beta-Flyer-Logo
Speichert Einladungsfehler als i18n-Keys statt übersetzter Strings.
Beta-Flyer nutzt das Steuerrad-Logo (logo.png), PDF neu erzeugt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:21:03 +02:00
elpatron caf7d81ac9 i18n: Du-Ansprache und Einladungstexte in Locales auslagern
Deutsche UI-Texte und Beta-Flyer auf informelles Deutsch umstellen;
hardcodierte Strings aus InvitationAcceptance in de/en.json verschieben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:18:47 +02:00
elpatron 8bcfb97e98 chore: release v0.1.0.36 2026-05-30 14:11:20 +02:00
elpatron b9ccb0dfb6 fix(client): Read-only-UI nur bei bestätigter READ-Rolle
Während des Ladens geteilter Logbücher wird Schreibzugriff nicht mehr
fälschlich gesperrt; der Zugriffs-Effect setzt bei fehlendem Record kein OWNER mehr.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:09:54 +02:00
elpatron d98e2e8dc0 feat(feedback): Rate-Limit und Spam-Erkennung für Feedback-Formular
Schützt den Feedback-Endpunkt vor Missbrauch durch pro-Nutzer-Limits, Honeypot, Zeitprüfung und einfache Inhaltsheuristiken.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:09:43 +02:00
elpatron f5f12f50f5 fix(sync): READ-Zugriff darf Sync-Queue nicht als erfolgreich leeren
Bei READ-only oder unbekannter Rolle gibt pushChanges false zurück, solange
noch Einträge in der Queue sind, damit lokale Änderungen nicht verloren gehen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:04:43 +02:00
elpatron 1437b75c2f feat(client): Onboarding-Tour um Statistik und Feedback erweitern
Neue Tour-Schritte für Statistik-Dashboard und Feedback-Formular, Hinweis
zum Löschen der Demo-Einträge und Landung auf Statistik nach Abschluss.
Rollenauflösung bei geteilten Logbüchern fail-closed bis die Rolle bekannt ist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:03:46 +02:00
elpatron 7d75e74679 fix: CORS-Origins, Sync-Body-Limit und geteilte Logbuch-Rolle
Erlaubt mehrere/normalisierte CORS-Origins mit Dev-Fallbacks für Session-Cookies,
stellt express.json wieder auf 50mb für große Sync-Payloads und setzt die
Zugriffsrolle beim Wechsel in geteilte Logbücher ohne Cache korrekt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:59:15 +02:00
elpatron 0276d8445e fix(client): Vite 6 statt Vite 8 wegen fehlender Rolldown-Bindings
Vite 8 benötigt native @rolldown-Bindings, die npm oft nicht installiert.
Downgrade auf Vite 6 mit plugin-react 4 behebt den Dev-Server-Absturz;
start-dev.sh prüft client/node_modules vor dem Start.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:54:30 +02:00
elpatron dea33e3f00 feat(security): Session-Cookies statt X-User-Id und API-Härtung
Ersetzt die spoofbare X-User-Id-Auth durch signierte HttpOnly-Sessions nach
WebAuthn, erzwingt WRITE-only Sync, speichert den Master-Key nur im RAM und
ergänzt CORS, Rate-Limits, Helmet sowie Passkey-Reauth für sensible Aktionen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:47:24 +02:00
elpatron 4f3f530f1f chore: release v0.1.0.35 2026-05-30 13:29:55 +02:00
elpatron 858d5d1d25 feat(feedback): optionales E-Mail-Kontaktfeld im Formular
Nutzer können optional eine E-Mail hinterlassen; Validierung client-/serverseitig, Weitergabe in Ntfy-Benachrichtigungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:24:43 +02:00
elpatron c914156d70 fix(feedback): Erfolgsstatus inline anzeigen und Modal auto-schließen
Erfolgsmeldung erscheint im Formular statt hinter dem Modal; Schließen-Button oben rechts; Fehler inline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:23:03 +02:00
elpatron 8bf89ed898 chore: release v0.1.0.34 2026-05-30 13:17:53 +02:00
elpatron adf8ee9929 fix(feedback): Ntfy in Docker, ASCII-Titel und Skipper-Badge
NTFY_* an den Backend-Container durchreichen; En-Dash im Ntfy-Header durch ASCII-Strich ersetzen (ByteString-Fehler). Skipper-Badge klar als Account-Anzeige kennzeichnen; start-dev.sh prüft npm vor dem Start.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:16:59 +02:00
elpatron 1055a12dad docs(marketing): Beta-Flyer (DIN A4) mit Playwright-Generierung
Skript und npm-Script zum Erzeugen des druckbaren Beta-Flyers als PDF.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:58:28 +02:00
elpatron f1f90da069 feat(feedback): Feedback-Formular mit Ntfy-Versand
Nutzer können Feedback aus dem Header senden; der Server leitet Nachrichten über Ntfy weiter (NTFY_* in .env).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:58:25 +02:00
elpatron 4541c81d3b chore: release v0.1.0.33 2026-05-30 12:39:29 +02:00
elpatron 03bb55f9a1 feat(weather): OWM-Fallback über Server-.env wenn kein User-Key
Wetter-Proxy auf /api/weather/current nutzt optionalen Nutzer-Key aus
den Einstellungen, sonst OpenWeatherMapAPIKey aus der Umgebung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:37:58 +02:00
elpatron 69d5203305 chore: release v0.1.0.32 2026-05-30 12:20:05 +02:00
elpatron e8f9381c5f fix(docker): VAPID-Umgebungsvariablen an Backend durchreichen
Web Push benötigt VAPID_* aus der Host-.env im Backend-Container.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:19:50 +02:00
elpatron 442ddccceb feat(analytics): Plausible-Event für Footer-Link-Klick
Trackt „Footer Link Clicked“ beim Klick auf den Autoren-Link im App-Footer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:54:30 +02:00
elpatron f47413999c docs(seo): kostenlos und werbefrei in Meta-Tags ergänzen
Title, Description, Keywords, OG/Twitter sowie README und PWA-Manifest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:50:32 +02:00
elpatron f23f0db70b chore: release v0.1.0.31 2026-05-30 11:46:26 +02:00
elpatron ece0abccbf docs: README um Web-Push-Dokumentation ergänzen
Beschreibt Opt-in, VAPID, iOS-PWA, Projektstruktur und Deployment-Hinweise.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:46:19 +02:00
elpatron 92e9020212 feat: Unterstützung für benutzerdefinierte Service Worker und Opt-in für Web Push-Benachrichtigungen
Ermöglicht es Logbuch-Eignern, benutzerdefinierte Service Worker zu verwenden und Web Push-Benachrichtigungen für Änderungen von Collaborators zu aktivieren, mit einem Opt-in in den Einstellungen.
2026-05-30 11:40:57 +02:00
elpatron 2428313a22 feat: Web Push für Logbuch-Eigner bei Crew-Sync
Benachrichtigt Owner optional per VAPID/Web Push, wenn Collaborators
Änderungen synchronisieren — ohne Klartext-Inhalte, mit Opt-in in den
Einstellungen, Custom Service Worker und Deep-Link zum Logbuch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:36:03 +02:00
elpatron 0e61bc5dad chore: release v0.1.0.30 2026-05-30 11:14:21 +02:00
elpatron 585ef788df fix: Rückgabetyp von fingerprintSignature für Passkey-Signaturen korrigieren
Behebt den TypeScript-Buildfehler, da Passkey-Signaturen Objekte und keine Strings sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:14:15 +02:00
elpatron 9aabb2729d chore: release v0.1.0.29 2026-05-30 11:12:37 +02:00
elpatron ebe4199b8b fix: Footer-Copyright und Signatur-Fingerprint vereinheitlichen
Footer zeigt KnorrLabs/Markus F.J. Busche mit Mailto nur am Namen. Signatur-Normalisierung ist für Persistenz und isDirty-Check konsistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:12:26 +02:00
elpatron 10f01f1ffc chore: release v0.1.0.28 2026-05-30 11:08:18 +02:00
elpatron 29765d172e feat: Ereignisse sofort speichern und Save-Button bei Änderungen aktivieren
Ereignisprotokoll-Einträge werden direkt persistiert, ohne vorher die Logbuchseite zu speichern. Der Speichern-Button ist nur aktiv, wenn noch ungespeicherte Änderungen vorliegen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:07:54 +02:00
elpatron 5f9e83dbdd chore: release v0.1.0.27 2026-05-30 10:56:53 +02:00
elpatron aa2b35ddac feat: Ereignisprotokoll bearbeiten und Skipper-Signatur invalidieren
Bestehende Ereigniszeilen lassen sich nachträglich ändern; beim Speichern
oder Löschen wird nur die Skipper-Unterschrift entfernt, die Crew-Signatur bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:56:45 +02:00
86 changed files with 6682 additions and 1477 deletions
+182
View File
@@ -0,0 +1,182 @@
---
name: merge
description: >-
Merge Git branches safely — fetch latest, merge or rebase onto master, resolve
conflicts intelligently, and verify the result. Use when the user asks to merge
branches, sync with master, resolve merge conflicts, or bring a feature branch
up to date.
---
# Git Merge
Führe Branch-Merges sicher und nachvollziehbar aus. Für PR-Review, CI und
Comment-Triage siehe den **babysit**-Skill — dieser Skill deckt die Git-Merge-
Operation selbst ab.
## Projekt-Kontext
- **Basis-Branch:** `master` (nicht `main`)
- **Monorepo:** `client/` (React PWA) und `server/` (Express API) — Konflikte
können in beiden liegen
## Sicherheitsregeln (immer einhalten)
- **Niemals** `git config` ändern
- **Niemals** `--no-verify`, `--no-gpg-sign` o.ä. ohne explizite Anfrage
- **Niemals** `push --force` auf `master` — bei Bedarf warnen und abbrechen
- **Niemals** destruktive Befehle (`reset --hard`, `clean -fd`) ohne explizite Anfrage
- **Niemals** interaktive Git-Befehle (`-i`-Flags) — nicht unterstützt
- **Kein Commit** ohne explizite Anfrage des Users
- **Kein Push** ohne explizite Anfrage des Users
## Workflow
### 1. Ausgangslage klären
Parallel ausführen:
```bash
git status
git branch -vv
git log --oneline -5
```
Ermittle:
- Aktueller Branch
- Ziel-Branch (Standard: `master`)
- Ob uncommittete Änderungen vorliegen
- Ob der Branch einen Remote-Tracking-Branch hat
**Bei uncommitteten Änderungen:** Stashen (`git stash push -m "pre-merge"`) nur
mit Zustimmung oder wenn der User es verlangt hat. Sonst stoppen und melden.
### 2. Merge-Strategie wählen
| Situation | Empfehlung |
|-----------|------------|
| Feature-Branch aktuell halten | `git merge origin/master` (Merge-Commit) |
| Linearer Verlauf gewünscht | `git rebase origin/master` (nur wenn User Rebase verlangt) |
| Zwei Feature-Branches zusammenführen | `git merge <branch>` auf Ziel-Branch |
**Standard:** Merge (nicht Rebase), es sei denn der User verlangt Rebase.
### 3. Remote aktualisieren
```bash
git fetch origin
```
Vor dem Merge prüfen, wie weit der Branch hinter `origin/master` liegt:
```bash
git log --oneline HEAD..origin/master
git log --oneline origin/master..HEAD
```
### 4. Merge ausführen
**Feature-Branch mit master synchronisieren** (häuigster Fall):
```bash
git checkout <feature-branch>
git merge origin/master
```
**Branch in master mergen** (nur wenn User das ausdrücklich will — normalerweise
passiert das via PR):
```bash
git checkout master
git pull origin master
git merge <feature-branch>
```
Merge-Commit-Nachricht kurz und sachlich halten, z.B.:
`Merge branch 'master' into feature/push-notifications-owner`
### 5. Konflikte lösen
Konfliktdateien finden:
```bash
git diff --name-only --diff-filter=U
```
**Pro Konfliktdatei:**
1. Datei lesen und beide Seiten verstehen (HEAD = eigener Branch, incoming = gemergter Branch)
2. Intent beider Änderungen erhalten — nicht blind eine Seite wählen
3. Konfliktmarker entfernen (`<<<<<<<`, `=======`, `>>>>>>>`)
4. Bei widersprüchlicher Intent: Merge abbrechen und User fragen
```bash
git merge --abort # oder: git rebase --abort
```
**Typische Konflikt-Muster in diesem Projekt:**
| Bereich | Hinweis |
|---------|---------|
| `package-lock.json` | Nach manueller Lösung `npm install` im betroffenen Paket (`client/` oder `server/`) ausführen |
| i18n (`client/src/i18n/`) | Beide Sprachkeys (DE + EN) behalten, keine Keys verlieren |
| Prisma/Schema | Migrationen beider Seiten zusammenführen, nicht überschreiben |
| Verschlüsselung/Auth | Vorsichtig — keine Sicherheitslogik stillschweigend vereinfachen |
Nach jeder gelösten Datei:
```bash
git add <file>
```
Merge abschließen (nur wenn User Commit verlangt hat):
```bash
git commit -m "$(cat <<'EOF'
Merge branch 'master' into <feature-branch>
EOF
)"
```
### 6. Verifizieren
Nach erfolgreichem Merge:
```bash
git status
git log --oneline -5
```
Relevante Checks je nach betroffenen Bereichen:
```bash
# Client
cd client && npm run build
# Server
cd server && npm run build
```
Bei Lockfile-Konflikten oder Dependency-Änderungen: Build in beiden Paketen prüfen.
### 7. Abschluss
- Ergebnis dem User mitteilen: welche Branches, wie viele Konflikte, was gelöst wurde
- Bei `git stash`: erinnern, Stash wieder anzuwenden (`git stash pop`)
- Push nur auf explizite Anfrage: `git push origin <branch>`
## Wann abbrechen und fragen
- Widersprüchliche fachliche Intent (z.B. beide Seiten ändern dieselbe Logik unterschiedlich)
- Konflikte in Krypto-, Auth- oder Sync-Kernlogik ohne klares „richtig“
- Merge würde `.env`, Credentials oder Secrets einschließen
- User wollte nur Status prüfen, nicht tatsächlich mergen
## Abgrenzung zu anderen Skills
| Skill | Wann |
|-------|------|
| **merge** (dieser) | Git merge/rebase, Konflikte, Branch sync |
| **babysit** | PR merge-ready: Comments, CI, PR-Konflikte im PR-Kontext |
| **creating-pull-requests** | PR erstellen und pushen |
+20 -1
View File
@@ -4,4 +4,23 @@ OpenWeatherMapAPIKey=<owm_api_key>
# For local dev: localhost and http://localhost
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
RP_ID=localhost
ORIGIN=http://localhost
# Must match the frontend URL (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 127.0.0.1:5173)
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# API session signing (min. 32 chars; required in production)
# Generate: openssl rand -base64 48
SESSION_SECRET=
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
# Feedback via Ntfy (https://ntfy.sh or self-hosted)
# NTFY_TOPIC: topic name only (not the full URL)
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-feedback
NTFY_TOKEN=tk_example_ntfy_access_token
@@ -168,7 +168,7 @@ Beim Laden eines Eintrags: `computedHash !== sig.entryHash` → UI-Warnung.
Neuer Router: `server/src/routes/sign.ts` → Mount unter `/api/sign`
Auth wie bestehend: Header `X-User-Id` (siehe `sync.ts`).
Auth wie bestehend: HttpOnly-Session-Cookie `daagbok_session` nach WebAuthn (`server/src/middleware/auth.ts`, Client `apiFetch` mit `credentials: 'include'`).
### 4.1 `POST /api/sign/options`
@@ -472,7 +472,7 @@ test('isSignatureValidForEntry')
| WebAuthn Login | `client/src/services/auth.ts`, `server/src/routes/auth.ts` |
| Collaborators | `server/src/routes/collaboration.ts`, `SettingsForm.tsx` |
| E2E-Einträge | `EntryPayload` in `server/prisma/schema.prisma` |
| Auth-Header | `X-User-Id` in `server/src/routes/sync.ts` |
| API-Auth | Session-Cookie via `requireUser` in `server/src/middleware/auth.ts` |
---
+66 -10
View File
@@ -1,6 +1,6 @@
# Kapteins Daagbok
Digitales Yacht-Logbuch als Progressive Web App (PWA) — offline-fähig, End-to-End-verschlüsselt und mit Passkey-Anmeldung.
Digitales Yacht-Logbuch als Progressive Web App (PWA) — **kostenlos**, **werbefrei**, offline-fähig, End-to-End-verschlüsselt und mit Passkey-Anmeldung.
**Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
@@ -21,6 +21,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
- **Schiffsdaten** und **Crew-Profile** (Skipper + Mitglieder)
- **Statistik-Dashboard** — Strecken, Verbrauch, Segel/Motor, Hafenkette (pro Logbuch oder accountweit)
- **Kollaboration** — Crew per Einladungslink einladen (Schreib- oder Lesezugriff)
- **Push-Benachrichtigungen** (optional) — Logbuch-Eigner per Web Push informieren, wenn Crew Änderungen synchronisiert (ohne Klartext-Inhalte; Opt-in in den Einstellungen)
- **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte
- **Export** — PDF pro Reisetag, CSV-Download/-Teilen
- **Backup & Wiederherstellung** — vollständiges verschlüsseltes Logbuch-Backup (Einträge, Fotos, GPS, Crew, Schiff) als `.daagbok.json`; Restore auf gleichem oder neuem Account
@@ -44,19 +45,32 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
| Lokaler Speicher | Dexie.js (IndexedDB), Hintergrund-Sync |
| Backend | Node.js, Express, Prisma |
| Datenbank | PostgreSQL 16 |
| Auth | WebAuthn (Passkeys) via `@simplewebauthn` |
| Auth | WebAuthn (Passkeys) + signiertes HttpOnly-Session-Cookie (`daagbok_session`) |
| Krypto | Web Crypto API (AES-GCM), BIP39 Recovery |
| Push (optional) | Web Push (VAPID), Custom Service Worker (`injectManifest`) |
### Rollen & Zugriff
| Rolle | Bedeutung |
|-------|-----------|
| **Owner** | Logbuch angelegt; voller Zugriff, Einladungen, Backup, Löschen |
| **Owner** | Logbuch angelegt; voller Zugriff, Einladungen, Backup, Löschen; optional Push bei Crew-Änderungen |
| **Collaborator (WRITE)** | Per Einladung; Einträge bearbeiten und als Crew signieren |
| **Collaborator (READ)** | Nur Lesen (z. B. öffentlicher Share-Link) |
Skipper- und Crew-Profile im Logbuch sind **Inhaltsdaten** (verschlüsselt), nicht an den Account gebunden. Ein Account kann gleichzeitig Owner eines eigenen und Collaborator in fremden Logbüchern sein.
### Authentifizierung & Session
| Schicht | Verhalten |
|---------|-----------|
| **Login** | WebAuthn (`/api/auth/login-verify`) — danach HttpOnly-Cookie, 7 Tage gültig |
| **API-Aufrufe** | Cookie `credentials: 'include'` (Client: `apiFetch`) — kein `X-User-Id` |
| **Master-Key** | Nur im RAM; nach Reload Entsperren per Passkey oder lokalem PIN |
| **Step-up** | Konto löschen, PRF-Enrollment: frische Passkey-Bestätigung (`/api/auth/reauth-*`) |
| **Sync WRITE** | Server lehnt Schreib-Sync für Collaborator mit `READ` ab |
Öffentliche Routen (ohne Session): Registrierung/Login-Optionen, Einladungsdetails, Read-only-Share (`share-pull`), Health-Check, VAPID-Public-Key.
## Backup & Wiederherstellung
Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
@@ -67,6 +81,27 @@ Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstel
Vor dem Löschen eines Logbuchs weist die App auf diese Funktion hin. Crew-Einladungen und Passkey-Signaturen werden nicht mitübertragen — Inhalte bleiben lesbar, Signaturen auf neuem Account ggf. nicht mehr verifizierbar.
## Push-Benachrichtigungen (optional)
Logbuch-**Eigner** können unter **Einstellungen** Web Push aktivieren. Sobald ein eingeladenes Crewmitglied mit Schreibrechten (`WRITE`) Änderungen synchronisiert, erhält der Owner eine **generische** Benachrichtigung — der Server kennt keine Logbuch-Inhalte (Zero-Knowledge).
| Aspekt | Verhalten |
|--------|-----------|
| Auslöser | Erfolgreicher Sync-Push durch Collaborator (`create`/`update`) |
| Aggregation | Mehrere Änderungen in einem Sync → eine Benachrichtigung pro Logbuch |
| Drosselung | Max. eine Push-Nachricht pro Logbuch alle 3 Minuten |
| Klick | Öffnet die App auf dem betroffenen Logbuch |
**Voraussetzungen:**
- HTTPS (Produktion)
- VAPID-Schlüssel auf dem Server (`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`)
- Browser-Berechtigung „Benachrichtigungen“; auf **iOS** installierte PWA ab iOS 16.4+
Schlüssel erzeugen: `npx web-push generate-vapid-keys` (im `server/`-Verzeichnis oder global).
Ausführlicher Implementierungs- und Testplan: [docs/push-notifications-plan.md](docs/push-notifications-plan.md).
## Projektstruktur
```
@@ -74,13 +109,15 @@ kapteins-daagbok/
├── client/ # React-PWA (Frontend)
│ ├── src/
│ │ ├── components/ # UI-Komponenten
│ │ ├── services/ # Auth, Sync, Krypto, Backup, Analytics, …
│ │ ├── services/ # Auth, Sync, Krypto, Backup, Push, Analytics, …
│ │ ├── sw.ts # Service Worker (Precache + Web Push)
│ │ └── i18n/ # DE/EN-Übersetzungen
│ └── Dockerfile # Nginx-Produktions-Image
├── server/ # Express-API + Prisma
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign, push
│ ├── src/services/ # z. B. pushNotify (Web Push)
│ └── prisma/ # Datenbankschema
├── docs/ # Projektdokumentation (z. B. Plausible Events)
├── docs/ # Projektdokumentation
├── scripts/ # Dev- und Deploy-Skripte
├── docker-compose.yml # Produktions-Stack (DB + Backend + Frontend)
└── VERSION # App-Version (Build & Footer)
@@ -91,7 +128,8 @@ kapteins-daagbok/
- **Node.js** 20+
- **npm**
- **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack)
- Optional: OpenWeatherMap-API-Key (Wetter-Abruf in den Einstellungen)
- Optional: eigener OpenWeatherMap-API-Key in den Einstellungen (sonst serverseitiger Key aus `.env`)
- Optional: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen)
## Lokale Entwicklung
@@ -108,16 +146,30 @@ cd client && npm ci && cd ..
cp .env.example .env
```
Für lokale Passkeys: `RP_ID=localhost`, `ORIGIN=http://localhost:5173` (bzw. die tatsächliche Frontend-URL).
Kopiere `.env.example` nach `.env` und passe mindestens an:
Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen, z. B.:
| Variable | Dev (Vite) | Produktion |
|----------|------------|------------|
| `RP_ID` | `localhost` | `kapteins-daagbok.eu` |
| `ORIGIN` | `http://localhost:5173` | `https://kapteins-daagbok.eu` |
| `SESSION_SECRET` | empfohlen (≥ 32 Zeichen) | **Pflicht** |
`ORIGIN` muss **exakt** der Frontend-URL entsprechen (CORS + Session-Cookie). Das Backend lädt `.env` aus dem Projektroot und optional `server/.env`.
```
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public"
OpenWeatherMapAPIKey= # Fallback für Wetter-Abruf, wenn Nutzer keinen eigenen Key hat
RP_ID=localhost
ORIGIN=http://localhost:5173
SESSION_SECRET= # openssl rand -base64 48 (in Prod Pflicht)
# Optional — Web Push (npx web-push generate-vapid-keys)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
```
`./scripts/start-dev.sh` prüft `ORIGIN` und `SESSION_SECRET` beim Start und gibt Hinweise aus.
### 3. Datenbank & Schema
Das Dev-Skript startet PostgreSQL in Docker (`postgres-daagbox`). Schema anwenden:
@@ -148,7 +200,7 @@ Gesamten Stack lokal bauen und starten:
Frontend: http://localhost · API: http://localhost/api/health
Umgebungsvariablen für Passkeys in `.env` setzen (`RP_ID`, `ORIGIN`).
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`).
## Deployment
@@ -160,11 +212,15 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
## Dokumentation
| Dokument | Inhalt |
|----------|--------|
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
| [docs/marketing/kapteins-daagbok-beta-flyer.pdf](docs/marketing/kapteins-daagbok-beta-flyer.pdf) | Beta-Flyer (DIN A4) zum Ausdrucken — Quelle: `docs/marketing/beta-flyer.html`, neu erzeugen: `cd client && npm run generate:flyer` |
| [.planning/PROJECT.md](.planning/PROJECT.md) | Produktvision und Anforderungen (GSD) |
## Analytics
+1 -1
View File
@@ -1 +1 @@
0.1.0.27
0.1.0.44
+10 -7
View File
@@ -4,12 +4,15 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren auch offline als PWA." />
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch" />
<meta name="description" content="Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren auch offline als PWA." />
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung, yacht logbook, sailing log, ad-free" />
<meta name="author" content="Markus F.J. Busche" />
<meta name="robots" content="index, follow" />
<meta name="application-name" content="Kapteins Daagbok" />
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
<link rel="alternate" hreflang="de" href="https://kapteins-daagbok.eu/?lng=de" />
<link rel="alternate" hreflang="en" href="https://kapteins-daagbok.eu/?lng=en" />
<link rel="alternate" hreflang="x-default" href="https://kapteins-daagbok.eu/" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
@@ -18,20 +21,20 @@
<link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Kapteins Daagbok" />
<meta property="og:title" content="Kapteins Daagbok Digitales Yacht-Logbuch" />
<meta property="og:description" content="Sicheres, E2E-verschlüsseltes Logbuch für Skipper: Reisetage, GPS-Tracks, Crew- und Schiffsdaten mit Passkey-Anmeldung und Offline-PWA." />
<meta property="og:title" content="Kapteins Daagbok Kostenloses digitales Yacht-Logbuch" />
<meta property="og:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." />
<meta property="og:url" content="https://kapteins-daagbok.eu/" />
<meta property="og:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta property="og:image:alt" content="Kapteins Daagbok Logo" />
<meta property="og:locale" content="de_DE" />
<meta property="og:locale:alternate" content="en_US" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Kapteins Daagbok Digitales Yacht-Logbuch" />
<meta name="twitter:description" content="Sicheres, E2E-verschlüsseltes Logbuch für Skipper: Reisetage, GPS-Tracks, Crew- und Schiffsdaten mit Passkey-Anmeldung und Offline-PWA." />
<meta name="twitter:title" content="Kapteins Daagbok Kostenloses digitales Yacht-Logbuch" />
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." />
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
<title>Kapteins Daagbok Digitales Yacht-Logbuch</title>
<title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
</head>
<body>
<div id="root"></div>
+963 -670
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
"generate:flyer:setup": "playwright install chromium"
},
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
@@ -29,17 +31,19 @@
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vite-plugin-pwa": "^1.3.0"
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.1"
},
"optionalDependencies": {
"@rolldown/binding-linux-x64-gnu": "^1.0.2"
"engines": {
"node": ">=20.0.0"
}
}
+488 -5
View File
@@ -63,6 +63,16 @@ body {
margin-bottom: 15px;
}
.auth-brand-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 14px;
}
.auth-brand-title-row h1,
.auth-brand h1 {
font-size: 32px;
font-weight: 700;
@@ -71,7 +81,7 @@ body {
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0 0 14px 0;
margin: 0;
line-height: 1.25;
letter-spacing: -0.5px;
}
@@ -388,6 +398,116 @@ html.scheme-dark .themed-select-option.is-selected {
max-height: min(90vh, 820px);
}
.feedback-modal {
position: relative;
}
.feedback-modal__close {
top: 12px;
right: 12px;
z-index: 1;
}
.feedback-status {
text-align: center;
padding: 8px 0 4px;
}
.feedback-status p {
margin: 12px 0 0;
font-size: 15px;
line-height: 1.5;
}
.feedback-status--success {
color: #4ade80;
padding: 24px 8px 32px;
}
.feedback-status--success p {
color: var(--app-text-heading, #f1f5f9);
}
.feedback-status--error {
color: var(--app-error-text, #fda4af);
background: var(--app-error-bg, rgba(244, 63, 94, 0.08));
border: 1px solid var(--app-error-border, #f43f5e);
border-radius: 8px;
margin-bottom: 16px;
padding: 10px 12px;
text-align: left;
}
.feedback-status--error p {
margin: 0;
font-size: 14px;
}
.feedback-modal .auth-actions {
margin-top: 0;
}
.feedback-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.feedback-form__honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.feedback-form__field {
display: flex;
flex-direction: column;
gap: 6px;
text-align: left;
}
.feedback-form__field > span {
font-size: 13px;
font-weight: 600;
color: var(--app-text-heading, #f1f5f9);
}
.feedback-form__field select,
.feedback-form__field input,
.feedback-form__field textarea {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--app-input-border, rgba(148, 163, 184, 0.25));
background: var(--app-input-bg, rgba(15, 23, 42, 0.6));
color: var(--app-text, #e2e8f0);
font: inherit;
resize: vertical;
}
.feedback-form__field select:focus,
.feedback-form__field input:focus,
.feedback-form__field textarea:focus {
outline: none;
border-color: var(--app-accent, #38bdf8);
}
.feedback-form__actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.feedback-form__actions .btn {
width: auto;
min-width: 100px;
margin: 0;
}
.registration-disclaimer__intro {
margin: 0 0 16px;
font-size: 14px;
@@ -619,9 +739,18 @@ html.scheme-dark .themed-select-option.is-selected {
font-size: 13px;
padding: 6px 12px;
border-radius: 20px;
background: var(--app-btn-secondary-bg);
border: 1px solid var(--app-btn-secondary-border);
color: var(--app-btn-secondary-text);
background: rgba(148, 163, 184, 0.08);
border: 1px solid rgba(148, 163, 184, 0.18);
color: var(--app-text-muted);
cursor: default;
user-select: none;
}
.skipper-badge__name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-icon {
@@ -776,6 +905,36 @@ html.scheme-dark .themed-select-option.is-selected {
color: var(--app-text-heading);
}
.section-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.copy-link-row {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
min-width: 0;
}
.copy-link-row .input-text {
flex: 1;
min-width: 0;
}
.form-actions--start {
justify-content: flex-start;
}
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.btn-refresh {
background: none;
border: none;
@@ -1430,6 +1589,18 @@ html.scheme-dark .themed-select-option.is-selected {
vertical-align: middle;
}
.events-actions-td {
white-space: nowrap;
}
.events-actions-td .btn-icon {
margin-left: 4px;
}
.events-actions-td .btn-icon:first-child {
margin-left: 0;
}
.events-table tbody tr:hover {
background: rgba(255, 255, 255, 0.02);
}
@@ -1504,6 +1675,224 @@ html.scheme-dark .themed-select-option.is-selected {
.hide-mobile {
display: none !important;
}
.dashboard-header,
.app-header {
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
}
.app-header-left {
flex: 1 1 100%;
min-width: 0;
align-items: flex-start;
gap: 10px;
}
.app-title-area {
min-width: 0;
flex: 1;
}
.app-title-area h2 {
font-size: 17px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.app-title-row {
gap: 6px;
}
.app-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-brand {
flex: 1 1 auto;
min-width: 0;
}
.header-brand h1 {
font-size: 20px;
}
.header-actions {
flex: 1 1 100%;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.conn-status > span:not(.pulse-dot) {
display: none;
}
.skipper-badge__name {
display: none;
}
.btn-back {
padding: 8px 10px;
flex-shrink: 0;
}
.section-title-bar {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.section-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.section-toolbar .btn {
flex: 1 1 auto;
min-width: 0;
}
.section-toolbar .btn.primary {
flex: 1 1 100%;
}
.section-title-left {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.section-title-left .form-header h2 {
font-size: 16px;
white-space: normal;
word-break: break-word;
}
.logbooks-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.logbook-card {
flex-wrap: wrap;
padding: 16px;
gap: 12px;
}
.card-meta {
flex-wrap: wrap;
}
.card-info h3 {
white-space: normal;
word-break: break-word;
}
.editor-header {
flex-wrap: wrap;
gap: 10px;
}
.crew-grid {
grid-template-columns: 1fr;
}
.copy-link-row {
flex-direction: column;
align-items: stretch;
}
.copy-link-row .btn {
width: 100%;
}
.form-actions--start {
flex-direction: column;
align-items: stretch;
}
.form-actions--start .btn {
width: 100%;
}
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-responsive table {
min-width: 480px;
}
.custom-dialog-overlay {
padding: max(16px, env(safe-area-inset-left)) max(16px, env(safe-area-inset-right));
align-items: flex-end;
}
.custom-dialog-card {
width: 100%;
max-width: none;
padding: 22px 18px;
margin-bottom: env(safe-area-inset-bottom, 0px);
}
.custom-dialog-actions {
flex-direction: column-reverse;
gap: 10px;
}
.custom-dialog-actions .btn {
width: 100%;
margin: 0 !important;
}
.disclaimer-modal-overlay {
padding: max(12px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-right));
align-items: flex-end;
}
.disclaimer-modal-panel,
.registration-disclaimer--modal {
width: 100%;
max-width: none;
}
.auth-card {
padding: 28px 20px;
max-width: calc(100vw - 24px);
}
.app-layout,
.dashboard-container {
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
}
.track-info-left {
flex-wrap: wrap;
}
.track-actions {
width: 100%;
}
.track-actions .btn {
flex: 1 1 calc(50% - 4px);
justify-content: center;
}
#openseamap-container,
.track-map-container {
height: min(360px, 45svh);
}
}
/* ========================================== */
@@ -2126,6 +2515,12 @@ html.theme-cupertino .events-scroll-container {
color: #94a3b8;
}
.track-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.track-error-msg {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
@@ -2265,6 +2660,13 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-text-muted);
}
.stats-section-subtitle {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--app-text-primary);
}
.stats-route-chain {
margin: 0;
font-size: 15px;
@@ -2367,6 +2769,14 @@ html.theme-cupertino .events-scroll-container {
background: linear-gradient(180deg, #38bdf8, #0284c7);
}
.stats-bar--motor-hours {
background: linear-gradient(180deg, #a78bfa, #7c3aed);
}
.stats-bar--fuel-per-hour {
background: linear-gradient(180deg, #fb923c, #ea580c);
}
.stats-bar-label {
margin-top: 8px;
font-size: 11px;
@@ -3034,10 +3444,14 @@ html.theme-cupertino .events-scroll-container {
.app-version-footer__copyright {
color: #94a3b8;
}
.app-version-footer__copyright a {
color: inherit;
text-decoration: none;
}
.app-version-footer__copyright:hover {
.app-version-footer__copyright a:hover {
color: #e2e8f0;
text-decoration: underline;
}
@@ -3056,6 +3470,28 @@ html.theme-cupertino .events-scroll-container {
border: 1px solid rgba(251, 191, 36, 0.25);
}
.beta-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--app-accent-light);
background: var(--app-accent-bg);
border: 1px solid var(--app-accent-focus-ring);
flex-shrink: 0;
}
.header-brand-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.role-badge {
display: inline-flex;
align-items: center;
@@ -3185,7 +3621,9 @@ body.app-tour-active .app-tour-target-active {
.app-tour-tooltip {
position: fixed;
z-index: 10002;
box-sizing: border-box;
width: min(420px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
padding: 20px 20px 16px;
border-radius: 16px;
background: #1e293b;
@@ -3194,10 +3632,19 @@ body.app-tour-active .app-tour-target-active {
pointer-events: auto;
}
.app-tour-tooltip:not(.centered) {
left: max(16px, env(safe-area-inset-left, 0px));
right: max(16px, env(safe-area-inset-right, 0px));
width: auto;
max-width: none;
}
.app-tour-tooltip.centered {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(420px, calc(100vw - 32px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)));
max-width: calc(100vw - 32px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
}
.app-tour-close {
@@ -3274,5 +3721,41 @@ body.app-tour-active .app-tour-target-active {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
@media (max-width: 520px) {
.app-tour-tooltip {
padding: 18px 16px 14px;
}
.app-tour-actions {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.app-tour-nav {
margin-left: 0;
width: 100%;
}
.app-tour-nav-btn {
flex: 1;
justify-content: center;
min-width: 0;
}
}
body.app-tour-active .pwa-install-banner {
display: none !important;
}
body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
z-index: 9990;
pointer-events: none;
}
body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
pointer-events: none;
}
+177 -41
View File
@@ -13,7 +13,8 @@ import SettingsForm from './components/SettingsForm.tsx'
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { getActiveMasterKey, logoutUser } from './services/auth.js'
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
import {
applyAppearanceToDocument,
@@ -28,26 +29,34 @@ import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx'
import LogbookRoleBadge from './components/LogbookRoleBadge.tsx'
import BetaBadge from './components/BetaBadge.tsx'
import { db } from './services/db.js'
import { getLogbookAccess } from './services/logbookAccess.js'
import type { LogbookAccessRole } from './services/logbook.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import {
getStoredDemoFirstEntryId,
seedDemoLogbookIfNeeded
} from './services/demoLogbook.js'
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() {
const { t, i18n } = useTranslation()
const { registerNavigation, requestStartAfterLogin } = useAppTour()
const { confirmLeave } = useUnsavedChangesContext()
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<AppTab>('logs')
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
const [tourFeedbackOpen, setTourFeedbackOpen] = useState(false)
const [demoHighlightEntryId, setDemoHighlightEntryId] = useState<string | null>(null)
const [online, setOnline] = useState(navigator.onLine)
const [isSyncing, setIsSyncing] = useState(false)
@@ -71,7 +80,7 @@ function App() {
[activeLogbookId]
)
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole>('OWNER')
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
useEffect(() => {
if (!activeLogbookId) {
@@ -79,19 +88,34 @@ function App() {
return
}
if (activeLogbookRecord?.isShared !== 1) {
if (!activeLogbookRecord) {
setActiveAccessRole(null)
return
}
if (activeLogbookRecord.isShared !== 1) {
setActiveAccessRole('OWNER')
return
}
const cachedRole = activeLogbookRecord.collaborationRole
if (cachedRole) {
setActiveAccessRole(cachedRole)
}
setActiveAccessRole(
cachedRole ? parseCollaborationRole(cachedRole, `logbook ${activeLogbookId}`) : null
)
getLogbookAccess(activeLogbookId).then((access) => {
if (access) setActiveAccessRole(access.role)
})
let cancelled = false
getLogbookAccess(activeLogbookId)
.then((access) => {
if (cancelled || !access) return
setActiveAccessRole(access.role)
})
.catch((err) => {
console.warn('Failed to resolve logbook access role:', err)
})
return () => {
cancelled = true
}
}, [activeLogbookId, activeLogbookRecord])
useEffect(() => {
@@ -169,16 +193,51 @@ function App() {
setIsAcceptingInvite(false)
const savedUser = localStorage.getItem('active_username')
const key = getActiveMasterKey()
if (savedUser && key) {
setIsAuthenticated(true)
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
if (savedLogbookId && savedLogbookTitle) {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
const openLogbookId = params.get('logbook')
if (openLogbookId) {
sessionStorage.setItem(PENDING_PUSH_LOGBOOK_KEY, openLogbookId)
const cleanUrl = new URL(window.location.href)
cleanUrl.searchParams.delete('logbook')
window.history.replaceState(
{},
document.title,
`${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}`
)
}
}, [])
useEffect(() => {
let cancelled = false
;(async () => {
try {
const session = await checkServerSession()
if (cancelled) return
if (session.authenticated && session.userId) {
localStorage.setItem('active_userid', session.userId)
}
const savedUser = localStorage.getItem('active_username')
const key = getActiveMasterKey()
if (session.authenticated && savedUser && key) {
setIsAuthenticated(true)
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
if (savedLogbookId && savedLogbookTitle) {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
}
}
} catch (err) {
if (!cancelled) {
console.warn('Session restore failed:', err)
}
}
})()
return () => {
cancelled = true
}
}, [])
@@ -198,7 +257,8 @@ function App() {
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId
setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: setTourFeedbackOpen
})
}, [registerNavigation])
@@ -217,9 +277,53 @@ function App() {
localStorage.setItem('active_logbook_title', title)
}
const openLogbookById = useCallback(
async (logbookId: string) => {
try {
const books = await fetchLogbooks()
const match = books.find((b) => b.id === logbookId)
if (match) {
selectLogbook(match.id, match.title)
return
}
} catch (err) {
console.error('Failed to resolve logbook from push:', err)
}
selectLogbook(logbookId, `${logbookId.slice(0, 8)}`)
},
[]
)
const consumePendingPushLogbook = useCallback(() => {
const pending = sessionStorage.getItem(PENDING_PUSH_LOGBOOK_KEY)
if (!pending) return
sessionStorage.removeItem(PENDING_PUSH_LOGBOOK_KEY)
void openLogbookById(pending)
}, [openLogbookById])
useEffect(() => {
if (isAuthenticated) {
consumePendingPushLogbook()
}
}, [isAuthenticated, consumePendingPushLogbook])
useEffect(() => {
if (!isAuthenticated || !('serviceWorker' in navigator)) return
const onSwMessage = (event: MessageEvent) => {
if (event.data?.type === 'OPEN_LOGBOOK' && typeof event.data.logbookId === 'string') {
void openLogbookById(event.data.logbookId)
}
}
navigator.serviceWorker.addEventListener('message', onSwMessage)
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
}, [isAuthenticated, openLogbookById])
const handleAuthenticated = async () => {
setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
void ensurePushSubscriptionIfEnabled()
try {
const demo = await seedDemoLogbookIfNeeded()
@@ -229,6 +333,7 @@ function App() {
setDemoHighlightEntryId(demo.firstEntryId)
}
requestStartAfterLogin()
consumePendingPushLogbook()
return
}
} catch (err) {
@@ -241,10 +346,18 @@ function App() {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
}
consumePendingPushLogbook()
}
const handleLogout = () => {
logoutUser()
const handleTabChange = async (tab: AppTab) => {
if (tab === activeTab) return
if (!(await confirmLeave())) return
setActiveTab(tab)
}
const handleLogout = async () => {
if (!(await confirmLeave())) return
void logoutUser()
setIsAuthenticated(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
@@ -254,7 +367,8 @@ function App() {
localStorage.removeItem('active_logbook_title')
}
const handleBackToDashboard = () => {
const handleBackToDashboard = async () => {
if (!(await confirmLeave())) return
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setTourSelectedEntryId(null)
@@ -317,7 +431,12 @@ function App() {
)
}
const pwaInstallBanner = <PwaInstallPrompt variant="banner" />
const pwaInstallBanner = !isActive ? <PwaInstallPrompt variant="banner" /> : null
const logbookReadOnly =
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
if (!activeLogbookId) {
return (
@@ -339,19 +458,20 @@ function App() {
{/* Active Logbook Header */}
<header className="app-header">
<div className="app-header-left">
<button className="btn-back" onClick={handleBackToDashboard}>
<button className="btn-back" onClick={handleBackToDashboard} title={t('nav.dashboard')}>
<ChevronLeft size={16} />
{t('nav.dashboard')}
<span className="hide-mobile">{t('nav.dashboard')}</span>
</button>
<div className="app-title-area">
<div className="app-title-row">
<h2>{activeLogbookTitle}</h2>
{activeAccessRole !== 'OWNER' && (
<BetaBadge />
{activeAccessRole && activeAccessRole !== 'OWNER' && (
<LogbookRoleBadge role={activeAccessRole} />
)}
</div>
<p className="app-subtitle">
{activeAccessRole !== 'OWNER'
{activeAccessRole && activeAccessRole !== 'OWNER'
? t('dashboard.section_shared_hint')
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
</p>
@@ -377,6 +497,14 @@ function App() {
<DisclaimerHeaderButton />
<FeedbackHeaderButton
logbookId={activeLogbookId}
logbookTitle={activeLogbookTitle}
tourOpen={tourFeedbackOpen}
onTourOpenChange={setTourFeedbackOpen}
tourHighlight={isActive && currentStepId === 'nav_feedback'}
/>
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
</button>
@@ -389,7 +517,7 @@ function App() {
<aside className="app-sidebar">
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')}
onClick={() => void handleTabChange('logs')}
data-tour="nav-logs"
>
<FileText size={18} />
@@ -398,7 +526,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => setActiveTab('vessel')}
onClick={() => void handleTabChange('vessel')}
data-tour="nav-vessel"
>
<Ship size={18} />
@@ -407,7 +535,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
onClick={() => void handleTabChange('crew')}
data-tour="nav-crew"
>
<Users size={18} />
@@ -426,7 +554,8 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
onClick={() => void handleTabChange('stats')}
data-tour="nav-stats"
>
<BarChart2 size={18} />
{t('nav.stats')}
@@ -434,7 +563,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => setActiveTab('settings')}
onClick={() => void handleTabChange('settings')}
>
<Settings size={18} />
{t('nav.settings')}
@@ -446,6 +575,7 @@ function App() {
{activeTab === 'logs' && (
<LogEntriesList
logbookId={activeLogbookId}
readOnly={logbookReadOnly}
controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId}
highlightEntryId={demoHighlightEntryId}
@@ -453,11 +583,15 @@ function App() {
)}
{activeTab === 'vessel' && (
<VesselForm logbookId={activeLogbookId} />
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly || !isLogbookOwner} />
)}
{activeTab === 'crew' && (
<CrewForm logbookId={activeLogbookId} />
<CrewForm
logbookId={activeLogbookId}
readOnly={logbookReadOnly}
skipperReadOnly={!isLogbookOwner}
/>
)}
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
@@ -486,12 +620,14 @@ function App() {
export default function AppWrapper() {
return (
<DialogProvider>
<AppTourProvider>
<PwaUpdatePrompt />
<App />
<AppTourOverlay />
</AppTourProvider>
<AppFooter />
<UnsavedChangesProvider>
<AppTourProvider>
<PwaUpdatePrompt />
<App />
<AppTourOverlay />
</AppTourProvider>
<AppFooter />
</UnsavedChangesProvider>
</DialogProvider>
)
}
+11 -3
View File
@@ -1,3 +1,5 @@
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
export default function AppFooter() {
@@ -7,9 +9,15 @@ export default function AppFooter() {
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a className="app-version-footer__copyright" href="mailto:elpatron+kd@mailbox.org">
© 2026 Markus F.J. Busche
</a>
<span className="app-version-footer__copyright">
© 2026 KnorrLabs/
<a
href="mailto:elpatron+kd@mailbox.org"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
Markus F.J. Busche
</a>
</span>
</footer>
)
}
+23 -6
View File
@@ -15,12 +15,33 @@ interface SpotlightRect {
height: number
}
const TOOLTIP_EDGE_MARGIN = 16
const TOOLTIP_ESTIMATED_HEIGHT = 240
function buildCutoutClipPath(rect: SpotlightRect): string {
const right = rect.left + rect.width
const bottom = rect.top + rect.height
return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)`
}
function computeTooltipTop(spotlight: SpotlightRect): number {
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
const below = spotlight.top + spotlight.height + 12
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
return below
}
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
if (above >= TOOLTIP_EDGE_MARGIN) {
return above
}
return Math.max(
TOOLTIP_EDGE_MARGIN,
Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT)
)
}
export default function AppTourOverlay() {
const { t } = useTranslation()
const {
@@ -111,12 +132,8 @@ export default function AppTourOverlay() {
const tooltipStyle = centered
? undefined
: spotlight
? {
top: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12),
left: Math.min(window.innerWidth - 340, Math.max(16, spotlight.left)),
maxWidth: '420px'
}
: { top: '20%', left: '50%', transform: 'translateX(-50%)', maxWidth: '420px' }
? { top: computeTooltipTop(spotlight) }
: { top: '20%' }
const backdropStyle = spotlight && !centered
? { clipPath: buildCutoutClipPath(spotlight) }
+9 -1
View File
@@ -13,6 +13,7 @@ import {
} from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import BetaBadge from './BetaBadge.tsx'
interface AuthOnboardingProps {
onAuthenticated: () => void
@@ -272,6 +273,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</label>
<input
type="password"
name="new-pin"
inputMode="numeric"
pattern="[0-9]*"
maxLength={8}
@@ -281,6 +283,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
onChange={(e) => setPinInput(e.target.value.replace(/\D/g, ''))}
disabled={loading}
required
autoComplete="new-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
@@ -321,6 +324,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="input-group">
<input
type="password"
name="pin"
inputMode="numeric"
pattern="[0-9]*"
maxLength={8}
@@ -330,6 +334,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
onChange={(e) => setPinLoginInput(e.target.value.replace(/\D/g, ''))}
disabled={loading}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
@@ -408,7 +413,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="auth-card glass">
<div className="auth-brand">
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
<h1>{t('app.name')}</h1>
<div className="auth-brand-title-row">
<h1>{t('app.name')}</h1>
<BetaBadge />
</div>
<p className="tagline">{t('auth.tagline')}</p>
</div>
+19
View File
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
interface BetaBadgeProps {
className?: string
}
export default function BetaBadge({ className = '' }: BetaBadgeProps) {
const { t } = useTranslation()
return (
<span
className={`beta-badge ${className}`.trim()}
title={t('app.beta_hint')}
aria-label={t('app.beta_hint')}
>
{t('app.beta')}
</span>
)
}
+26 -15
View File
@@ -12,6 +12,7 @@ import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide
interface CrewFormProps {
logbookId: string
readOnly?: boolean
skipperReadOnly?: boolean
preloadedData?: any[]
}
@@ -34,9 +35,15 @@ interface DecryptedCrew {
data: CrewMemberData
}
export default function CrewForm({ logbookId, readOnly = false, preloadedData }: CrewFormProps) {
export default function CrewForm({
logbookId,
readOnly = false,
skipperReadOnly = false,
preloadedData
}: CrewFormProps) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const skipperFormReadOnly = readOnly || skipperReadOnly
// Skipper profile state
const [skipName, setSkipName] = useState('')
@@ -192,7 +199,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
const handleSaveSkipper = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly) return
if (skipperFormReadOnly) return
setSavingSkipper(true)
setError(null)
setSkipperSuccess(false)
@@ -397,10 +404,14 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
{error && <div className="auth-error mb-4">{error}</div>}
{skipperReadOnly && !readOnly && (
<p className="help-text mb-4">{t('crew.skipper_read_only_hint')}</p>
)}
<form onSubmit={handleSaveSkipper} className="vessel-form">
<div className="form-grid">
<div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={readOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: readOnly ? 'default' : 'pointer' }}>
<div className="vessel-photo-preview" onClick={skipperFormReadOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: skipperFormReadOnly ? 'default' : 'pointer' }}>
{skipPhoto ? (
<img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" />
) : (
@@ -408,7 +419,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
<User size={48} className="placeholder-icon" />
</div>
)}
{!readOnly && (
{!skipperFormReadOnly && (
<div className="vessel-photo-overlay">
<Camera size={24} />
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
@@ -416,7 +427,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
)}
</div>
{!readOnly && (
{!skipperFormReadOnly && (
<div className="vessel-photo-actions">
<button
type="button"
@@ -473,7 +484,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipName}
onChange={(e) => setSkipName(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
required
/>
</div>
@@ -485,7 +496,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipAddress}
onChange={(e) => setSkipAddress(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -496,7 +507,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipBirthDate}
onChange={(e) => setSkipBirthDate(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -507,7 +518,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipPhone}
onChange={(e) => setSkipPhone(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -518,7 +529,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipNationality}
onChange={(e) => setSkipNationality(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -529,7 +540,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipPassport}
onChange={(e) => setSkipPassport(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -540,7 +551,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipBloodType}
onChange={(e) => setSkipBloodType(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -551,7 +562,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipAllergies}
onChange={(e) => setSkipAllergies(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
@@ -562,12 +573,12 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
className="input-text"
value={skipDiseases}
onChange={(e) => setSkipDiseases(e.target.value)}
disabled={savingSkipper || readOnly}
disabled={savingSkipper || skipperFormReadOnly}
/>
</div>
</div>
{!readOnly && (
{!skipperFormReadOnly && (
<div className="form-actions">
{skipperSuccess && (
<div className="success-toast">
+2 -1
View File
@@ -30,7 +30,8 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId
setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: () => {}
})
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
@@ -0,0 +1,51 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MessageSquarePlus } from 'lucide-react'
import FeedbackModal from './FeedbackModal.tsx'
interface FeedbackHeaderButtonProps {
logbookId?: string | null
logbookTitle?: string | null
tourOpen?: boolean
onTourOpenChange?: (open: boolean) => void
tourHighlight?: boolean
}
export default function FeedbackHeaderButton({
logbookId,
logbookTitle,
tourOpen = false,
onTourOpenChange,
tourHighlight = false
}: FeedbackHeaderButtonProps) {
const { t } = useTranslation()
const [userOpen, setUserOpen] = useState(false)
const open = tourOpen || userOpen
const handleClose = () => {
setUserOpen(false)
onTourOpenChange?.(false)
}
return (
<>
<button
type="button"
className="btn-icon"
onClick={() => setUserOpen(true)}
title={t('feedback.button_title')}
aria-label={t('feedback.button_title')}
data-tour="feedback-button"
>
<MessageSquarePlus size={18} />
</button>
<FeedbackModal
open={open}
onClose={handleClose}
logbookId={logbookId}
logbookTitle={logbookTitle}
tourMode={tourHighlight}
/>
</>
)
}
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CheckCircle2, MessageSquarePlus, X } from 'lucide-react'
import { FeedbackApiError, sendFeedback, type FeedbackCategory } from '../services/feedback.js'
const SUCCESS_CLOSE_DELAY_MS = 1800
interface FeedbackModalProps {
open: boolean
onClose: () => void
logbookId?: string | null
logbookTitle?: string | null
tourMode?: boolean
}
type SubmitState = 'idle' | 'submitting' | 'success' | 'error'
export default function FeedbackModal({
open,
onClose,
logbookId,
logbookTitle,
tourMode = false
}: FeedbackModalProps) {
const { t } = useTranslation()
const [category, setCategory] = useState<FeedbackCategory>('general')
const [contactEmail, setContactEmail] = useState('')
const [message, setMessage] = useState('')
const [website, setWebsite] = useState('')
const [submitState, setSubmitState] = useState<SubmitState>('idle')
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const closeTimerRef = useRef<number | null>(null)
const openedAtRef = useRef<number>(Date.now())
const isBusy = submitState === 'submitting' || submitState === 'success'
const clearCloseTimer = () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
}
useEffect(() => {
return () => clearCloseTimer()
}, [])
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isBusy) onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [open, onClose, isBusy])
useEffect(() => {
if (!open) {
clearCloseTimer()
setCategory('general')
setContactEmail('')
setMessage('')
setWebsite('')
setSubmitState('idle')
setStatusMessage(null)
return
}
openedAtRef.current = Date.now()
}, [open])
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!message.trim() || submitState === 'submitting' || submitState === 'success') return
setSubmitState('submitting')
setStatusMessage(null)
try {
await sendFeedback({
category,
message: message.trim(),
contactEmail: contactEmail.trim() || undefined,
logbookId,
logbookTitle,
openedAt: openedAtRef.current,
website
})
setSubmitState('success')
setStatusMessage(t('feedback.success'))
closeTimerRef.current = window.setTimeout(() => {
closeTimerRef.current = null
onClose()
}, SUCCESS_CLOSE_DELAY_MS)
} catch (error) {
setSubmitState('error')
setStatusMessage(
error instanceof FeedbackApiError && error.code === 'NOT_CONFIGURED'
? t('feedback.error_not_configured')
: error instanceof FeedbackApiError && error.code === 'INVALID_EMAIL'
? t('feedback.error_invalid_email')
: error instanceof FeedbackApiError && error.code === 'RATE_LIMITED'
? t('feedback.error_rate_limited')
: error instanceof FeedbackApiError && error.code === 'SPAM_DETECTED'
? t('feedback.error_spam')
: t('feedback.error_send')
)
}
}
if (!open) return null
return (
<div
className={`disclaimer-modal-overlay${tourMode ? ' feedback-modal-overlay--tour' : ''}`}
onClick={isBusy || tourMode ? undefined : onClose}
>
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
<div
className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal"
data-tour="feedback-form"
>
<button
type="button"
className="registration-disclaimer__close feedback-modal__close"
onClick={onClose}
disabled={isBusy || tourMode}
aria-label={t('feedback.cancel')}
>
<X size={18} />
</button>
<div className="auth-header">
<MessageSquarePlus className="auth-icon accent" size={48} />
<h2>{t('feedback.title')}</h2>
</div>
{submitState === 'success' ? (
<div className="feedback-status feedback-status--success" role="status" aria-live="polite">
<CheckCircle2 size={40} aria-hidden="true" />
<p>{statusMessage}</p>
</div>
) : (
<>
<p className="registration-disclaimer__intro">{t('feedback.intro')}</p>
{statusMessage && submitState === 'error' && (
<div className="feedback-status feedback-status--error" role="alert">
<p>{statusMessage}</p>
</div>
)}
<form className="feedback-form" onSubmit={handleSubmit}>
<label className="feedback-form__honeypot" aria-hidden="true">
<span>Website</span>
<input
type="text"
name="website"
value={website}
onChange={(event) => setWebsite(event.target.value)}
tabIndex={-1}
autoComplete="off"
/>
</label>
<label className="feedback-form__field">
<span>{t('feedback.category_label')}</span>
<select
value={category}
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
disabled={submitState === 'submitting'}
>
<option value="general">{t('feedback.category_general')}</option>
<option value="bug">{t('feedback.category_bug')}</option>
<option value="feature">{t('feedback.category_feature')}</option>
</select>
</label>
<label className="feedback-form__field">
<span>{t('feedback.contact_label')}</span>
<input
type="email"
value={contactEmail}
onChange={(event) => {
setContactEmail(event.target.value)
if (submitState === 'error') {
setSubmitState('idle')
setStatusMessage(null)
}
}}
placeholder={t('feedback.contact_placeholder')}
autoComplete="email"
maxLength={254}
disabled={submitState === 'submitting'}
/>
</label>
<label className="feedback-form__field">
<span>{t('feedback.message_label')}</span>
<textarea
value={message}
onChange={(event) => {
setMessage(event.target.value)
if (submitState === 'error') {
setSubmitState('idle')
setStatusMessage(null)
}
}}
placeholder={t('feedback.message_placeholder')}
rows={6}
maxLength={2000}
required
disabled={submitState === 'submitting'}
/>
</label>
<div className="auth-actions feedback-form__actions">
<button
type="button"
className="btn secondary"
onClick={onClose}
disabled={submitState === 'submitting' || tourMode}
>
{t('feedback.cancel')}
</button>
<button
type="submit"
className="btn primary"
disabled={submitState === 'submitting' || !message.trim()}
>
{submitState === 'submitting' ? t('feedback.sending') : t('feedback.send')}
</button>
</div>
</form>
</>
)}
</div>
</div>
</div>
)
}
+61 -66
View File
@@ -14,12 +14,32 @@ import { parseCollaborationRole } from '../services/logbook.js'
import { syncLogbook } from '../services/sync.js'
import { db } from '../services/db.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiJson } from '../services/api.js'
interface InvitationAcceptanceProps {
onAccepted: (logbookId: string, title: string) => void
onCancel: () => void
}
type LocalizedError =
| { source: 'i18n'; key: string }
| { source: 'raw'; text: string }
const resolveLocalizedError = (
error: LocalizedError | null,
t: (key: string) => string
): string | null => {
if (!error) return null
return error.source === 'i18n' ? t(error.key) : error.text
}
const localizedErrorFromMessage = (
message: string | undefined,
fallbackKey: string
): LocalizedError => {
return message ? { source: 'raw', text: message } : { source: 'i18n', key: fallbackKey }
}
const hexToBuffer = (hex: string): ArrayBuffer => {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) {
@@ -33,7 +53,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [error, setError] = useState<LocalizedError | null>(null)
const [token, setToken] = useState('')
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
@@ -47,7 +67,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const [username, setUsername] = useState('')
const [loginMode, setLoginMode] = useState<'options' | 'register'>('options')
const [regUsername, setRegUsername] = useState('')
const [authError, setAuthError] = useState<string | null>(null)
const [authError, setAuthError] = useState<LocalizedError | null>(null)
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
const [showRecoveryFallback, setShowRecoveryFallback] = useState(false)
@@ -56,7 +76,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const autoAcceptStarted = useRef(false)
const isDe = i18n.language.startsWith('de')
const errorText = resolveLocalizedError(error, t)
const authErrorText = resolveLocalizedError(authError, t)
const sessionReady = (): boolean => {
return !!(getActiveMasterKey() && localStorage.getItem('active_userid'))
@@ -82,19 +103,15 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setLogbookKey(hexToBuffer(hexKey))
} catch (err) {
console.error('Invalid key in URL fragment:', err)
setError(isDe
? 'Der Einladungslink ist kryptografisch ungültig (Schlüssel fehlerhaft).'
: 'The invitation link is cryptographically invalid (corrupted key).')
setError({ source: 'i18n', key: 'invitation.error_invalid_key' })
}
} else {
setError(isDe
? 'Der Einladungslink enthält keinen Entschlüsselungsschlüssel (#key=...). Bitte den vollständigen Link vom Eigner verwenden.'
: 'The invitation link is missing the decryption key (#key=...). Please use the complete link from the owner.')
setError({ source: 'i18n', key: 'invitation.error_missing_key' })
}
const rand = Math.floor(1000 + Math.random() * 9000)
setRegUsername(`CrewSkipper_${rand}`)
}, [isDe])
}, [])
useEffect(() => {
if (token && logbookKey) {
@@ -109,14 +126,13 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const res = await fetch(`/api/collaboration/invite-details?token=${token}`)
if (res.status === 410) {
setError(isDe
? 'Diese Einladung ist abgelaufen (48 Stunden gültig).'
: 'This invitation link has expired (valid for 48 hours only).')
setError({ source: 'i18n', key: 'invitation.error_expired' })
return
}
if (!res.ok) {
throw new Error(isDe ? 'Einladungstoken ungültig.' : 'Failed to verify invitation token.')
setError({ source: 'i18n', key: 'invitation.error_invalid_token' })
return
}
const details = await res.json()
@@ -129,7 +145,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setDecryptedTitle(title)
} catch (err: any) {
console.error('Failed to load invitation details:', err)
setError(err.message || (isDe ? 'Einladungsdetails konnten nicht geladen werden.' : 'Invitation details could not be retrieved.'))
setError(localizedErrorFromMessage(err.message, 'invitation.error_load_failed'))
} finally {
setLoading(false)
}
@@ -140,9 +156,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const activeUserId = localStorage.getItem('active_userid')
if (!masterKey || !activeUserId) {
autoAcceptStarted.current = false
setError(isDe
? 'Sitzung unvollständig — bitte erneut anmelden (Benutzer-ID fehlt).'
: 'Incomplete session — please log in again (user ID missing).')
setError({ source: 'i18n', key: 'invitation.error_incomplete_session' })
setIsLoggedIn(false)
return
}
@@ -164,12 +178,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
)
const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
const res = await fetch('/api/collaboration/accept', {
const acceptResult = await apiJson<{ role: string; logbookId: string }>('/api/collaboration/accept', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': activeUserId
},
body: JSON.stringify({
token,
encryptedLogbookKey: encrypted.ciphertext,
@@ -177,13 +187,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
tag: encrypted.tag
})
})
if (!res.ok) {
const serverError = await res.json().catch(() => ({}))
throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.'))
}
const acceptResult = await res.json()
const collaborationRole = parseCollaborationRole(acceptResult.role, 'invitation accept')
await saveLogbookKey(logbookId, logbookKey)
@@ -204,12 +207,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
onAccepted(logbookId, decryptedTitle)
} catch (err: any) {
console.error('Accepting invitation failed:', err)
setError(err.message || (isDe ? 'Beitritt fehlgeschlagen.' : 'Acceptance failed.'))
setError(localizedErrorFromMessage(err.message, 'invitation.error_accept_failed'))
autoAcceptStarted.current = false
} finally {
setAccepting(false)
}
}, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted, isDe])
}, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted])
useEffect(() => {
if (loading || accepting || autoAcceptStarted.current) return
@@ -245,7 +248,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
if (resolvedUser) setUsername(resolvedUser)
setShowRecoveryFallback(true)
} catch (err: any) {
setAuthError(err.message || (isDe ? 'Passkey-Anmeldung fehlgeschlagen.' : 'Passkey authentication failed.'))
setAuthError(localizedErrorFromMessage(err.message, 'invitation.error_login_failed'))
} finally {
setLoading(false)
}
@@ -257,9 +260,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
const resolvedUser = (username.trim() || encryptedPayloads.username || '').trim()
if (!resolvedUser) {
setAuthError(isDe
? 'Benutzername konnte nicht ermittelt werden — bitte erneut anmelden.'
: 'Could not determine username — please try logging in again.')
setAuthError({ source: 'i18n', key: 'invitation.error_username_missing' })
return
}
@@ -272,10 +273,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setIsLoggedIn(true)
setUsername(resolvedUser)
} else {
setAuthError(t('auth.error_incorrect_recovery'))
setAuthError({ source: 'i18n', key: 'auth.error_incorrect_recovery' })
}
} catch (err: any) {
setAuthError(err.message || t('auth.error_decryption_failed'))
setAuthError(localizedErrorFromMessage(err.message, 'auth.error_decryption_failed'))
} finally {
setLoading(false)
}
@@ -295,7 +296,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setRecoveryPhrase(result.recoveryPhrase)
}
} catch (err: any) {
setAuthError(err.message || (isDe ? 'Registrierung fehlgeschlagen.' : 'Registration failed.'))
setAuthError(localizedErrorFromMessage(err.message, 'invitation.error_register_failed'))
} finally {
setLoading(false)
}
@@ -354,14 +355,14 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
/>
<div className="auth-actions mt-4">
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
{isDe ? 'Zurück' : 'Back'}
{t('auth.back')}
</button>
<button type="submit" className="btn primary" disabled={loading}>
{t('auth.decrypt_logbook')}
</button>
</div>
</form>
{authError && <div className="auth-error mt-4">{authError}</div>}
{authErrorText && <div className="auth-error mt-4">{authErrorText}</div>}
</div>
)
}
@@ -371,12 +372,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-card glass">
<div className="auth-header">
<Ship className="auth-icon accent spin" size={48} />
<h2>{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Einladung wird geprüft...' : 'Checking Invitation...')}</h2>
<h2>{accepting ? t('invitation.loading_joining') : t('invitation.loading_checking')}</h2>
</div>
<p className="recovery-warning">
{accepting
? (isDe ? 'Logbuch wird freigeschaltet und synchronisiert...' : 'Unlocking logbook and syncing data...')
: (isDe ? 'Lade Verschlüsselungsschlüssel...' : 'Retrieving encryption key...')}
{accepting ? t('invitation.loading_unlocking') : t('invitation.loading_retrieving_key')}
</p>
</div>
)
@@ -387,12 +386,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-card glass">
<div className="auth-header">
<AlertTriangle className="auth-icon warn" size={48} />
<h2>{isDe ? 'Einladungsfehler' : 'Invitation Error'}</h2>
<h2>{t('invitation.error_title')}</h2>
</div>
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p>
<p className="recovery-warning" style={{ color: '#ef4444' }}>{errorText}</p>
<div className="auth-actions mt-6">
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
{isDe ? 'Zurück zum Start' : 'Back to Dashboard'}
{t('invitation.back_to_start')}
</button>
</div>
</div>
@@ -403,18 +402,18 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-card glass">
<div className="auth-header">
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} />
<h2>{isDe ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2>
<h2>{t('invitation.title')}</h2>
</div>
<div style={{ textAlign: 'center', margin: '20px 0', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}>
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
{isDe ? 'Einladung von' : 'INVITED BY'}
{t('invitation.invited_by')}
</p>
<p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}>
Skipper {ownerUsername}
</p>
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
{isDe ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
{t('invitation.vessel_logbook')}
</p>
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
{decryptedTitle}
@@ -424,12 +423,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
{isLoggedIn ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{isDe
? `Angemeldet als ${username}. Beitritt wird vorbereitet...`
: `Signed in as ${username}. Preparing to join...`}
{t('invitation.signed_in_preparing', { username })}
</p>
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ width: '100%' }}>
{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Erneut beitreten' : 'Join again')}
{accepting ? t('invitation.loading_joining') : t('invitation.join_again')}
<ArrowRight size={16} />
</button>
</div>
@@ -438,27 +435,25 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
{loginMode === 'options' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{isDe
? 'Melden Sie sich an oder registrieren Sie ein Konto, um dem Logbuch beizutreten.'
: 'Sign in or register an account to join this logbook.'}
{t('invitation.login_or_register_hint')}
</p>
<button className="btn primary" onClick={handleLogin} disabled={loading} style={{ width: '100%', padding: '14px' }}>
<LogIn size={16} />
{isDe ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
{t('auth.login')}
</button>
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
{isDe ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
{t('invitation.or_sign_up')}
</span>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
</div>
<button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}>
<UserPlus size={16} />
{isDe ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
{t('invitation.register_crew_account')}
</button>
</div>
)}
@@ -467,7 +462,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="input-group">
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>
{isDe ? 'Benutzername' : 'Username'}
{t('invitation.username_label')}
</label>
<input
type="text"
@@ -479,23 +474,23 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
</div>
<div className="auth-actions">
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
{isDe ? 'Zurück' : 'Back'}
{t('auth.back')}
</button>
<button type="submit" className="btn primary" disabled={!regUsername.trim() || loading}>
{isDe ? 'Passkey erstellen' : 'Create Passkey'}
{t('invitation.create_passkey')}
</button>
</div>
</form>
)}
{authError && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authError}</div>}
{authErrorText && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authErrorText}</div>}
</div>
)}
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
<button className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{isDe ? 'English' : 'Deutsch'}
{i18n.language.startsWith('de') ? t('invitation.switch_language_en') : t('invitation.switch_language_de')}
</button>
</div>
</div>
+3 -3
View File
@@ -372,7 +372,7 @@ export default function LogEntriesList({
<Calendar size={24} className="form-icon" />
<h2>{t('logs.title')}</h2>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div className="section-toolbar">
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
<Download size={16} />
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
@@ -384,9 +384,9 @@ export default function LogEntriesList({
</button>
{!readOnly && (
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}>
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
<Plus size={16} />
{t('logs.new_entry')}
<span className="hide-mobile">{t('logs.new_entry')}</span>
</button>
)}
</div>
+469 -161
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
@@ -6,24 +6,28 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
normalizeSignature,
serializeSignature,
fingerprintSignature,
normalizedSerializedSignature,
isPasskeySignature,
isClassicSignature,
createClassicSignature,
isSignatureValidForEntry,
hasAnySignature
} from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, type LogEventPayload } from '../utils/logEntryPayload.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import {
getDecryptedTrack,
saveUploadedTrack,
@@ -33,6 +37,63 @@ import {
type SavedTrack
} from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
function emptyTankLevels() {
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
}
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
const trackDistance = decrypted.trackDistanceNm
const trackSpeedMax = decrypted.trackSpeedMaxKn
const trackSpeedAvg = decrypted.trackSpeedAvgKn
const motorHoursRaw = decrypted.motorHours
const payload = buildLogEntryPayload({
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
departure: String(decrypted.departure || ''),
destination: String(decrypted.destination || ''),
freshwater: {
morning: fw.morning || 0,
refilled: fw.refilled || 0,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
},
fuel: {
morning: fuel.morning || 0,
refilled: fuel.refilled || 0,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
},
trackDistanceNm:
trackDistance != null && trackDistance !== ''
? parseFloat(String(trackDistance))
: undefined,
trackSpeedMaxKn:
trackSpeedMax != null && trackSpeedMax !== ''
? parseFloat(String(trackSpeedMax))
: undefined,
trackSpeedAvgKn:
trackSpeedAvg != null && trackSpeedAvg !== ''
? parseFloat(String(trackSpeedAvg))
: undefined,
motorHours:
motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw))
: undefined,
events: (decrypted.events as LogEventPayload[]) || []
})
return JSON.stringify({
...payload,
signSkipper: fingerprintSignature(decrypted.signSkipper),
signCrew: fingerprintSignature(decrypted.signCrew)
})
}
interface LogEntryEditorProps {
entryId: string
@@ -45,24 +106,7 @@ interface LogEntryEditorProps {
preloadedYacht?: any
}
interface LogEvent {
time: string
mgk: string
rwk: string
windPressure: string
windDirection: string
windStrength: string
seaState: string
weatherIcon: string
current: string
heel: string
sailsOrMotor: string
logReading: string
distance: string
gpsLat: string
gpsLng: string
remarks: string
}
interface LogEvent extends LogEventPayload {}
export default function LogEntryEditor({
entryId,
@@ -102,7 +146,7 @@ export default function LogEntryEditor({
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
const [canSignSkipper, setCanSignSkipper] = useState(false)
const [hasWriteCollaborators, setHasWriteCollaborators] = useState(false)
const [canSignCrew, setCanSignCrew] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [entryHash, setEntryHash] = useState('')
@@ -111,6 +155,9 @@ export default function LogEntryEditor({
const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('')
const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('')
// Motor hours under engine propulsion (per travel day)
const [motorHours, setMotorHours] = useState('')
// Events list state
const [events, setEvents] = useState<LogEvent[]>([])
@@ -139,6 +186,7 @@ export default function LogEntryEditor({
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
// Track file upload
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
@@ -148,6 +196,8 @@ export default function LogEntryEditor({
const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false)
const lastSignatureAlertHashRef = useRef<string | null>(null)
const skipCrewSignClearRef = useRef(false)
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(waypoints)
@@ -168,9 +218,14 @@ export default function LogEntryEditor({
if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') {
setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn))
}
if (entry?.motorHours != null && entry.motorHours !== '') {
setMotorHours(String(entry.motorHours))
} else {
setMotorHours('')
}
}
const buildPayloadForSigning = useCallback(() => {
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
return buildLogEntryPayload({
date,
dayOfTravel,
@@ -191,16 +246,130 @@ export default function LogEntryEditor({
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
events: eventsOverride ?? events
})
}, [
date, dayOfTravel, departure, destination,
fwMorning, fwRefilled, fwEvening, fwConsumption,
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
events
])
const fuelPerMotorHour = useMemo(
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
[fuelConsumption, motorHours]
)
const currentFingerprint = useMemo(() => {
const payload = buildPayloadForSigning()
return JSON.stringify({
...payload,
signSkipper: fingerprintSignature(signSkipper),
signCrew: fingerprintSignature(signCrew)
})
}, [buildPayloadForSigning, signSkipper, signCrew])
const buildEventFromForm = (): LogEvent =>
normalizeLogEvent({
time: evTime,
mgk: evMgk,
rwk: evRwk,
windPressure: evWindPressure,
windDirection: evWindDirection,
windStrength: evWindStrength,
seaState: evSeaState,
weatherIcon: evWeatherIcon,
current: evCurrent,
heel: evHeel,
sailsOrMotor: evSailsOrMotor,
logReading: evLogReading,
distance: evDistance,
gpsLat: evGpsLat,
gpsLng: evGpsLng,
remarks: evRemarks
})
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
if (editingEventIndex !== null) {
return sortLogEventsByTime(events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev)))
}
return sortLogEventsByTime([...events, eventData])
}
const hasPendingEventForm = useMemo(() => {
if (!evTime.trim()) return false
const draft = buildEventFromForm()
if (editingEventIndex !== null) {
const original = events[editingEventIndex]
return original ? !logEventsEqual(draft, original) : false
}
return true
}, [
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
])
const isDirty = savedFingerprint !== null && (
currentFingerprint !== savedFingerprint || hasPendingEventForm
)
const { confirmLeave } = useRegisterUnsavedChanges(
`log-entry-${entryId}`,
!readOnly && !loading && isDirty
)
const handleBack = async () => {
if (!(await confirmLeave())) return
onBack()
}
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
if (readOnly) return
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryData = {
...buildPayloadForSigning(eventsOverride),
signSkipper: normalizedSerializedSignature(signSkipper),
signCrew: normalizedSerializedSignature(signCrew)
}
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
setSavedFingerprint(JSON.stringify({
...buildPayloadForSigning(eventsOverride),
signSkipper: fingerprintSignature(signSkipper),
signCrew: fingerprintSignature(signCrew)
}))
}, [
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
@@ -215,8 +384,11 @@ export default function LogEntryEditor({
useEffect(() => {
getLogbookAccess(logbookId).then((access) => {
if (!access) return
setCanSignSkipper(access.isOwner || access.role === 'WRITE')
setHasWriteCollaborators(access.writeCollaboratorCount > 0)
setCanSignSkipper(access.isOwner)
setCanSignCrew(
access.role === 'WRITE' ||
(access.isOwner && access.writeCollaboratorCount === 0)
)
})
}, [logbookId])
@@ -253,13 +425,17 @@ export default function LogEntryEditor({
if (entryHash !== lockedContentHashRef.current) {
lockedContentHashRef.current = null
setSignSkipper('')
setSignCrew('')
if (lastSignatureAlertHashRef.current !== entryHash) {
const hadSkipper = !!signSkipper
const hadCrew = !!signCrew
const skipperOnly = skipCrewSignClearRef.current
skipCrewSignClearRef.current = false
if (hadSkipper) setSignSkipper('')
if (hadCrew && !skipperOnly) setSignCrew('')
if (lastSignatureAlertHashRef.current !== entryHash && (hadSkipper || (hadCrew && !skipperOnly))) {
lastSignatureAlertHashRef.current = entryHash
void showAlertRef.current(
t('logs.sign_cleared_re_sign'),
t('logs.sign_cleared_re_sign_title')
skipperOnly ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'),
skipperOnly ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title')
)
}
}
@@ -278,6 +454,7 @@ export default function LogEntryEditor({
const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash)
const handlePasskeySignSkipper = async () => {
if (!canSignSkipper) return
const confirmed = await confirmSignWarning()
if (!confirmed) return
@@ -295,6 +472,7 @@ export default function LogEntryEditor({
}
const handlePasskeySignCrew = async () => {
if (!canSignCrew) return
const confirmed = await confirmSignWarning()
if (!confirmed) return
@@ -359,6 +537,7 @@ export default function LogEntryEditor({
async function loadEntry() {
setLoading(true)
setError(null)
setSavedFingerprint(null)
lockedContentHashRef.current = null
contentReadyRef.current = false
lastSignatureAlertHashRef.current = null
@@ -385,7 +564,8 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || [])
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
return
}
@@ -417,7 +597,8 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || [])
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
}
}
} catch (err: any) {
@@ -541,24 +722,19 @@ export default function LogEntryEditor({
return
}
const apiKey = localStorage.getItem('owm_api_key')
if (!apiKey) {
showAlert('GPS capturing failed, and no OpenWeatherMap API key is configured to perform location lookup.')
return
}
try {
const res = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(locationQuery)}&appid=${apiKey}&units=metric`
)
if (!res.ok) throw new Error('Location not found')
const data = await res.json()
if (data.coord) {
setEvGpsLat(Number(data.coord.lat).toFixed(6))
setEvGpsLng(Number(data.coord.lon).toFixed(6))
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
const coord = data.coord as { lat?: number; lon?: number } | undefined
if (coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
setEvGpsLng(Number(coord.lon).toFixed(6))
showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`)
}
} catch (e) {
if (e instanceof WeatherApiError && e.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
}
showAlert('Failed to retrieve GPS location or look up coordinates by location name.')
}
}
@@ -597,35 +773,26 @@ export default function LogEntryEditor({
return
}
const apiKey = localStorage.getItem('owm_api_key')
if (!apiKey) {
showAlert(t('settings.no_key'))
return
}
setWeatherLoading(true)
try {
let url = ''
if (hasGps) {
url = `https://api.openweathermap.org/data/2.5/weather?lat=${evGpsLat}&lon=${evGpsLng}&appid=${apiKey}&units=metric`
} else {
url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(fallbackLocation)}&appid=${apiKey}&units=metric`
}
const res = await fetch(url)
if (!res.ok) throw new Error('Weather API rejected the request')
const data = await res.json()
const data = await fetchOpenWeatherCurrent(
hasGps
? { lat: evGpsLat, lon: evGpsLng }
: { q: fallbackLocation }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
// If fetched by location, automatically pre-fill GPS coordinates
if (!hasGps && data.coord) {
setEvGpsLat(Number(data.coord.lat).toFixed(6))
setEvGpsLng(Number(data.coord.lon).toFixed(6))
if (!hasGps && coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
setEvGpsLng(Number(coord.lon).toFixed(6))
}
const wind = data.wind as { speed?: number; deg?: number } | undefined
const main = data.main as { pressure?: number } | undefined
// Convert wind speed m/s to Beaufort scale
const mps = data.wind.speed || 0
const mps = wind?.speed || 0
let bft = 0
if (mps < 0.3) bft = 0
else if (mps < 1.6) bft = 1
@@ -642,22 +809,27 @@ export default function LogEntryEditor({
else bft = 12
setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`)
setEvWindPressure(String(data.main.pressure || ''))
setEvWindPressure(String(main?.pressure || ''))
// Calculate wind compass direction sector
if (data.wind.deg !== undefined) {
const deg = data.wind.deg
if (wind?.deg !== undefined) {
const deg = wind.deg
const sectors = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
const index = Math.round(deg / 22.5) % 16
setEvWindDirection(sectors[index])
}
if (data.weather && data.weather[0]) {
setEvWeatherIcon(data.weather[0].icon)
if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
const first = data.weather[0] as { icon?: string }
if (first.icon) setEvWeatherIcon(first.icon)
}
showAlert(t('settings.weather_success'))
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
}
console.error('Weather prefilling failed:', err)
showAlert(t('settings.weather_error'))
} finally {
@@ -692,32 +864,7 @@ export default function LogEntryEditor({
return currentItems.includes(item.toLowerCase())
}
const handleAddEvent = (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !evTime) return
const newEvent: LogEvent = {
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
}
setEvents((prev) => [...prev, newEvent])
// Clear event form fields
const clearEventForm = () => {
setEvTime('')
setEvMgk('')
setEvRwk('')
@@ -735,11 +882,102 @@ export default function LogEntryEditor({
setEvGpsLng('')
setEvRemarks('')
setEvLocationName('')
setEditingEventIndex(null)
}
const handleDeleteEvent = (index: number) => {
const fillEventForm = (ev: LogEvent) => {
const normalized = normalizeLogEvent(ev)
setEvTime(normalized.time)
setEvMgk(normalized.mgk)
setEvRwk(normalized.rwk)
setEvWindPressure(normalized.windPressure)
setEvWindDirection(normalized.windDirection)
setEvWindStrength(normalized.windStrength)
setEvSeaState(normalized.seaState)
setEvWeatherIcon(normalized.weatherIcon)
setEvCurrent(normalized.current)
setEvHeel(normalized.heel)
setEvSailsOrMotor(normalized.sailsOrMotor)
setEvLogReading(normalized.logReading)
setEvDistance(normalized.distance)
setEvGpsLat(normalized.gpsLat)
setEvGpsLng(normalized.gpsLng)
setEvRemarks(normalized.remarks)
setEvLocationName('')
}
const markSkipperSignatureClearedForEventChange = () => {
if (!signSkipper) return
skipCrewSignClearRef.current = true
setSignSkipper('')
}
const handleEditEvent = (index: number) => {
if (readOnly) return
setEvents((prev) => prev.filter((_, idx) => idx !== index))
const ev = events[index]
if (!ev) return
fillEventForm(ev)
setEditingEventIndex(index)
}
const handleCancelEventEdit = () => {
clearEventForm()
}
const handleSaveEvent = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !evTime) return
const eventData = buildEventFromForm()
const isEdit = editingEventIndex !== null
const hadSkipperSignature = isEdit && !!signSkipper
if (hadSkipperSignature) {
markSkipperSignatureClearedForEventChange()
}
const nextEvents = applyEventFormToEvents(eventData)
try {
await persistEntryToDb(nextEvents)
setEvents(nextEvents)
clearEventForm()
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
} catch (err: any) {
console.error('Failed to auto-save event:', err)
setError(err.message || 'Failed to save event.')
}
}
const handleDeleteEvent = async (index: number) => {
if (readOnly) return
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange()
const nextEvents = events.filter((_, idx) => idx !== index)
setEvents(nextEvents)
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
if (editingEventIndex === index) {
clearEventForm()
} else if (editingEventIndex !== null && index < editingEventIndex) {
setEditingEventIndex(editingEventIndex - 1)
}
try {
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save after event delete:', err)
setError(err.message || 'Failed to save event deletion.')
}
}
const handleDownloadPdf = async () => {
@@ -759,44 +997,27 @@ export default function LogEntryEditor({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly) return
let eventsToSave = events
if (hasPendingEventForm) {
const isEdit = editingEventIndex !== null
if (isEdit && signSkipper) {
markSkipperSignatureClearedForEventChange()
}
eventsToSave = applyEventFormToEvents(buildEventFromForm())
setEvents(eventsToSave)
clearEventForm()
} else if (!isDirty) {
return
}
setSaving(true)
setError(null)
setSuccess(false)
try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryPayload = buildPayloadForSigning()
const entryData = {
...entryPayload,
signSkipper: serializeSignature(signSkipper),
signCrew: serializeSignature(signCrew)
}
// E2E encrypt
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
// Save locally
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
// Queue for background sync
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
await persistEntryToDb(eventsToSave)
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
@@ -804,8 +1025,6 @@ export default function LogEntryEditor({
setSuccess(false)
onBack()
}, 1500)
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to save entry details:', err)
setError(err.message || 'Failed to save entry details.')
@@ -829,7 +1048,7 @@ export default function LogEntryEditor({
<div className="form-card" style={{ paddingBottom: '20px' }}>
<div className="section-title-bar">
<div className="section-title-left">
<button className="btn-back" onClick={onBack} style={{ padding: '6px 12px' }}>
<button className="btn-back" onClick={() => void handleBack()} style={{ padding: '6px 12px' }}>
<ChevronLeft size={16} />
{t('logs.back_to_list')}
</button>
@@ -849,7 +1068,7 @@ export default function LogEntryEditor({
style={{ width: 'auto', padding: '8px 16px' }}
>
<Download size={16} />
<span>{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span>
<span className="hide-mobile">{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span>
</button>
</div>
</div>
@@ -910,6 +1129,20 @@ export default function LogEntryEditor({
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.motor_hours')}</label>
<input
type="number"
className="input-text"
value={motorHours}
onChange={(e) => setMotorHours(e.target.value)}
disabled={saving || readOnly}
min="0"
step="0.1"
placeholder="0"
/>
</div>
</div>
</div>
@@ -1020,6 +1253,22 @@ export default function LogEntryEditor({
aria-readonly="true"
/>
</div>
<div className="input-group">
<label>{t('logs.fuel_per_motor_hour')}</label>
<input
type="text"
className="input-text consumption-value"
value={
fuelPerMotorHour != null
? `${formatFuelPerMotorHour(fuelPerMotorHour)} L/h`
: '—'
}
readOnly
tabIndex={-1}
aria-readonly="true"
/>
</div>
</div>
</div>
</div>
@@ -1079,8 +1328,22 @@ export default function LogEntryEditor({
</td>
<td className="remarks-td">{ev.remarks}</td>
{!readOnly && (
<td>
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
<td className="events-actions-td">
<button
type="button"
className="btn-icon"
onClick={() => handleEditEvent(idx)}
title={t('logs.edit_event')}
disabled={editingEventIndex !== null && editingEventIndex !== idx}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => handleDeleteEvent(idx)}
title={t('logs.delete_event')}
>
<Trash2 size={14} />
</button>
</td>
@@ -1095,7 +1358,9 @@ export default function LogEntryEditor({
{/* Add New Event Form Sub-Card */}
{!readOnly && (
<div className="member-editor-card glass">
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>{t('logs.add_event')}</h4>
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>
{editingEventIndex !== null ? t('logs.edit_event') : t('logs.add_event')}
</h4>
<div className="form-grid mb-4">
<div className="input-group">
@@ -1329,16 +1594,30 @@ export default function LogEntryEditor({
</div>
</div>
<button
type="button"
className="btn secondary"
onClick={handleAddEvent}
disabled={saving || !evTime}
style={{ width: 'auto', padding: '10px 20px', marginLeft: 'auto', display: 'flex' }}
>
<Plus size={16} />
{t('logs.add_event_btn')}
</button>
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto', flexWrap: 'wrap' }}>
{editingEventIndex !== null && (
<button
type="button"
className="btn secondary"
onClick={handleCancelEventEdit}
disabled={saving}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
<X size={16} />
{t('logs.cancel_event_edit')}
</button>
)}
<button
type="button"
className="btn secondary"
onClick={handleSaveEvent}
disabled={saving || !evTime}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
{editingEventIndex !== null ? t('logs.save_event_btn') : t('logs.add_event_btn')}
</button>
</div>
</div>
)}
</div>
@@ -1394,15 +1673,16 @@ export default function LogEntryEditor({
)}
</span>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<div className="track-actions">
<button
type="button"
className="btn secondary"
onClick={() => downloadTrackFile(savedTrack)}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
title={t('logs.gps_tracking_btn_gpx')}
>
<Download size={14} />
{t('logs.gps_tracking_btn_gpx')}
<span className="hide-mobile">{t('logs.gps_tracking_btn_gpx')}</span>
</button>
{!readOnly && (
<button
@@ -1410,9 +1690,10 @@ export default function LogEntryEditor({
className="btn secondary"
onClick={handleDeleteTrack}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
title={t('logs.gps_track_delete')}
>
<Trash2 size={14} />
{t('logs.gps_track_delete')}
<span className="hide-mobile">{t('logs.gps_track_delete')}</span>
</button>
)}
</div>
@@ -1473,13 +1754,40 @@ export default function LogEntryEditor({
disabled={saving}
isOnline={isOnline}
canSignSkipper={canSignSkipper}
hasWriteCollaborators={hasWriteCollaborators}
canSignCrew={canSignCrew}
signSkipper={signSkipper}
signCrew={signCrew}
skipperSignatureValid={skipperSignatureValid}
crewSignatureValid={crewSignatureValid}
onSignSkipperChange={setSignSkipper}
onSignCrewChange={setSignCrew}
onSignSkipperChange={(value) => {
if (canSignSkipper && !readOnly) setSignSkipper(value)
}}
onSignCrewChange={(value) => {
if (!canSignCrew || readOnly) return
if (!value) {
setSignCrew('')
return
}
if (isPasskeySignature(value) || isClassicSignature(value)) {
setSignCrew(value)
return
}
if (!canSignSkipper) {
const userId = localStorage.getItem('active_userid') || ''
const username = localStorage.getItem('active_username') || ''
if (userId && username) {
setSignCrew(createClassicSignature({
role: 'crew',
userId,
username,
signedAt: new Date().toISOString(),
payload: value
}))
return
}
}
setSignCrew(value)
}}
onPasskeySignSkipper={handlePasskeySignSkipper}
onPasskeySignCrew={handlePasskeySignCrew}
onBeforeSign={confirmSignWarning}
@@ -1495,7 +1803,7 @@ export default function LogEntryEditor({
</div>
)}
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}>
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim() || !isDirty}>
<Save size={18} />
{saving ? t('logs.saving') : t('logs.save')}
</button>
+102 -84
View File
@@ -58,6 +58,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const handleExportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleExport()
}
const handleImportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleRestore()
}
const handleExport = async () => {
setError(null)
setSuccess(null)
@@ -209,40 +219,45 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</h4>
<p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p>
<div className="input-group">
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-export-passphrase"
type="password"
className="input-text"
value={exportPassphrase}
onChange={(e) => setExportPassphrase(e.target.value)}
placeholder={t('settings.backup_passphrase_placeholder')}
autoComplete="new-password"
disabled={exporting}
/>
</div>
<div className="input-group">
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
<input
id="backup-export-confirm"
type="password"
className="input-text"
value={exportConfirm}
onChange={(e) => setExportConfirm(e.target.value)}
autoComplete="new-password"
disabled={exporting}
/>
</div>
<button
type="button"
className="btn primary"
onClick={handleExport}
disabled={exporting || !exportPassphrase || !exportConfirm}
>
<Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button>
<form onSubmit={handleExportSubmit} className="backup-export-form">
<div className="input-group">
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-export-passphrase"
name="backup-export-passphrase"
type="password"
className="input-text"
value={exportPassphrase}
onChange={(e) => setExportPassphrase(e.target.value)}
placeholder={t('settings.backup_passphrase_placeholder')}
autoComplete="new-password"
disabled={exporting}
required
/>
</div>
<div className="input-group">
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
<input
id="backup-export-confirm"
name="backup-export-confirm"
type="password"
className="input-text"
value={exportConfirm}
onChange={(e) => setExportConfirm(e.target.value)}
autoComplete="new-password"
disabled={exporting}
required
/>
</div>
<button
type="submit"
className="btn primary"
disabled={exporting || !exportPassphrase || !exportConfirm}
>
<Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button>
</form>
</section>
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
@@ -252,58 +267,61 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
</h4>
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
<div className="input-group">
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
<input
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok.json,application/json"
className="input-text"
onChange={handleFileChange}
disabled={importing}
/>
</div>
<form onSubmit={handleImportSubmit} className="backup-import-form">
<div className="input-group">
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
<input
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok.json,application/json"
className="input-text"
onChange={handleFileChange}
disabled={importing}
/>
</div>
{importFile && (
<>
<div className="input-group">
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-import-passphrase"
type="password"
className="input-text"
value={importPassphrase}
onChange={(e) => {
setImportPassphrase(e.target.value)
setImportPreview(null)
}}
autoComplete="current-password"
disabled={importing}
/>
</div>
{importFile && (
<>
<div className="input-group">
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-import-passphrase"
name="backup-import-passphrase"
type="password"
className="input-text"
value={importPassphrase}
onChange={(e) => {
setImportPassphrase(e.target.value)
setImportPreview(null)
}}
autoComplete="current-password"
disabled={importing}
required
/>
</div>
<div className="backup-actions-row">
<button
type="button"
className="btn secondary"
onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase}
>
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button>
<button
type="button"
className="btn primary"
onClick={() => handleRestore()}
disabled={importing || !importPassphrase}
>
<Upload size={16} />
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
</button>
</div>
</>
)}
<div className="backup-actions-row">
<button
type="button"
className="btn secondary"
onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase}
>
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button>
<button
type="submit"
className="btn primary"
disabled={importing || !importPassphrase}
>
<Upload size={16} />
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
</button>
</div>
</>
)}
</form>
{importPreview && (
<div className="backup-preview glass">
+16 -5
View File
@@ -4,12 +4,14 @@ import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
@@ -98,7 +100,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
}
const handleLogout = () => {
logoutUser()
void logoutUser()
onLogout()
}
@@ -176,7 +178,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<div className="header-brand">
<Ship className="header-logo" size={32} />
<div>
<h1>{t('app.name')}</h1>
<div className="header-brand-title-row">
<h1>{t('app.name')}</h1>
<BetaBadge />
</div>
<p className="subtitle">{t('app.tagline')}</p>
</div>
</div>
@@ -205,9 +210,13 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div>
{/* Skipper profile */}
<div className="skipper-badge">
<User size={16} />
<span>{username}</span>
<div
className="skipper-badge"
title={t('dashboard.logged_in_as', { name: username })}
aria-label={t('dashboard.logged_in_as', { name: username })}
>
<User size={16} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</div>
{/* Lang toggle */}
@@ -217,6 +226,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<DisclaimerHeaderButton />
<FeedbackHeaderButton />
{/* Logout */}
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
-1
View File
@@ -233,7 +233,6 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
<input
type="file"
accept="image/*"
capture="environment"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Bell, BellOff } from 'lucide-react'
import {
disableCollaboratorChangePush,
enableCollaboratorChangePush,
fetchPushPrefs,
getNotificationPermission,
isPushSupported
} from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { useDialog } from './ModalDialog.tsx'
export default function PushNotificationSettings() {
const { t } = useTranslation()
const { showAlert } = useDialog()
const [enabled, setEnabled] = useState(false)
const [loading, setLoading] = useState(true)
const [toggling, setToggling] = useState(false)
const supported = isPushSupported()
const permission = getNotificationPermission()
const iosNeedsInstall = isIosDevice() && !isRunningStandalone()
const loadPrefs = useCallback(async () => {
if (!supported) {
setLoading(false)
return
}
try {
const prefs = await fetchPushPrefs()
setEnabled(prefs.collaboratorChangesEnabled)
} catch (err) {
console.error('Failed to load push prefs:', err)
} finally {
setLoading(false)
}
}, [supported])
useEffect(() => {
void loadPrefs()
}, [loadPrefs])
const handleToggle = async (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.checked
setToggling(true)
try {
if (next) {
await enableCollaboratorChangePush()
setEnabled(true)
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
} else {
await disableCollaboratorChangePush()
setEnabled(false)
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('settings.push_error')
showAlert(message)
void loadPrefs()
} finally {
setToggling(false)
}
}
if (!supported) {
return (
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<BellOff size={20} style={{ color: '#94a3b8' }} />
<h3 style={{ margin: 0, color: '#94a3b8', fontSize: '16px' }}>{t('settings.push_title')}</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: 0 }}>
{t('settings.push_unsupported')}
</p>
</div>
)
}
return (
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Bell size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.push_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.push_desc')}
</p>
{iosNeedsInstall && (
<p className="text-muted" style={{ fontSize: '13px', margin: '0 0 12px 0' }}>
{t('settings.push_ios_install_hint')}
</p>
)}
{permission === 'denied' && (
<p style={{ fontSize: '13px', color: '#f87171', margin: '0 0 12px 0' }}>
{t('settings.push_denied_hint')}
</p>
)}
<label
className="switch-label"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: loading || toggling || iosNeedsInstall ? 'not-allowed' : 'pointer',
fontSize: '14px',
color: '#f1f5f9',
opacity: loading || iosNeedsInstall ? 0.6 : 1
}}
>
<input
type="checkbox"
checked={enabled}
onChange={handleToggle}
disabled={loading || toggling || iosNeedsInstall}
style={{ width: '18px', height: '18px', cursor: 'inherit' }}
/>
<span>{t('settings.push_enable')}</span>
</label>
{enabled && permission === 'granted' && (
<p className="text-muted" style={{ fontSize: '12px', margin: '12px 0 0 0' }}>
{t('settings.push_active')}
</p>
)}
</div>
)
}
+2
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogEntriesList from './LogEntriesList.tsx'
@@ -124,6 +125,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
}
setGpsTracks(decGpsTracks)
trackPlausibleEvent(PlausibleEvents.PUBLIC_LINK_OPENED)
} catch (err: any) {
console.error(err)
+24 -38
View File
@@ -5,11 +5,13 @@ import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx'
import { useDialog } from './ModalDialog.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import ThemedSelect from './ThemedSelect.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiFetch } from '../services/api.js'
interface SettingsFormProps {
logbookId?: string | null
@@ -66,15 +68,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const loadShareLink = async () => {
if (!logbookId) return
setLoadingShareLink(true)
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
try {
const res = await fetch(`/api/collaboration/share-link?logbookId=${logbookId}`, {
headers: {
'X-User-Id': userId
}
})
const res = await apiFetch(`/api/collaboration/share-link?logbookId=${logbookId}`)
if (res.ok) {
const data = await res.json()
@@ -98,17 +95,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const handleToggleShare = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!logbookId) return
const checked = e.target.checked
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
setLoadingShareLink(true)
try {
const res = await fetch('/api/collaboration/share-link', {
const res = await apiFetch('/api/collaboration/share-link', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ logbookId, enabled: checked })
})
@@ -119,6 +111,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const logbookKey = await ensureLogbookKey(logbookId)
const hexKey = bufferToHex(logbookKey)
setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`)
trackPlausibleEvent(PlausibleEvents.LOGBOOK_SHARED)
showAlert('Public share link enabled!')
} else {
setShareEnabled(false)
@@ -148,15 +141,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const loadCollaborators = async () => {
setLoadingCollabs(true)
setCollabError(null)
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
try {
const res = await fetch(`/api/collaboration/collaborators?logbookId=${logbookId}`, {
headers: {
'X-User-Id': userId
}
})
const res = await apiFetch(`/api/collaboration/collaborators?logbookId=${logbookId}`)
if (res.status === 403) {
setIsOwner(false)
@@ -183,20 +171,15 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
if (!logbookId) return
setGeneratingInvite(true)
setInviteLink('')
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
try {
// 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed)
const logbookKey = await ensureLogbookKey(logbookId)
// 2. Create invite token on server
const res = await fetch('/api/collaboration/invite', {
const res = await apiFetch('/api/collaboration/invite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ logbookId, role: 'WRITE' })
})
@@ -229,16 +212,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
const handleRevoke = async (collabId: string, collName: string) => {
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!localStorage.getItem('active_userid')) return
if (await showConfirm(t('logs.revoke_confirm'), collName, t('logs.confirm_yes'), t('logs.confirm_no'))) {
try {
const res = await fetch(`/api/collaboration/collaborators/${collabId}`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
const res = await apiFetch(`/api/collaboration/collaborators/${collabId}`, {
method: 'DELETE'
})
if (res.ok) {
@@ -297,6 +276,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
<form onSubmit={handleSubmit} className="vessel-form mt-6">
<PwaInstallPrompt variant="inline" />
<PushNotificationSettings />
{/* Weather Integration card */}
<div className="member-editor-card glass">
@@ -313,12 +293,14 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</label>
<input
id="owm-api-key"
name="owm-api-key"
type="password"
className="input-text"
placeholder="e.g. 8b6a7f...d8"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={saving}
autoComplete="off"
/>
</div>
</div>
@@ -419,6 +401,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
{t('settings.share_desc')}
</p>
<p className="signature-lock-notice" style={{ marginBottom: '16px' }}>
{t('settings.share_privacy_warning')}
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
<input
@@ -434,7 +420,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{shareEnabled && shareLink && (
<div className="input-group mb-4" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div className="input-group mb-4 copy-link-row">
<input
type="text"
readOnly
@@ -475,7 +461,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
{t('logs.invite_link_desc')}
</p>
<div className="form-actions" style={{ justifyContent: 'flex-start', gap: '12px', marginBottom: '20px' }}>
<div className="form-actions form-actions--start" style={{ gap: '12px', marginBottom: '20px' }}>
<button
type="button"
className="btn primary"
@@ -489,7 +475,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{inviteLink && (
<div className="input-group mb-6" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div className="input-group mb-6 copy-link-row">
<input
type="text"
readOnly
+27 -9
View File
@@ -4,7 +4,7 @@ import { Check } from 'lucide-react'
import SignaturePad from './SignaturePad.tsx'
import PasskeySignButton from './PasskeySignButton.tsx'
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
import { isPasskeySignature } from '../utils/signatures.js'
import { isPasskeySignature, getSignaturePayload, getSignatureAttribution } from '../utils/signatures.js'
type SignatureMode = 'passkey' | 'classic'
@@ -13,7 +13,7 @@ interface SignatureSectionProps {
disabled?: boolean
isOnline: boolean
canSignSkipper: boolean
hasWriteCollaborators: boolean
canSignCrew: boolean
signSkipper: SignatureValue | ''
signCrew: SignatureValue | ''
skipperSignatureValid: boolean
@@ -25,14 +25,30 @@ interface SignatureSectionProps {
onBeforeSign?: () => Promise<boolean>
}
function SignerAttributionBadge({ value }: { value: SignatureValue | '' }) {
const { t, i18n } = useTranslation()
const attribution = getSignatureAttribution(value)
if (!attribution) return null
const formattedDate = new Date(attribution.signedAt).toLocaleString(
i18n.language === 'de' ? 'de-DE' : 'en-GB'
)
return (
<div className="passkey-sign-badge valid signature-attribution-badge">
<span>{t('logs.sign_passkey_signed', { username: attribution.username })}</span>
<span className="passkey-sign-date">{formattedDate}</span>
</div>
)
}
function padValue(value: SignatureValue | ''): string {
if (!value || isPasskeySignature(value)) return ''
return value
return getSignaturePayload(value)
}
function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode {
if (isPasskeySignature(value)) return 'passkey'
if (value) return 'classic'
if (getSignaturePayload(value)) return 'classic'
return passkeyAvailable ? 'passkey' : 'classic'
}
@@ -108,6 +124,7 @@ function RoleSignatureBlock({
}
return (
<div className="signature-role-block">
<SignerAttributionBadge value={value} />
<SignaturePad
id={padId}
label={roleLabel}
@@ -162,6 +179,7 @@ function RoleSignatureBlock({
{showClassicPanel && (
<>
<SignerAttributionBadge value={value} />
<SignaturePad
id={padId}
label={roleLabel}
@@ -189,7 +207,7 @@ export default function SignatureSection({
disabled = false,
isOnline,
canSignSkipper,
hasWriteCollaborators,
canSignCrew,
signSkipper,
signCrew,
skipperSignatureValid,
@@ -203,7 +221,7 @@ export default function SignatureSection({
const { t } = useTranslation()
const showSkipperPasskey = canSignSkipper && isOnline
const showCrewPasskey = hasWriteCollaborators && isOnline
const showCrewPasskey = canSignCrew && isOnline
const hasSignature = !!(signSkipper || signCrew)
return (
@@ -228,7 +246,7 @@ export default function SignatureSection({
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
signatureValid={skipperSignatureValid}
showPasskey={showSkipperPasskey}
readOnly={readOnly}
readOnly={readOnly || !canSignSkipper}
disabled={disabled}
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined}
@@ -245,7 +263,7 @@ export default function SignatureSection({
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
signatureValid={crewSignatureValid}
showPasskey={showCrewPasskey}
readOnly={readOnly}
readOnly={readOnly || !canSignCrew}
disabled={disabled}
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
onChange={onSignCrewChange}
+84 -2
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge } from 'lucide-react'
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge, Timer } from 'lucide-react'
import MultiTrackMap from './MultiTrackMap.tsx'
import {
formatLiters,
formatHours,
formatNm,
loadAccountStats,
loadLogbookStats,
@@ -12,6 +13,7 @@ import {
type TravelDayStats
} from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
interface StatsDashboardProps {
logbookId: string
@@ -78,12 +80,26 @@ function TotalsGrid({ totals }: { totals: StatsTotals }) {
value={formatNm(totals.motorDistanceNm)}
unit={t('stats.unit_nm')}
/>
<KpiCard
icon={<Timer size={20} />}
label={t('stats.motor_hours_total')}
value={formatHours(totals.totalMotorHours)}
unit={t('stats.unit_h')}
/>
<KpiCard
icon={<Fuel size={20} />}
label={t('stats.fuel_total')}
value={formatLiters(totals.totalFuelL)}
unit={t('stats.unit_l')}
/>
{totals.fuelPerMotorHourL != null && (
<KpiCard
icon={<Timer size={20} />}
label={t('stats.fuel_per_motor_hour')}
value={formatFuelPerMotorHour(totals.fuelPerMotorHourL)}
unit={`${t('stats.unit_l')}/${t('stats.unit_h')}`}
/>
)}
<KpiCard
icon={<Droplets size={20} />}
label={t('stats.water_total')}
@@ -247,6 +263,36 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
/>
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
<p className="stats-section-sub">
{t('stats.avg_motor_hours')}: {formatHours(totals.avgMotorHoursPerDay)} {t('stats.unit_h')}
{totals.fuelPerMotorHourL != null && (
<>
{' · '}
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</>
)}
</p>
<DailyBarChart
days={travelDays}
valueFn={(d) => d.motorHours}
barClass="stats-bar--motor-hours"
formatValue={formatHours}
/>
{travelDays.some((d) => d.fuelPerMotorHourL != null) && (
<>
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
<DailyBarChart
days={travelDays}
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
barClass="stats-bar--fuel-per-hour"
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
/>
</>
)}
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
<p className="stats-section-sub">
@@ -256,6 +302,9 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
{totals.fuelPerNmL != null && (
<> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}</>
)}
{totals.fuelPerMotorHourL != null && (
<> · {t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}</>
)}
</p>
<ConsumptionChart days={travelDays} />
</div>
@@ -310,7 +359,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
}, [accountStats])
return (
<div className="form-card">
<div className="form-card" data-tour="stats-dashboard">
<div className="form-header">
<BarChart2 size={24} className="form-icon" />
<div>
@@ -367,6 +416,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<th>{t('stats.travel_days')}</th>
<th>{t('stats.total_distance')}</th>
<th>{t('stats.fuel_total')}</th>
<th>{t('stats.motor_hours_total')}</th>
<th>{t('stats.water_total')}</th>
</tr>
</thead>
@@ -377,6 +427,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<td>{lb.totals.travelDayCount}</td>
<td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td>
<td>{formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')}</td>
<td>{formatHours(lb.totals.totalMotorHours)} {t('stats.unit_h')}</td>
<td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td>
</tr>
))}
@@ -397,8 +448,39 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
/>
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
{accountStats.totals.fuelPerMotorHourL != null && (
<p className="stats-section-sub">
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</p>
)}
<DailyBarChart
days={allAccountDays}
valueFn={(d) => d.motorHours}
barClass="stats-bar--motor-hours"
formatValue={formatHours}
/>
{allAccountDays.some((d) => d.fuelPerMotorHourL != null) && (
<>
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
<DailyBarChart
days={allAccountDays}
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
barClass="stats-bar--fuel-per-hour"
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
/>
</>
)}
</div>
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
{accountStats.totals.fuelPerMotorHourL != null && (
<p className="stats-section-sub">
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
</p>
)}
<ConsumptionChart days={allAccountDays} />
</div>
+50 -13
View File
@@ -27,11 +27,14 @@ export type TourStepId =
| 'entry_track'
| 'nav_vessel'
| 'nav_crew'
| 'nav_stats'
| 'nav_feedback'
| 'finish'
interface TourNavigation {
setActiveTab: (tab: AppTab) => void
setSelectedEntryId: (entryId: string | null) => void
setFeedbackOpen: (open: boolean) => void
}
interface DemoTourContext {
@@ -55,7 +58,7 @@ interface AppTourContextValue {
requestStartAfterLogin: () => void
}
const STEP_ORDER: TourStepId[] = [
const FULL_STEP_ORDER: TourStepId[] = [
'welcome',
'nav_logs',
'entry_list',
@@ -63,16 +66,28 @@ const STEP_ORDER: TourStepId[] = [
'entry_track',
'nav_vessel',
'nav_crew',
'nav_stats',
'nav_feedback',
'finish'
]
/** Public demo has no stats/feedback UI — skip those steps. */
const DEMO_EXCLUDED_STEPS: TourStepId[] = ['nav_stats', 'nav_feedback']
const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter((id) => !DEMO_EXCLUDED_STEPS.includes(id))
function getStepOrder(demoMode: boolean): TourStepId[] {
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
}
const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
nav_logs: '[data-tour="nav-logs"]',
entry_list: '[data-tour="entry-list"]',
entry_open: '[data-tour="entry-first"]',
entry_track: '[data-tour="entry-track"]',
nav_vessel: '[data-tour="nav-vessel"]',
nav_crew: '[data-tour="nav-crew"]'
nav_crew: '[data-tour="nav-crew"]',
nav_stats: '[data-tour="stats-dashboard"]',
nav_feedback: '[data-tour="feedback-form"]'
}
const AppTourContext = createContext<AppTourContextValue | null>(null)
@@ -86,7 +101,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const demoContextRef = useRef<DemoTourContext | null>(null)
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null
const stepOrder = getStepOrder(isDemoTour)
const currentStepId = isActive ? stepOrder[stepIndex] ?? null : null
const resolveFirstEntryId = useCallback((): string | null => {
return demoContextRef.current?.firstEntryId ?? getStoredDemoFirstEntryId()
@@ -111,16 +127,29 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setSelectedEntryId(null)
nav.setActiveTab('crew')
}
if (stepId === 'nav_stats') {
nav.setSelectedEntryId(null)
nav.setActiveTab('stats')
}
if (stepId === 'nav_feedback') {
nav.setSelectedEntryId(null)
nav.setFeedbackOpen(true)
} else {
nav.setFeedbackOpen(false)
}
}, [resolveFirstEntryId])
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
if (!stepId) return
const selector = TARGET_BY_STEP[stepId]
if (!selector) return
window.requestAnimationFrame(() => {
const el = document.querySelector(selector)
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
})
const delayMs = stepId === 'nav_feedback' ? 180 : 0
window.setTimeout(() => {
window.requestAnimationFrame(() => {
const el = document.querySelector(selector)
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
})
}, delayMs)
}, [])
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
@@ -142,12 +171,18 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const tourProps = tourModeRef.current.demoMode ? { mode: 'demo' } : undefined
if (outcome === 'completed') {
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
const nav = navigationRef.current
if (nav && !tourModeRef.current.demoMode) {
nav.setSelectedEntryId(null)
nav.setActiveTab('stats')
}
} else {
const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome'
const step = getStepOrder(tourModeRef.current.demoMode)[stepIndexAtDismiss] ?? 'welcome'
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps })
}
tourModeRef.current = { demoMode: false }
navigationRef.current?.setFeedbackOpen(false)
setIsDemoTour(false)
setIsActive(false)
setStepIndex(0)
@@ -162,12 +197,13 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
}, [dismissTour, stepIndex])
const nextStep = useCallback(() => {
if (stepIndex + 1 >= STEP_ORDER.length) {
const order = getStepOrder(isDemoTour)
if (stepIndex + 1 >= order.length) {
dismissTour('completed', stepIndex)
return
}
setStepIndex(stepIndex + 1)
}, [dismissTour, stepIndex])
}, [dismissTour, isDemoTour, stepIndex])
const prevStep = useCallback(() => {
setStepIndex((current) => Math.max(0, current - 1))
@@ -175,11 +211,11 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (!isActive) return
const stepId = STEP_ORDER[stepIndex]
const stepId = getStepOrder(isDemoTour)[stepIndex]
if (!stepId) return
applyStepSideEffects(stepId)
scrollToCurrentTarget(stepId)
}, [isActive, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
const restartTour = useCallback(() => {
const userId = localStorage.getItem('active_userid')
@@ -220,7 +256,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
isDemoTour,
currentStepId,
currentStepIndex: stepIndex,
totalSteps: STEP_ORDER.length,
totalSteps: stepOrder.length,
startTour,
stopTour,
restartTour,
@@ -244,6 +280,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
skipTour,
startTour,
stepIndex,
stepOrder.length,
stopTour
]
)
@@ -0,0 +1,77 @@
import {
createContext,
useContext,
useCallback,
useEffect,
useRef,
useMemo,
type ReactNode
} from 'react'
import { useTranslation } from 'react-i18next'
import { useDialog } from '../components/ModalDialog.tsx'
interface UnsavedChangesContextValue {
setDirty: (source: string, dirty: boolean) => void
confirmLeave: () => Promise<boolean>
}
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null)
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const dirtySources = useRef(new Set<string>())
const setDirty = useCallback((source: string, dirty: boolean) => {
if (dirty) dirtySources.current.add(source)
else dirtySources.current.delete(source)
}, [])
const confirmLeave = useCallback(async (): Promise<boolean> => {
if (dirtySources.current.size === 0) return true
return showConfirm(
t('common.unsaved_changes_message'),
t('common.unsaved_changes_title'),
t('common.unsaved_changes_leave'),
t('common.unsaved_changes_stay')
)
}, [showConfirm, t])
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (dirtySources.current.size === 0) return
e.preventDefault()
e.returnValue = ''
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [])
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
return (
<UnsavedChangesContext.Provider value={value}>
{children}
</UnsavedChangesContext.Provider>
)
}
export function useUnsavedChangesContext(): UnsavedChangesContextValue {
const ctx = useContext(UnsavedChangesContext)
if (!ctx) {
throw new Error('useUnsavedChangesContext must be used within UnsavedChangesProvider')
}
return ctx
}
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
const { setDirty, confirmLeave } = useUnsavedChangesContext()
useEffect(() => {
setDirty(source, isDirty)
return () => setDirty(source, false)
}, [source, isDirty, setDirty])
return { confirmLeave }
}
+5 -1
View File
@@ -3,6 +3,7 @@ import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import enTranslation from './locales/en.json'
import deTranslation from './locales/de.json'
import { initSeo } from '../utils/seo.js'
i18n
.use(LanguageDetector)
@@ -17,9 +18,12 @@ i18n
escapeValue: false // React already escapes values (prevents XSS)
},
detection: {
order: ['localStorage', 'navigator'],
order: ['querystring', 'localStorage', 'navigator'],
lookupQuerystring: 'lng',
caches: ['localStorage']
}
})
initSeo(i18n)
export default i18n
+155 -55
View File
@@ -2,7 +2,15 @@
"translation": {
"app": {
"name": "Kapteins Daagbok",
"tagline": "Privates Yacht-Logbuch"
"tagline": "Privates Yacht-Logbuch",
"beta": "Beta",
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
},
"common": {
"unsaved_changes_title": "Ungespeicherte Änderungen",
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
"unsaved_changes_leave": "Verlassen",
"unsaved_changes_stay": "Bleiben"
},
"nav": {
"dashboard": "Dashboard",
@@ -22,43 +30,43 @@
"quick_login": "Schnell-Login",
"forget_account": "Account auf diesem Gerät vergessen",
"not_user": "Nicht {{name}}?",
"recovery_title": "Ihr Wiederherstellungsschlüssel",
"recovery_warning": "WICHTIG: Schreiben Sie diese 12 Wörter auf. Wenn Sie Ihren Passkey und diese Wörter verlieren, können Ihre Daten nicht wiederhergestellt werden.",
"recovery_title": "Dein Wiederherstellungsschlüssel",
"recovery_warning": "WICHTIG: Schreib diese 12 Wörter auf. Wenn du deinen Passkey und diese Wörter verlierst, können deine Daten nicht wiederhergestellt werden.",
"confirm_recovery": "Ich habe die Wörter aufgeschrieben",
"status_logged_in": "Angemeldet",
"status_logged_out": "Abgemeldet",
"copied": "Kopiert!",
"copy_phrase": "Schlüssel kopieren",
"enter_recovery": "Wiederherstellungsschlüssel eingeben",
"recovery_fallback_warning": "Ihr Passkey wurde erfolgreich authentifiziert, aber Ihr Gerät unterstützt keine hardwarebasierte Schlüsselableitung. Geben Sie Ihren 12-Wörter-Wiederherstellungsschlüssel ein, um Ihr Logbuch zu entschlüsseln.",
"recovery_placeholder": "Geben Sie Ihren aus 12 Wörtern bestehenden Wiederherstellungsschlüssel getrennt durch Leerzeichen ein...",
"recovery_fallback_warning": "Dein Passkey wurde erfolgreich authentifiziert, aber dein Gerät unterstützt keine hardwarebasierte Schlüsselableitung. Gib deinen 12-Wörter-Wiederherstellungsschlüssel ein, um dein Logbuch zu entschlüsseln.",
"recovery_placeholder": "Gib deinen aus 12 Wörtern bestehenden Wiederherstellungsschlüssel getrennt durch Leerzeichen ein...",
"back": "Zurück",
"decrypting": "Entschlüsselung...",
"decrypt_logbook": "Logbuch entschlüsseln",
"error_incorrect_recovery": "Falscher Wiederherstellungsschlüssel. Entschlüsselung fehlgeschlagen.",
"error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfen Sie Ihren Wiederherstellungsschlüssel.",
"error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfe deinen Wiederherstellungsschlüssel.",
"or_register": "oder Registrieren",
"explore_demo": "Demo ohne Account erkunden",
"username_placeholder": "Benutzername / Skippername",
"processing": "Verarbeitung...",
"help": "Hilfe",
"setup_pin_title": "Lokale PIN einrichten (Optional)",
"setup_pin_warning": "Da Ihr Gerät keine direkte Passkey-Schlüsselableitung unterstützt, müssten Sie andernfalls bei jedem Login auf diesem Gerät Ihren 12-Wörter-Schlüssel eingeben. Richten Sie eine lokale PIN ein, um das zu vermeiden.",
"setup_pin_warning": "Da dein Gerät keine direkte Passkey-Schlüsselableitung unterstützt, müsstest du andernfalls bei jedem Login auf diesem Gerät deinen 12-Wörter-Schlüssel eingeben. Richte eine lokale PIN ein, um das zu vermeiden.",
"pin_placeholder": "Z.B. 123456",
"pin_label": "Lokaler PIN-Code (4-8 Ziffern)",
"save_pin": "PIN speichern & Fortfahren",
"skip_pin": "Überspringen & recovery verwenden",
"enter_pin_title": "Mit PIN entschlüsseln",
"enter_pin_warning": "Geben Sie Ihre lokale PIN ein, um den Entschlüsselungsschlüssel auf diesem Gerät freizuschalten.",
"enter_pin_placeholder": "Geben Sie Ihre PIN ein...",
"enter_pin_warning": "Gib deine lokale PIN ein, um den Entschlüsselungsschlüssel auf diesem Gerät freizuschalten.",
"enter_pin_placeholder": "Gib deine PIN ein...",
"decrypt_with_pin": "Entschlüsseln",
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
},
"pwa": {
"title": "App installieren",
"generic_benefit": "Installieren Sie Kapteins Daagbok auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
"ios_instructions": "Auf dem iPad/iPhone: Fügen Sie die App zum Home-Bildschirm hinzu, damit Ihre Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
"generic_benefit": "Installiere Kapteins Daagbok auf deinem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
"ios_instructions": "Auf dem iPad/iPhone: Füge die App zum Home-Bildschirm hinzu, damit deine Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
"ios_step_share": "Teilen-Symbol in der Safari-Leiste antippen",
"ios_step_add": "„Zum Home-Bildschirm“ wählen",
"install_now": "Jetzt installieren",
@@ -102,7 +110,7 @@
"saved": "Schiffsdaten erfolgreich gespeichert!",
"loading": "Schiffsdaten werden geladen...",
"sails_list": "Besegelung (vorhandene Segel)",
"sails_help": "Tragen Sie hier die Segel ein, die an Eurem Schiff zur Verfügung stehen (z. B. Großsegel, Genua, Fock).",
"sails_help": "Trag hier die Segel ein, die an deinem Schiff zur Verfügung stehen (z. B. Großsegel, Genua, Fock).",
"add_sail": "Segel hinzufügen",
"sail_name_placeholder": "z. B. Großsegel",
"no_sails": "Keine Segel hinterlegt.",
@@ -116,6 +124,12 @@
"travel_details": "Reisedetails",
"add_event": "Neuen Logbucheintrag hinzufügen",
"add_event_btn": "Ereignis hinzufügen",
"edit_event": "Ereignis bearbeiten",
"save_event_btn": "Änderung speichern",
"cancel_event_edit": "Abbrechen",
"delete_event": "Ereignis löschen",
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
"date": "Datum",
"day_of_travel": "Tag der Reise / Reisetag",
"departure": "Start-Hafen (Reise von)",
@@ -137,6 +151,7 @@
"sign_passkey_signing": "Passkey wird angefordert…",
"sign_passkey_signed": "Freigegeben von {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Passkey-Freigabe entfernen",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Klassisch",
@@ -153,19 +168,19 @@
"sign_lock_notice": "Nach der Unterschrift sind Änderungen am Logbucheintrag (außer Fotos) nicht möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.",
"sign_lock_active": "Dieser Eintrag ist unterschrieben. Änderungen am Logbuch (außer Fotos) entfernen Skipper- und Crew-Unterschrift automatisch.",
"sign_lock_warning_title": "Unterschrift bestätigen",
"sign_lock_warning": "Nach dem Unterschreiben sind Änderungen am Logbucheintrag (außer Fotos) nicht mehr möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.\n\nMöchten Sie fortfahren?",
"sign_lock_warning": "Nach dem Unterschreiben sind Änderungen am Logbucheintrag (außer Fotos) nicht mehr möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.\n\nMöchtest du fortfahren?",
"sign_proceed": "Unterschreiben",
"sign_cancel": "Abbrechen",
"sign_cleared_re_sign_title": "Unterschriften entfernt",
"sign_cleared_re_sign": "Der Logbucheintrag wurde geändert. Skipper- und Crew-Unterschrift wurden entfernt. Bitte erneut unterschreiben.",
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstelle deinen ersten Reisetag!",
"back_to_list": "Zurück zur Journal-Liste",
"save": "Logbuchseite speichern",
"saving": "Wird gespeichert...",
"saved": "Logbuchseite erfolgreich gespeichert!",
"loading": "Journal wird geladen...",
"delete_entry": "Tag löschen",
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
"carry_over_tanks_yes": "Übernehmen",
@@ -190,6 +205,8 @@
"event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor",
"motor_propulsion": "Maschinenfahrt",
"motor_hours": "Maschinenstunden (gesamt)",
"fuel_per_motor_hour": "Verbrauch pro Maschinenstunde",
"event_distance": "Distanz (sm)",
"export_csv": "CSV herunterladen",
"share_csv": "CSV teilen",
@@ -201,16 +218,16 @@
"photo_btn": "Foto aufnehmen / Hochladen",
"photo_processing": "Wird verarbeitet...",
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
"photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?",
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
"confirm_yes": "Ja",
"confirm_no": "Nein",
"track_upload_title": "GPS-Track (Datei)",
"track_upload_points": "Punkte",
"gps_tracking_btn_gpx": "Track-Datei herunterladen",
"gps_track_upload_help": "Ziehen Sie eine GPX-, KML- oder GeoJSON-Datei hierher oder klicken Sie zum Auswählen",
"gps_track_upload_help": "Zieh eine GPX-, KML- oder GeoJSON-Datei hierher oder klicke zum Auswählen",
"gps_track_upload_btn": "GPS-Track hochladen",
"gps_track_delete": "Track-Datei löschen",
"gps_track_delete_confirm": "Sind Sie sicher, dass Sie diese Track-Datei dauerhaft löschen möchten?",
"gps_track_delete_confirm": "Bist du sicher, dass du diese Track-Datei dauerhaft löschen möchtest?",
"track_distance": "GPS-Strecke (sm)",
"track_speed_max": "Max. Geschwindigkeit (kn)",
"track_speed_avg": "Ø Geschwindigkeit (kn)",
@@ -224,38 +241,40 @@
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
"invite_crew": "Crew einladen",
"invite_link_copied": "Einladungslink in die Zwischenablage kopiert!",
"invite_link_desc": "Teilen Sie diesen Link mit Crewmitgliedern, um ihnen Schreibrechte für dieses Logbuch zu gewähren.",
"invite_link_desc": "Teile diesen Link mit Crewmitgliedern, um ihnen Schreibrechte für dieses Logbuch zu gewähren.",
"collaborators_list": "Mitglieder / Crew",
"revoke": "Entfernen",
"revoke_confirm": "Sind Sie sicher, dass Sie diesem Crewmitglied den Zugriff entziehen möchten?",
"revoke_confirm": "Bist du sicher, dass du diesem Crewmitglied den Zugriff entziehen möchtest?",
"invite_role": "Rolle",
"invite_expires": "Link ist 48 Stunden lang gültig"
},
"dashboard": {
"title": "Ihre Logbücher",
"subtitle": "Wählen Sie ein Logbuch aus oder erstellen Sie ein neues, um Ihre Reisen zu verwalten.",
"title": "Deine Logbücher",
"subtitle": "Wähle ein Logbuch aus oder erstelle ein neues, um deine Reisen zu verwalten.",
"create_btn": "Logbuch erstellen",
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
"logout": "Abmelden",
"delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstellen Sie vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls Sie die Daten später behalten möchten.",
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
"logged_in_as": "Angemeldet als {{name}}",
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls du die Daten später behalten möchtest.",
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...",
"status_synced": "Synchronisiert",
"status_local": "Nur lokaler Cache",
"delete_btn": "Logbuch löschen",
"section_owned": "Meine Logbücher",
"section_shared": "Geteilte Logbücher",
"section_shared_hint": "Sie wurden als Crew-Mitglied eingeladen. Skipper-Profil und Einstellungen gehören dem Eigner.",
"section_shared_hint": "Du wurdest als Crew-Mitglied eingeladen. Skipper-Profil und Einstellungen gehören dem Eigner.",
"role_owner": "Eigenes Logbuch",
"role_owner_hint": "Sie sind Eigner und Skipper dieses Logbuchs",
"role_owner_hint": "Du bist Eigner und Skipper dieses Logbuchs",
"role_crew": "Crew-Zugang",
"role_crew_hint": "Eingeladenes Logbuch — Sie können als Crew mitarbeiten und signieren",
"role_crew_hint": "Eingeladenes Logbuch — du kannst als Crew mitarbeiten und signieren",
"role_read": "Nur Lesen",
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung"
},
"crew": {
"title": "Skipper- & Crew-Profile",
"skipper_section": "Skipper-Profil",
"skipper_read_only_hint": "Das Skipper-Profil kann nur vom Logbuch-Eigner bearbeitet werden.",
"crew_section": "Crew-Liste",
"add_crew": "Crew-Mitglied hinzufügen",
"edit_crew": "Crew-Mitglied bearbeiten",
@@ -274,11 +293,11 @@
"save_member": "Mitglied speichern",
"saved": "Skipper-Profil erfolgreich gespeichert!",
"loading": "Crew-Dateien werden geladen...",
"delete_confirm": "Sind Sie sicher, dass Sie dieses Crew-Mitglied entfernen möchten?"
"delete_confirm": "Bist du sicher, dass du dieses Crew-Mitglied entfernen möchtest?"
},
"deviation": {
"title": "Ablenkungstabelle (Compass Deviation)",
"subtitle": "Tragen Sie die Magnetkompass-Ablenkung (Abl.) für Kurse (MgK) von 000° bis 360° in 10°-Schritten ein.",
"subtitle": "Trag die Magnetkompass-Ablenkung (Abl.) für Kurse (MgK) von 000° bis 360° in 10°-Schritten ein.",
"heading": "MgK",
"deviation": "Ablenkung",
"save": "Kalibrierungsgitter speichern",
@@ -288,18 +307,18 @@
},
"settings": {
"title": "Systemeinstellungen",
"subtitle": "Konfigurieren Sie externe Integrationen und Anmeldedaten.",
"subtitle": "Konfiguriere externe Integrationen und Anmeldedaten.",
"owm_title": "Wetter-Integration",
"owm_key": "OpenWeatherMap API-Schlüssel",
"save": "Konfiguration speichern",
"saving": "Wird gespeichert...",
"saved": "Einstellungen erfolgreich gespeichert!",
"key_help": "Ein API-Schlüssel wird benötigt, um Wetterparameter und Seebedingungen automatisch anhand von GPS-Koordinaten abzurufen.",
"no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.",
"key_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel in den Einstellungen oder kontaktiere den Betreiber.",
"weather_success": "Wetterdaten erfolgreich abgerufen!",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie den API-Schlüssel und die Verbindung.",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
"gps_error": "Bitte geben Sie einen Ort an oder ermitteln Sie die GPS-Koordinaten.",
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
"theme_title": "Design-Anpassung",
"theme_label": "Design-Stil der App",
"theme_auto": "Automatisch (OS-Erkennung)",
@@ -312,28 +331,37 @@
"color_scheme_light": "Hell",
"color_scheme_dark": "Dunkel",
"share_title": "Logbuch teilen (Schreibgeschützt)",
"share_desc": "Aktivieren Sie diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann Ihre Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
"share_desc": "Aktiviere diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann deine Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
"share_privacy_warning": "Empfehlung: Teile diesen Link nur privat (z. B. per E-Mail oder Messenger), nicht in sozialen Medien.",
"share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren",
"danger_zone_title": "Gefahrenzone",
"danger_zone_desc": "Durch das Löschen Ihres Kontos werden alle Ihre Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_account_btn": "Konto unwiderruflich löschen",
"delete_account_confirm_title": "Konto löschen?",
"delete_account_confirm_desc": "Sind Sie absolut sicher, dass Sie Ihr Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchten?",
"delete_account_confirm_desc": "Bist du absolut sicher, dass du dein Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchtest?",
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
"deleting_account": "Konto wird gelöscht…",
"tour_title": "App-Tour",
"tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.",
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
"tour_restart": "Tour erneut starten",
"push_title": "Push-Benachrichtigungen",
"push_desc": "Als Logbuch-Eigner wirst du benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
"push_enable": "Bei Crew-Änderungen benachrichtigen",
"push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
"backup_title": "Backup & Wiederherstellung",
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
"backup_export_title": "Backup erstellen",
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahren Sie Datei und Passphrase getrennt und sicher auf.",
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
"backup_restore_title": "Backup wiederherstellen",
"backup_restore_desc": "Stellt ein Backup in Ihrem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
"backup_passphrase": "Backup-Passphrase",
"backup_passphrase_placeholder": "Mindestens 8 Zeichen",
"backup_passphrase_confirm": "Passphrase bestätigen",
@@ -353,7 +381,7 @@
"backup_invalid_json": "Die Datei ist keine gültige JSON-Datei.",
"backup_invalid_format": "Unbekanntes oder veraltetes Backup-Format.",
"backup_not_owner": "Nur der Logbuch-Eigner kann Backups erstellen.",
"backup_not_authenticated": "Bitte melden Sie sich an, um ein Backup wiederherzustellen.",
"backup_not_authenticated": "Bitte melde dich an, um ein Backup wiederherzustellen.",
"backup_id_conflict": "Ein Logbuch mit dieser ID existiert bereits.",
"backup_overwrite_confirm": "Das vorhandene Logbuch mit gleicher ID wird ersetzt. Fortfahren?",
"backup_new_id_confirm": "Das Backup als neues Logbuch mit neuer ID importieren?",
@@ -365,13 +393,13 @@
},
"disclaimer": {
"title": "Wichtige Hinweise",
"intro": "Bitte lesen Sie die folgenden Hinweise, bevor Sie Kapteins Daagbok nutzen.",
"intro": "Bitte lies die folgenden Hinweise, bevor du Kapteins Daagbok nutzt.",
"e2e_title": "Ende-zu-Ende-Verschlüsselung",
"e2e_body": "Ihre Logbuchdaten werden Ende-zu-Ende verschlüsselt. Nur Sie bzw. Personen mit Ihrem Schlüssel können die Inhalte lesen. Auf dem Server werden ausschließlich verschlüsselte Daten gespeichert.",
"e2e_body": "Deine Logbuchdaten werden Ende-zu-Ende verschlüsselt. Nur du bzw. Personen mit deinem Schlüssel können die Inhalte lesen. Auf dem Server werden ausschließlich verschlüsselte Daten gespeichert.",
"pwa_title": "Progressive Web App (PWA)",
"pwa_body": "Kapteins Daagbok läuft als Progressive Web App in Ihrem Browser und kann auf Ihrem Gerät installiert werden ähnlich wie eine native App, ohne App-Store.",
"pwa_body": "Kapteins Daagbok läuft als Progressive Web App in deinem Browser und kann auf deinem Gerät installiert werden ähnlich wie eine native App, ohne App-Store.",
"storage_title": "Lokale Speicherung & Synchronisation",
"storage_body": "Ihre Daten werden lokal auf Ihrem Gerät zwischengespeichert (IndexedDB). Bei aktiver Internetverbindung werden Änderungen mit dem Server synchronisiert. Ohne Verbindung können Sie weiterarbeiten; die Synchronisation erfolgt später.",
"storage_body": "Deine Daten werden lokal auf deinem Gerät zwischengespeichert (IndexedDB). Bei aktiver Internetverbindung werden Änderungen mit dem Server synchronisiert. Ohne Verbindung kannst du weiterarbeiten; die Synchronisation erfolgt später.",
"free_title": "Kostenlos & werbefrei",
"free_body": "Kapteins Daagbok ist kostenlos und enthält keine Werbung.",
"liability_title": "Haftungsausschluss",
@@ -383,6 +411,28 @@
"close": "Schließen",
"button_title": "Hinweise & Haftungsausschluss"
},
"feedback": {
"button_title": "Feedback senden",
"title": "Feedback",
"intro": "Teile Fehler, Ideen oder allgemeines Feedback. Deine Nachricht wird über einen sicheren Benachrichtigungskanal an das Projektteam gesendet.",
"category_label": "Kategorie",
"category_general": "Allgemein",
"category_bug": "Fehler melden",
"category_feature": "Feature-Wunsch",
"contact_label": "E-Mail (optional)",
"contact_placeholder": "deine@email.beispiel",
"message_label": "Nachricht",
"message_placeholder": "Beschreib dein Feedback…",
"send": "Senden",
"sending": "Wird gesendet…",
"cancel": "Abbrechen",
"success": "Vielen Dank! Dein Feedback wurde gesendet.",
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuche es später erneut.",
"error_invalid_email": "Bitte gib eine gültige E-Mail-Adresse ein.",
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar.",
"error_rate_limited": "Zu viele Feedback-Nachrichten in kurzer Zeit. Bitte warte einige Minuten.",
"error_spam": "Diese Nachricht konnte nicht gesendet werden. Bitte formuliere sie anders."
},
"demo": {
"logbook_title": "Demo-Logbuch Ostsee",
"badge": "Demo",
@@ -390,6 +440,36 @@
"cta_register": "Account erstellen",
"back_to_login": "Zur Anmeldung"
},
"invitation": {
"error_invalid_key": "Der Einladungslink ist kryptografisch ungültig (Schlüssel fehlerhaft).",
"error_missing_key": "Der Einladungslink enthält keinen Entschlüsselungsschlüssel (#key=...). Bitte den vollständigen Link vom Eigner verwenden.",
"error_expired": "Diese Einladung ist abgelaufen (48 Stunden gültig).",
"error_invalid_token": "Einladungstoken ungültig.",
"error_load_failed": "Einladungsdetails konnten nicht geladen werden.",
"error_incomplete_session": "Sitzung unvollständig — bitte erneut anmelden (Benutzer-ID fehlt).",
"error_accept_failed": "Beitritt fehlgeschlagen.",
"error_login_failed": "Passkey-Anmeldung fehlgeschlagen.",
"error_username_missing": "Benutzername konnte nicht ermittelt werden — bitte erneut anmelden.",
"error_register_failed": "Registrierung fehlgeschlagen.",
"loading_joining": "Beitritt...",
"loading_checking": "Einladung wird geprüft...",
"loading_unlocking": "Logbuch wird freigeschaltet und synchronisiert...",
"loading_retrieving_key": "Lade Verschlüsselungsschlüssel...",
"error_title": "Einladungsfehler",
"back_to_start": "Zurück zum Start",
"title": "Logbuch-Einladung",
"invited_by": "Einladung von",
"vessel_logbook": "Schiff / Logbuch",
"signed_in_preparing": "Angemeldet als {{username}}. Beitritt wird vorbereitet...",
"join_again": "Erneut beitreten",
"login_or_register_hint": "Melde dich an oder registriere ein Konto, um dem Logbuch beizutreten.",
"or_sign_up": "ODER NEU REGISTRIEREN",
"register_crew_account": "Neues Crew-Konto erstellen",
"username_label": "Benutzername",
"create_passkey": "Passkey erstellen",
"switch_language_en": "English",
"switch_language_de": "Deutsch"
},
"stats": {
"title": "Statistik",
"subtitle": "Strecken, Verbrauch und Antriebsart auf einen Blick",
@@ -402,6 +482,9 @@
"travel_days": "Reisetage",
"sail_distance": "Unter Segel",
"motor_distance": "Maschinenfahrt",
"motor_hours_total": "Maschinenstunden gesamt",
"daily_motor_hours": "Maschinenstunden pro Reisetag",
"avg_motor_hours": "Ø Maschinenstunden pro Reisetag",
"unknown_propulsion": "Unbekannt",
"fuel_total": "Kraftstoff gesamt",
"water_total": "Wasser gesamt",
@@ -415,9 +498,12 @@
"avg_fuel": "Ø Kraftstoff",
"avg_water": "Ø Wasser",
"fuel_per_nm": "Kraftstoff pro sm",
"fuel_per_motor_hour": "Kraftstoff pro Maschinenstunde",
"daily_fuel_per_motor_hour": "Kraftstoffverbrauch pro Maschinenstunde je Reisetag",
"fuel_legend": "Kraftstoff",
"water_legend": "Wasser",
"unit_nm": "sm",
"unit_h": "h",
"unit_l": "L",
"day_label": "Tag {{day}}",
"account_logbooks": "Logbücher im Überblick",
@@ -432,19 +518,19 @@
"steps": {
"welcome": {
"title": "Willkommen an Bord!",
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für dich angelegt. Die Beispieleinträge kannst du jederzeit löschen, wenn du mit dem eigenen Logbuch starten möchtest. Diese kurze Tour zeigt dir die wichtigsten Funktionen."
},
"welcome_public": {
"title": "Willkommen an Bord!",
"body": "Erkunden Sie unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese kurze Tour zeigt Ihnen Schiffsdaten, Crew und Logbucheinträge."
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge."
},
"nav_logs": {
"title": "Logbucheinträge",
"body": "Hier verwalten Sie Ihre Reisetage Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
"body": "Hier verwaltest du deine Reisetage Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
},
"entry_list": {
"title": "Ihre Reisetage",
"body": "Jede Karte steht für einen Reisetag. Tippen Sie auf einen Eintrag, um Details zu sehen oder zu bearbeiten."
"title": "Deine Reisetage",
"body": "Jede Karte steht für einen Reisetag. Tippe auf einen Eintrag, um Details zu sehen oder zu bearbeiten."
},
"entry_open": {
"title": "Reisetag öffnen",
@@ -452,21 +538,35 @@
},
"entry_track": {
"title": "GPS-Track",
"body": "Laden Sie GPX-Dateien hoch oder sehen Sie bereits gespeicherte Routen auf der Karte inklusive Distanz und Geschwindigkeit."
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte inklusive Distanz und Geschwindigkeit."
},
"nav_vessel": {
"title": "Schiffsdaten",
"body": "Hinterlegen Sie Name, Maße und technische Daten Ihrer Yacht einmal ausfüllen, für alle Reisetage verfügbar."
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht einmal ausfüllen, für alle Reisetage verfügbar."
},
"nav_crew": {
"title": "Crew-Liste",
"body": "Verwalten Sie Besatzungsmitglieder und weisen Sie sie später Reisetagen zu."
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
},
"nav_stats": {
"title": "Statistik-Dashboard",
"body": "Hier siehst du Reisedistanzen, Verbrauch, Routenkarten und Antriebsanteile automatisch aus deinen Logbucheinträgen berechnet."
},
"nav_feedback": {
"title": "Feedback senden",
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken auch nach der Tour jederzeit über das Symbol oben rechts."
},
"finish": {
"title": "Alles klar!",
"body": "Sie können die Tour jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
}
}
},
"seo": {
"title": "Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)",
"description": "Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren auch offline als PWA.",
"keywords": "Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung",
"ogImageAlt": "Kapteins Daagbok Logo"
}
}
}
+105 -5
View File
@@ -2,7 +2,15 @@
"translation": {
"app": {
"name": "Kapteins Daagbok",
"tagline": "Private Yacht Logbook"
"tagline": "Private Yacht Logbook",
"beta": "Beta",
"beta_hint": "Beta release — features may still change"
},
"common": {
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
"unsaved_changes_leave": "Leave",
"unsaved_changes_stay": "Stay"
},
"nav": {
"dashboard": "Dashboard",
@@ -116,6 +124,12 @@
"travel_details": "Travel Details",
"add_event": "Add Event Log Record",
"add_event_btn": "Add Event Entry",
"edit_event": "Edit event",
"save_event_btn": "Save changes",
"cancel_event_edit": "Cancel",
"delete_event": "Delete event",
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
"date": "Date",
"day_of_travel": "Day of Travel",
"departure": "Departure Port (von)",
@@ -137,6 +151,7 @@
"sign_passkey_signing": "Requesting Passkey…",
"sign_passkey_signed": "Signed by {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Remove Passkey signature",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Classic",
@@ -190,6 +205,8 @@
"event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status",
"motor_propulsion": "Engine Propulsion",
"motor_hours": "Engine hours (total)",
"fuel_per_motor_hour": "Consumption per engine hour",
"event_distance": "Distance (nm)",
"export_csv": "Download CSV",
"share_csv": "Share CSV",
@@ -237,6 +254,7 @@
"create_btn": "Create Logbook",
"new_logbook_placeholder": "Logbook or Yacht Name",
"logout": "Logout",
"logged_in_as": "Signed in as {{name}}",
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.",
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...",
@@ -256,6 +274,7 @@
"crew": {
"title": "Skipper & Crew Profiles",
"skipper_section": "Skipper Profile",
"skipper_read_only_hint": "The skipper profile can only be edited by the logbook owner.",
"crew_section": "Crew List",
"add_crew": "Add Crew Member",
"edit_crew": "Edit Crew Member",
@@ -294,8 +313,8 @@
"save": "Save Configuration",
"saving": "Saving...",
"saved": "Settings saved successfully!",
"key_help": "An API key is required to automatically fetch real-time weather and sea state parameters based on your vessel's GPS coordinates.",
"no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.",
"key_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
"no_key": "No OpenWeatherMap API key available. Add your own key in settings or contact the operator.",
"weather_success": "Weather details fetched successfully!",
"weather_error": "Failed to fetch weather. Check your API key and connection.",
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
@@ -313,6 +332,7 @@
"color_scheme_dark": "Dark",
"share_title": "Share Logbook (Read-Only)",
"share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).",
"share_privacy_warning": "Recommendation: Share this link only privately (e.g. via email or messenger), not on social media.",
"share_enable": "Enable Public Link",
"share_copied": "Link copied!",
"share_copy_btn": "Copy Link",
@@ -328,6 +348,14 @@
"tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
"tour_restart": "Restart tour",
"push_title": "Push notifications",
"push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.",
"push_enable": "Notify on crew changes",
"push_active": "Push notifications are active on this device.",
"push_unsupported": "Push notifications are not supported in this browser.",
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications.",
"backup_title": "Backup & restore",
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
"backup_export_title": "Create backup",
@@ -383,6 +411,28 @@
"close": "Close",
"button_title": "Legal notice & disclaimer"
},
"feedback": {
"button_title": "Send feedback",
"title": "Feedback",
"intro": "Share bugs, ideas or general feedback. Your message is sent to the project team via a secure notification channel.",
"category_label": "Category",
"category_general": "General",
"category_bug": "Bug report",
"category_feature": "Feature request",
"contact_label": "Email (optional)",
"contact_placeholder": "your@email.example",
"message_label": "Message",
"message_placeholder": "Describe your feedback…",
"send": "Send",
"sending": "Sending…",
"cancel": "Cancel",
"success": "Thank you! Your feedback has been sent.",
"error_send": "Could not send feedback. Please try again later.",
"error_invalid_email": "Please enter a valid email address.",
"error_not_configured": "Feedback is not available on this server.",
"error_rate_limited": "Too many feedback messages in a short time. Please wait a few minutes.",
"error_spam": "This message could not be sent. Please rephrase it and try again."
},
"demo": {
"logbook_title": "Baltic Sea Demo Logbook",
"badge": "Demo",
@@ -390,6 +440,36 @@
"cta_register": "Create account",
"back_to_login": "Back to login"
},
"invitation": {
"error_invalid_key": "The invitation link is cryptographically invalid (corrupted key).",
"error_missing_key": "The invitation link is missing the decryption key (#key=...). Please use the complete link from the owner.",
"error_expired": "This invitation link has expired (valid for 48 hours only).",
"error_invalid_token": "Failed to verify invitation token.",
"error_load_failed": "Invitation details could not be retrieved.",
"error_incomplete_session": "Incomplete session — please log in again (user ID missing).",
"error_accept_failed": "Acceptance failed.",
"error_login_failed": "Passkey authentication failed.",
"error_username_missing": "Could not determine username — please try logging in again.",
"error_register_failed": "Registration failed.",
"loading_joining": "Joining...",
"loading_checking": "Checking Invitation...",
"loading_unlocking": "Unlocking logbook and syncing data...",
"loading_retrieving_key": "Retrieving encryption key...",
"error_title": "Invitation Error",
"back_to_start": "Back to Dashboard",
"title": "Logbook Invitation",
"invited_by": "INVITED BY",
"vessel_logbook": "VESSEL / LOGBOOK",
"signed_in_preparing": "Signed in as {{username}}. Preparing to join...",
"join_again": "Join again",
"login_or_register_hint": "Sign in or register an account to join this logbook.",
"or_sign_up": "OR SIGN UP",
"register_crew_account": "Register New Crew Account",
"username_label": "Username",
"create_passkey": "Create Passkey",
"switch_language_en": "English",
"switch_language_de": "Deutsch"
},
"stats": {
"title": "Statistics",
"subtitle": "Routes, consumption and propulsion at a glance",
@@ -402,6 +482,9 @@
"travel_days": "Travel days",
"sail_distance": "Under sail",
"motor_distance": "Engine",
"motor_hours_total": "Total engine hours",
"daily_motor_hours": "Engine hours per travel day",
"avg_motor_hours": "Avg. engine hours per travel day",
"unknown_propulsion": "Unknown",
"fuel_total": "Total fuel",
"water_total": "Total water",
@@ -415,9 +498,12 @@
"avg_fuel": "Avg. fuel",
"avg_water": "Avg. water",
"fuel_per_nm": "Fuel per nm",
"fuel_per_motor_hour": "Fuel per engine hour",
"daily_fuel_per_motor_hour": "Fuel consumption per engine hour by travel day",
"fuel_legend": "Fuel",
"water_legend": "Water",
"unit_nm": "nm",
"unit_h": "h",
"unit_l": "L",
"day_label": "Day {{day}}",
"account_logbooks": "Logbooks overview",
@@ -432,7 +518,7 @@
"steps": {
"welcome": {
"title": "Welcome aboard!",
"body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features."
"body": "We created a demo logbook with three travel days in Kiel Bay. You can delete the sample entries anytime when you're ready to start your own logbook. This short tour shows you the key features."
},
"welcome_public": {
"title": "Welcome aboard!",
@@ -462,11 +548,25 @@
"title": "Crew list",
"body": "Manage crew members and assign them to travel days later."
},
"nav_stats": {
"title": "Statistics dashboard",
"body": "View travel distances, consumption, route maps, and propulsion breakdown — calculated automatically from your log entries."
},
"nav_feedback": {
"title": "Send feedback",
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
},
"finish": {
"title": "You're all set!",
"body": "You can restart the tour anytime in Settings. Fair winds!"
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
}
}
},
"seo": {
"title": "Kapteins Daagbok Free Digital Yacht Logbook (Ad-Free)",
"description": "Free, ad-free digital yacht logbook with end-to-end encryption and Passkey sign-in. Document travel days, GPS tracks, crew and vessel data securely — offline-capable PWA.",
"keywords": "yacht logbook, ship logbook, sailing log, maritime logbook, passkey, E2E encryption, GPS track, free, ad-free, offline PWA",
"ogImageAlt": "Kapteins Daagbok logo"
}
}
}
+6 -1
View File
@@ -14,13 +14,18 @@ export const PlausibleEvents = {
ONBOARDING_TOUR_SKIPPED: 'Onboarding Tour Skipped',
INVITE_GENERATED: 'Invite Generated',
INVITE_ACCEPTED: 'Invite Accepted',
LOGBOOK_SHARED: 'Logbook Shared',
PUBLIC_LINK_OPENED: 'Public Link Opened',
PDF_EXPORTED: 'PDF Exported',
CSV_EXPORTED: 'CSV Exported',
CSV_SHARED: 'CSV Shared',
PHOTO_UPLOADED: 'Photo Uploaded',
BACKUP_EXPORTED: 'Backup Exported',
BACKUP_RESTORED: 'Backup Restored',
DEMO_OPENED: 'Demo Opened'
DEMO_OPENED: 'Demo Opened',
PUSH_ENABLED: 'Push Enabled',
PUSH_DISABLED: 'Push Disabled',
FOOTER_LINK_CLICKED: 'Footer Link Clicked'
} as const
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
+38
View File
@@ -0,0 +1,38 @@
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
export async function apiFetch(
input: string,
init: RequestInit = {}
): Promise<Response> {
const headers = new Headers(init.headers)
if (init.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
return fetch(input, {
...init,
headers,
credentials: 'include'
})
}
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
const res = await apiFetch(input, init)
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const message =
typeof data === 'object' && data && 'error' in data && typeof data.error === 'string'
? data.error
: `Request failed (${res.status})`
throw new ApiError(message, res.status)
}
return data as T
}
+63 -75
View File
@@ -6,27 +6,22 @@ import {
deriveKeyFromPin,
encryptBuffer,
decryptBuffer,
generateRecoveryPhrase,
base64ToBuffer,
bufferToBase64
generateRecoveryPhrase
} from './crypto.js'
import { clearLogbookKeysCache } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js'
import { apiFetch, apiJson } from './api.js'
const API_BASE = '/api/auth'
// Shared in-memory container for the active user's session master key
// Master key lives in memory only (never localStorage — XSS-resistant).
let activeMasterKey: ArrayBuffer | null = null
// Restore key from localStorage on load if present (survives reload/restart)
try {
const savedKey = localStorage.getItem('active_master_key')
if (savedKey) {
activeMasterKey = base64ToBuffer(savedKey)
}
} catch (e) {
console.error('Failed to restore active master key:', e)
localStorage.removeItem('active_master_key')
} catch {
/* ignore */
}
export function getActiveMasterKey(): ArrayBuffer | null {
@@ -35,17 +30,34 @@ export function getActiveMasterKey(): ArrayBuffer | null {
export function setActiveMasterKey(key: ArrayBuffer | null) {
activeMasterKey = key
if (key) {
try {
localStorage.setItem('active_master_key', bufferToBase64(key))
} catch (e) {
console.error('Failed to save master key to localStorage:', e)
}
} else {
localStorage.removeItem('active_master_key')
}
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
try {
return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`)
} catch {
return { authenticated: false }
}
}
export async function reauthWithPasskey(): Promise<boolean> {
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
method: 'POST'
})
const credentialResponse = await startAuthentication({ optionsJSON: options })
await apiJson(`${API_BASE}/reauth-verify`, {
method: 'POST',
body: JSON.stringify({
credentialResponse,
challenge: options.challenge
})
})
return true
}
// PIN fallback mechanism functions
export async function setLocalPin(pin: string, username: string, masterKey: ArrayBuffer): Promise<void> {
const pinKey = await deriveKeyFromPin(pin, username)
@@ -152,19 +164,11 @@ export interface RegistrationResult {
export async function registerUser(username: string): Promise<RegistrationResult> {
// 1. Get registration options
const optionsRes = await fetch(`${API_BASE}/register-options`, {
const options = await apiJson<any>(`${API_BASE}/register-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
})
if (!optionsRes.ok) {
const err = await optionsRes.json()
throw new Error(err.error || 'Failed to fetch registration options')
}
const options = await optionsRes.json()
// Request the PRF extension WITH an evaluation salt. This must match the
// salt used during login (PRF_SALT), otherwise the PRF-derived key produced
// at login would never match what was stored here and every login would fall
@@ -229,9 +233,8 @@ export async function registerUser(username: string): Promise<RegistrationResult
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
// 4. Verify registration on the server
const verifyRes = await fetch(`${API_BASE}/register-verify`, {
const result = await apiJson<{ verified: boolean; userId: string }>(`${API_BASE}/register-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credentialResponse,
@@ -243,13 +246,6 @@ export async function registerUser(username: string): Promise<RegistrationResult
encryptedMasterKeyRecTag: encryptedRecovery.tag
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json()
throw new Error(err.error || 'Failed to verify registration response')
}
const result = await verifyRes.json()
if (result.verified) {
setActiveMasterKey(masterKey)
localStorage.setItem('active_username', username)
@@ -292,19 +288,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
}
// 1. Get authentication options
const optionsRes = await fetch(`${API_BASE}/login-options`, {
const options = await apiJson<any>(`${API_BASE}/login-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
})
if (!optionsRes.ok) {
const err = await optionsRes.json()
throw new Error(err.error || 'Failed to fetch login options')
}
const options = await optionsRes.json()
// Add PRF extension evaluation input.
// When the server returned a concrete allowCredentials list we use
// `evalByCredential` (keyed by the base64url credential id), which is the
@@ -366,21 +354,23 @@ export async function loginUser(username?: string): Promise<LoginResult> {
}
// 3. Verify assertion on the server
const verifyRes = await fetch(`${API_BASE}/login-verify`, {
const result = await apiJson<{
verified: boolean
userId: string
username: string
encryptedMasterKeyPrf: string | null
encryptedMasterKeyPrfIv: string | null
encryptedMasterKeyPrfTag: string | null
encryptedMasterKeyRec: string
encryptedMasterKeyRecIv: string
encryptedMasterKeyRecTag: string
}>(`${API_BASE}/login-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credentialResponse,
challenge: options.challenge
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json()
throw new Error(err.error || 'Failed to verify login response')
}
const result = await verifyRes.json()
if (!result.verified) {
return { verified: false, prfSuccess: false }
}
@@ -407,7 +397,12 @@ export async function loginUser(username?: string): Promise<LoginResult> {
console.log('PRF extension results first present:', !!prfResults.results?.first)
}
if (prfResults?.results?.first && result.encryptedMasterKeyPrf) {
if (
prfResults?.results?.first &&
result.encryptedMasterKeyPrf &&
result.encryptedMasterKeyPrfIv &&
result.encryptedMasterKeyPrfTag
) {
try {
const firstBuffer = typeof prfResults.results.first === 'string'
? base64urlToBuffer(prfResults.results.first)
@@ -475,22 +470,14 @@ export async function completeLoginWithRecovery(
const prfKey = await deriveKeyFromPrf(firstBuffer)
const encryptedPrf = await encryptBuffer(decryptedMaster, prfKey)
console.log('Sending PRF credentials to server...')
const enrollRes = await fetch(`${API_BASE}/enroll-prf`, {
await apiJson(`${API_BASE}/enroll-prf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': encryptedPayloads.userId
},
body: JSON.stringify({
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
encryptedMasterKeyPrfIv: encryptedPrf.iv,
encryptedMasterKeyPrfTag: encryptedPrf.tag
})
})
console.log('Enrollment response status:', enrollRes.status)
if (!enrollRes.ok) {
console.warn('Server rejected PRF enrollment')
}
} catch (err) {
console.error('Failed to encrypt/enroll master key with PRF key:', err)
}
@@ -508,25 +495,26 @@ export async function completeLoginWithRecovery(
}
}
export function logoutUser() {
export async function logoutUser() {
setActiveMasterKey(null)
clearLogbookKeysCache()
localStorage.removeItem('active_username')
localStorage.removeItem('active_userid')
try {
await apiFetch(`${API_BASE}/logout`, { method: 'POST' })
} catch {
/* ignore network errors on logout */
}
}
export async function deleteAccount(): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
const username = localStorage.getItem('active_username')
if (!userId) return false
if (!localStorage.getItem('active_userid')) return false
try {
const res = await fetch(`${API_BASE}/delete-account`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
})
await reauthWithPasskey()
const res = await apiFetch(`${API_BASE}/delete-account`, { method: 'DELETE' })
if (res.ok) {
if (username) {
@@ -546,7 +534,7 @@ export async function deleteAccount(): Promise<boolean> {
])
// Wipe localStorage and session variables
logoutUser()
await logoutUser()
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
return true
}
+10 -4
View File
@@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import i18n from '../i18n/index.js'
function escapeCsvValue(val: string | number | undefined | null): string {
@@ -79,7 +80,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
@@ -95,6 +96,10 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
passkeyLabel: (username: string, signedAt: string) => {
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
return i18n.t('logs.sign_passkey_export', { username, date })
},
attributionLabel: (username: string, signedAt: string) => {
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
return i18n.t('logs.sign_attribution_export', { username, date })
}
};
@@ -108,6 +113,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const trackDist = entry.trackDistanceNm ?? '';
const trackMax = entry.trackSpeedMaxKn ?? '';
const trackAvg = entry.trackSpeedAvgKn ?? '';
const motorH = entry.motorHours ?? '';
const fwM = entry.freshwater?.morning ?? '';
const fwR = entry.freshwater?.refilled ?? '';
const fwE = entry.freshwater?.evening ?? '';
@@ -123,7 +129,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
trackDist, trackMax, trackAvg,
trackDist, trackMax, trackAvg, motorH,
'', '', '',
'', '', '', '',
'', '', '', '', '',
@@ -134,12 +140,12 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
].map(escapeCsvValue));
} else {
// Sort events chronologically by time
const sortedEvents = [...eventsList].sort((a, b) => (a.time || '').localeCompare(b.time || ''));
const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) {
rows.push([
dateVal, travelDay, dep, dest,
signS, signC,
trackDist, trackMax, trackAvg,
trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
+8
View File
@@ -26,6 +26,7 @@ export interface DemoDaySpec {
filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
motorHours?: number
events: Array<Record<string, string>>
}
@@ -100,6 +101,7 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
motorHours: 1.5,
events: [
{
time: '09:00',
@@ -247,6 +249,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
if (day.motorHours != null && day.motorHours > 0) {
entryPayload.motorHours = day.motorHours
}
entries.push(entryPayload as PublicDemoFixture['entries'][number])
@@ -303,6 +308,9 @@ export function buildDemoEntryPayloads(): Array<{
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
if (day.motorHours != null && day.motorHours > 0) {
entryPayload.motorHours = day.motorHours
}
return {
entryId,
+9 -25
View File
@@ -1,5 +1,6 @@
import { startAuthentication } from '@simplewebauthn/browser'
import type { PasskeySignature } from '../types/signatures.js'
import { apiJson } from './api.js'
export async function signLogEntry(params: {
logbookId: string
@@ -7,32 +8,22 @@ export async function signLogEntry(params: {
entryHash: string
role: 'skipper' | 'crew'
}): Promise<PasskeySignature> {
const userId = localStorage.getItem('active_userid')
if (!userId) throw new Error('User not authenticated')
if (!localStorage.getItem('active_userid')) throw new Error('User not authenticated')
const optionsRes = await fetch('/api/sign/options', {
const options = await apiJson<any>('/api/sign/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify(params)
})
if (!optionsRes.ok) {
const err = await optionsRes.json().catch(() => ({}))
throw new Error(err.error || 'Failed to start passkey signing')
}
const options = await optionsRes.json()
const credentialResponse = await startAuthentication({ optionsJSON: options })
const verifyRes = await fetch('/api/sign/verify', {
const result = await apiJson<{
userId: string
username: string
credentialId: string
signedAt: string
}>('/api/sign/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({
credentialResponse,
challenge: options.challenge,
@@ -43,13 +34,6 @@ export async function signLogEntry(params: {
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json().catch(() => ({}))
throw new Error(err.error || 'Passkey signature verification failed')
}
const result = await verifyRes.json()
return {
kind: 'passkey',
version: 1,
+69
View File
@@ -0,0 +1,69 @@
import { apiFetch } from './api.js'
export type FeedbackCategory = 'bug' | 'feature' | 'general'
export class FeedbackApiError extends Error {
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED'
constructor(
message: string,
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'FeedbackApiError'
this.code = code
}
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export function isValidFeedbackEmail(email: string): boolean {
return EMAIL_PATTERN.test(email.trim())
}
export async function sendFeedback(payload: {
category: FeedbackCategory
message: string
contactEmail?: string | null
logbookId?: string | null
logbookTitle?: string | null
openedAt: number
website?: string
}): Promise<void> {
const contactEmail = payload.contactEmail?.trim()
if (contactEmail && !isValidFeedbackEmail(contactEmail)) {
throw new FeedbackApiError('Invalid email address', 'INVALID_EMAIL')
}
const res = await apiFetch('/api/feedback', {
method: 'POST',
body: JSON.stringify({
category: payload.category,
message: payload.message,
contactEmail: contactEmail || undefined,
username: localStorage.getItem('active_username') || undefined,
logbookId: payload.logbookId || undefined,
logbookTitle: payload.logbookTitle || undefined,
appVersion: typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined,
pageUrl: window.location.href,
openedAt: payload.openedAt,
website: payload.website || undefined
})
})
if (res.status === 503) {
throw new FeedbackApiError('Feedback is not configured on this server', 'NOT_CONFIGURED')
}
if (res.status === 429) {
throw new FeedbackApiError('Too many feedback submissions', 'RATE_LIMITED')
}
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new FeedbackApiError(
data.error || 'Failed to send feedback',
data.code === 'SPAM_DETECTED' ? 'SPAM_DETECTED' : 'REQUEST_FAILED'
)
}
}
+4 -18
View File
@@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js'
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { apiFetch } from './api.js'
const API_BASE = '/api/logbooks'
@@ -66,13 +67,7 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
if (navigator.onLine) {
try {
const response = await fetch(API_BASE, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
}
})
const response = await apiFetch(API_BASE, { method: 'GET' })
if (response.ok) {
const serverLogbooks = await response.json()
@@ -208,12 +203,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
if (navigator.onLine) {
try {
const response = await fetch(API_BASE, {
const response = await apiFetch(API_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({
id: localId,
...payloadData
@@ -301,12 +292,7 @@ export async function deleteLogbook(id: string): Promise<void> {
if (navigator.onLine) {
try {
const response = await fetch(`${API_BASE}/${id}`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
})
const response = await apiFetch(`${API_BASE}/${id}`, { method: 'DELETE' })
if (!response.ok) {
console.warn('Server deletion failed or was rejected')
}
+4 -7
View File
@@ -1,3 +1,5 @@
import { apiJson } from './api.js'
export interface LogbookAccess {
isOwner: boolean
role: 'OWNER' | 'READ' | 'WRITE'
@@ -5,15 +7,10 @@ export interface LogbookAccess {
}
export async function getLogbookAccess(logbookId: string): Promise<LogbookAccess | null> {
const userId = localStorage.getItem('active_userid')
if (!userId || !navigator.onLine) return null
if (!localStorage.getItem('active_userid') || !navigator.onLine) return null
try {
const res = await fetch(`/api/logbooks/${logbookId}/access`, {
headers: { 'X-User-Id': userId }
})
if (!res.ok) return null
return res.json()
return await apiJson<LogbookAccess>(`/api/logbooks/${logbookId}/access`)
} catch {
return null
}
+13 -4
View File
@@ -3,7 +3,8 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { isSignatureImage, isPasskeySignature } from '../utils/signatures.js'
import { isSignatureImage, isPasskeySignature, isClassicSignature, getSignaturePayload } from '../utils/signatures.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import i18n from '../i18n/index.js'
function formatPasskeySignDate(signedAt: string): string {
@@ -132,7 +133,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
// Draw Data Rows
const events = entry.events || [];
const maxRows = 16;
const sortedEvents = [...events].sort((a: any, b: any) => (a.time || '').localeCompare(b.time || ''));
const sortedEvents = sortLogEventsByTime(events);
doc.setFont('Helvetica', 'normal');
@@ -255,8 +256,16 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9);
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
} else if (isSignatureImage(entry.signCrew)) {
doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
} else if (isClassicSignature(entry.signCrew)) {
doc.setFont('Helvetica', 'normal');
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
doc.text(entry.signCrew.username, sigX + 80.5, sigY + 9);
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
if (isSignatureImage(entry.signCrew.payload)) {
doc.addImage(entry.signCrew.payload, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
}
} else if (isSignatureImage(getSignaturePayload(entry.signCrew))) {
doc.addImage(getSignaturePayload(entry.signCrew), 'PNG', sigX + 80.5, sigY + 6, 72, 14)
} else {
doc.setFont('Helvetica', 'normal');
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
+154
View File
@@ -0,0 +1,154 @@
import { apiFetch, apiJson } from './api.js'
const API_BASE = '/api/push'
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(base64)
const output = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) {
output[i] = raw.charCodeAt(i)
}
return output
}
export function isPushSupported(): boolean {
return (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
)
}
export function getNotificationPermission(): NotificationPermission | 'unsupported' {
if (!isPushSupported()) return 'unsupported'
return Notification.permission
}
async function fetchVapidPublicKey(): Promise<string | null> {
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
if (typeof envKey === 'string' && envKey.trim()) {
return envKey.trim()
}
try {
const res = await fetch(`${API_BASE}/vapid-public-key`)
if (!res.ok) return null
const data = await res.json()
return typeof data.publicKey === 'string' ? data.publicKey : null
} catch {
return null
}
}
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
if (!localStorage.getItem('active_userid')) {
return { collaboratorChangesEnabled: false }
}
return apiJson<{ collaboratorChangesEnabled: boolean }>(`${API_BASE}/prefs`)
}
export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promise<void> {
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
await apiJson(`${API_BASE}/prefs`, {
method: 'PUT',
body: JSON.stringify({ collaboratorChangesEnabled })
})
}
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
const json = subscription.toJSON()
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
throw new Error('Invalid push subscription')
}
const locale = document.documentElement.lang?.startsWith('en') ? 'en' : 'de'
await apiJson(`${API_BASE}/subscription`, {
method: 'PUT',
body: JSON.stringify({
endpoint: json.endpoint,
keys: json.keys,
locale,
userAgent: navigator.userAgent
})
})
}
export async function subscribeToPush(): Promise<void> {
if (!isPushSupported()) {
throw new Error('Push notifications are not supported on this device')
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
throw new Error('Notification permission denied')
}
const publicKey = await fetchVapidPublicKey()
if (!publicKey) {
throw new Error('Push notifications are not configured on this server')
}
const registration = await navigator.serviceWorker.ready
let subscription = await registration.pushManager.getSubscription()
if (!subscription) {
const keyBytes = urlBase64ToUint8Array(publicKey)
const applicationServerKey = new Uint8Array(keyBytes)
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
}
await saveSubscriptionToServer(subscription)
}
export async function unsubscribeFromPush(): Promise<void> {
if (!isPushSupported()) return
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return
const endpoint = subscription.endpoint
await subscription.unsubscribe()
if (localStorage.getItem('active_userid') && endpoint) {
await apiFetch(`${API_BASE}/subscription`, {
method: 'DELETE',
body: JSON.stringify({ endpoint })
}).catch(() => {})
}
}
/** Re-register subscription when prefs are on and permission already granted. */
export async function ensurePushSubscriptionIfEnabled(): Promise<void> {
if (!isPushSupported() || Notification.permission !== 'granted') return
const prefs = await fetchPushPrefs()
if (!prefs.collaboratorChangesEnabled) return
try {
await subscribeToPush()
} catch (err) {
console.warn('Could not refresh push subscription:', err)
}
}
export async function enableCollaboratorChangePush(): Promise<void> {
await subscribeToPush()
await savePushPrefs(true)
}
export async function disableCollaboratorChangePush(): Promise<void> {
await savePushPrefs(false)
await unsubscribeFromPush()
}
+22 -2
View File
@@ -10,6 +10,7 @@ import {
parseEventDistanceNm,
splitDistanceByPropulsion
} from '../utils/propulsionStats.js'
import { computeFuelPerMotorHour } from '../utils/fuelStats.js'
export type DistanceSource = 'gps' | 'events' | 'none'
@@ -27,6 +28,8 @@ export interface TravelDayStats {
sailDistanceNm: number
motorDistanceNm: number
unknownPropulsionNm: number
motorHours: number
fuelPerMotorHourL: number | null
hasGpsTrack: boolean
}
@@ -59,12 +62,15 @@ export interface StatsTotals {
sailDistanceNm: number
motorDistanceNm: number
unknownPropulsionNm: number
totalMotorHours: number
totalFuelL: number
totalFreshwaterL: number
avgDistancePerDayNm: number
avgMotorHoursPerDay: number
avgFuelPerDayL: number
avgFreshwaterPerDayL: number
fuelPerNmL: number | null
fuelPerMotorHourL: number | null
}
const TRACK_COLORS = [
@@ -102,6 +108,7 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0)
const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0)
const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0)
const totalMotorHours = days.reduce((sum, d) => sum + d.motorHours, 0)
const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0)
const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 0)
@@ -112,10 +119,13 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
sailDistanceNm: Number(sailDistanceNm.toFixed(2)),
motorDistanceNm: Number(motorDistanceNm.toFixed(2)),
unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)),
totalMotorHours: Number(totalMotorHours.toFixed(1)),
totalFuelL: Number(totalFuelL.toFixed(1)),
totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)),
avgDistancePerDayNm:
travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0,
avgMotorHoursPerDay:
travelDayCount > 0 ? Number((totalMotorHours / travelDayCount).toFixed(1)) : 0,
avgFuelPerDayL:
travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0,
avgFreshwaterPerDayL:
@@ -123,7 +133,8 @@ function buildTotals(days: TravelDayStats[]): StatsTotals {
fuelPerNmL:
totalDistanceNm > 0 && totalFuelL > 0
? Number((totalFuelL / totalDistanceNm).toFixed(2))
: null
: null,
fuelPerMotorHourL: computeFuelPerMotorHour(totalFuelL, totalMotorHours)
}
}
@@ -180,6 +191,9 @@ async function loadTravelDaysForLogbook(
hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId))
}
const fuelConsumptionL = Number(payload.fuel?.consumption) || 0
const motorHours = Number(payload.motorHours) || 0
days.push({
entryId: entry.payloadId,
logbookId,
@@ -189,11 +203,13 @@ async function loadTravelDaysForLogbook(
destination: payload.destination || '',
distanceNm,
distanceSource,
fuelConsumptionL: Number(payload.fuel?.consumption) || 0,
fuelConsumptionL,
freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
sailDistanceNm: propulsion.sailDistanceNm,
motorDistanceNm: propulsion.motorDistanceNm,
unknownPropulsionNm: propulsion.unknownPropulsionNm,
motorHours,
fuelPerMotorHourL: computeFuelPerMotorHour(fuelConsumptionL, motorHours),
hasGpsTrack
})
}
@@ -249,3 +265,7 @@ export function formatNm(value: number): string {
export function formatLiters(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
export function formatHours(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
+30 -14
View File
@@ -1,5 +1,7 @@
import { db, type SyncQueueItem } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { apiFetch } from './api.js'
import { getLogbookAccess } from './logbookAccess.js'
const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>()
@@ -124,21 +126,39 @@ function scheduleResync(logbookId: string) {
})
}
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
async function resolveLogbookPushAccess(logbookId: string): Promise<LogbookPushAccess> {
const access = await getLogbookAccess(logbookId)
if (access) {
return access.isOwner || access.role === 'OWNER' ? 'OWNER' : access.role
}
const local = await db.logbooks.get(logbookId)
if (local?.isShared !== 1) return 'OWNER'
if (local.collaborationRole === 'READ') return 'READ'
if (local.collaborationRole === 'WRITE') return 'WRITE'
return 'UNKNOWN'
}
// Push local sync queue items to the server
async function pushChanges(logbookId: string): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
if (!userId) return false
if (!getActiveMasterKey() || !localStorage.getItem('active_userid')) return false
const pending = await coalesceSyncQueue(logbookId)
if (pending.length === 0) return true
const pushAccess = await resolveLogbookPushAccess(logbookId)
if (pushAccess === 'READ' || pushAccess === 'UNKNOWN') {
console.warn(
`[sync] Skipping push for logbook ${logbookId} (${pushAccess}); ${pending.length} queue item(s) retained`
)
return false
}
try {
const response = await fetch(`${API_BASE}/push`, {
const response = await apiFetch(`${API_BASE}/push`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ items: pending })
})
@@ -187,15 +207,11 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
// Pull updates from the server and apply last-write-wins
async function pullChanges(logbookId: string): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
if (!userId) return false
if (!localStorage.getItem('active_userid')) return false
try {
const response = await fetch(`${API_BASE}/pull?logbookId=${logbookId}`, {
method: 'GET',
headers: {
'X-User-Id': userId
}
const response = await apiFetch(`${API_BASE}/pull?logbookId=${logbookId}`, {
method: 'GET'
})
if (!response.ok) {
+45
View File
@@ -0,0 +1,45 @@
import { apiFetch } from './api.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
super(message)
this.name = 'WeatherApiError'
this.code = code
}
}
export async function fetchOpenWeatherCurrent(params: {
lat?: string
lon?: string
q?: string
}): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams()
if (params.lat && params.lon) {
searchParams.set('lat', params.lat)
searchParams.set('lon', params.lon)
} else if (params.q?.trim()) {
searchParams.set('q', params.q.trim())
} else {
throw new WeatherApiError('lat/lon or location query required')
}
const userKey = localStorage.getItem('owm_api_key')?.trim()
const headers: Record<string, string> = {}
if (userKey) headers['X-OWM-Api-Key'] = userKey
const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers })
if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
}
const data = await res.json()
if (!res.ok) {
throw new WeatherApiError('Weather API rejected the request')
}
return data
}
+73
View File
@@ -0,0 +1,73 @@
/// <reference lib="webworker" />
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
declare let self: ServiceWorkerGlobalScope
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
interface PushPayload {
title?: string
body?: string
tag?: string
renotify?: boolean
data?: {
url?: string
logbookId?: string
changeCount?: number
}
}
self.addEventListener('push', (event) => {
event.waitUntil(
(async () => {
let payload: PushPayload = {}
try {
payload = event.data?.json() ?? {}
} catch {
payload = { body: event.data?.text() ?? '' }
}
const title = payload.title ?? 'Kapteins Daagbok'
const body = payload.body ?? ''
const data = payload.data ?? {}
await self.registration.showNotification(title, {
body,
tag: payload.tag,
icon: '/logo.png',
badge: '/logo.png',
data
})
})()
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const data = (event.notification.data ?? {}) as PushPayload['data']
const targetPath = data?.url ?? '/'
const targetUrl = new URL(targetPath, self.location.origin).href
event.waitUntil(
(async () => {
const windowClients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true
})
for (const client of windowClients) {
if ('focus' in client) {
await client.focus()
client.postMessage({
type: 'OPEN_LOGBOOK',
logbookId: data?.logbookId
})
return
}
}
await self.clients.openWindow(targetUrl)
})()
)
})
+13 -2
View File
@@ -11,5 +11,16 @@ export interface PasskeySignature {
clientVerified: boolean
}
/** Legacy: PNG data URL oder getippter Name */
export type SignatureValue = string | PasskeySignature
/** Klassische Unterschrift mit Benutzer-Zuordnung (Crew) */
export interface ClassicSignature {
kind: 'classic'
version: 1
role: 'skipper' | 'crew'
userId: string
username: string
signedAt: string
payload: string
}
/** Legacy: PNG data URL oder getippter Name; oder strukturierte Signaturen */
export type SignatureValue = string | PasskeySignature | ClassicSignature
+13
View File
@@ -0,0 +1,13 @@
/** Liters per motor hour from daily fuel consumption and motor hours. */
export function computeFuelPerMotorHour(
fuelConsumptionL: number,
motorHours: number
): number | null {
if (motorHours <= 0) return null
return Number((fuelConsumptionL / motorHours).toFixed(2))
}
export function formatFuelPerMotorHour(value: number | null | undefined): string {
if (value == null) return '—'
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
+49 -1
View File
@@ -17,6 +17,50 @@ export interface LogEventPayload {
remarks: string
}
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'gpsLat', 'gpsLng', 'remarks'
]
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<string, unknown>): LogEventPayload {
const e = event as Record<string, unknown>
const timeRaw = String(e.time ?? '').trim()
const normalized: LogEventPayload = {
time: timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw,
mgk: '',
rwk: '',
windPressure: '',
windDirection: '',
windStrength: '',
seaState: '',
weatherIcon: '',
current: '',
heel: '',
sailsOrMotor: '',
logReading: '',
distance: '',
gpsLat: '',
gpsLng: '',
remarks: ''
}
for (const key of LOG_EVENT_FIELDS) {
if (key === 'time') continue
normalized[key] = String(e[key] ?? '').trim()
}
return normalized
}
export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean {
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
}
/** Chronological order: earliest time first (HH:MM). */
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
}
export interface LogEntryPayloadInput {
date: string
dayOfTravel: string
@@ -27,6 +71,7 @@ export interface LogEntryPayloadInput {
trackDistanceNm?: number
trackSpeedMaxKn?: number
trackSpeedAvgKn?: number
motorHours?: number
events: LogEventPayload[]
}
@@ -38,12 +83,15 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
destination: input.destination.trim(),
freshwater: { ...input.freshwater },
fuel: { ...input.fuel },
events: input.events.map((e) => ({ ...e }))
events: sortLogEventsByTime(input.events.map((e) => normalizeLogEvent(e)))
}
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
if (input.motorHours !== undefined && input.motorHours > 0) {
payload.motorHours = Number(input.motorHours.toFixed(2))
}
return payload
}
+73
View File
@@ -0,0 +1,73 @@
import type { i18n as I18nInstance } from 'i18next'
const SITE_ORIGIN = 'https://kapteins-daagbok.eu'
export type SeoLang = 'de' | 'en'
let i18nRef: I18nInstance | null = null
export function normalizeSeoLang(lng: string): SeoLang {
return lng.startsWith('de') ? 'de' : 'en'
}
function setMeta(attr: 'name' | 'property', key: string, content: string) {
let el = document.querySelector(`meta[${attr}="${key}"]`)
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, key)
document.head.appendChild(el)
}
el.setAttribute('content', content)
}
function syncLanguageUrl(lang: SeoLang) {
const url = new URL(window.location.href)
const currentLng = url.searchParams.get('lng')
if (currentLng && normalizeSeoLang(currentLng) === lang) return
url.searchParams.set('lng', lang)
const next = `${url.pathname}${url.search}${url.hash}`
window.history.replaceState({}, '', next)
}
export function updatePageSeo(lng?: string) {
if (!i18nRef?.isInitialized) return
const lang = normalizeSeoLang(lng ?? i18nRef.language)
document.documentElement.lang = lang
const title = i18nRef.t('seo.title')
document.title = title
const description = i18nRef.t('seo.description')
const keywords = i18nRef.t('seo.keywords')
const imageAlt = i18nRef.t('seo.ogImageAlt')
setMeta('name', 'description', description)
setMeta('name', 'keywords', keywords)
setMeta('property', 'og:title', title)
setMeta('property', 'og:description', description)
setMeta('property', 'og:locale', lang === 'de' ? 'de_DE' : 'en_US')
setMeta('property', 'og:locale:alternate', lang === 'de' ? 'en_US' : 'de_DE')
setMeta('name', 'twitter:title', title)
setMeta('name', 'twitter:description', description)
setMeta('property', 'og:image:alt', imageAlt)
setMeta('name', 'twitter:image:alt', imageAlt)
syncLanguageUrl(lang)
}
export function initSeo(i18n: I18nInstance) {
i18nRef = i18n
i18n.on('initialized', () => updatePageSeo())
i18n.on('languageChanged', (lng) => updatePageSeo(lng))
if (i18n.isInitialized) {
updatePageSeo()
}
}
export function hreflangUrl(lang: SeoLang): string {
return `${SITE_ORIGIN}/?lng=${lang}`
}
export const seoSiteOrigin = SITE_ORIGIN
+66 -4
View File
@@ -1,8 +1,13 @@
import { hashEntryForSigning } from './entryCanonicalHash.js'
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
import type { ClassicSignature, PasskeySignature, SignatureValue } from '../types/signatures.js'
export type SkipperSignStatus = 'none' | 'valid' | 'invalid'
export interface SignatureAttribution {
username: string
signedAt: string
}
export function isSignatureImage(value: string | undefined | null): boolean {
return typeof value === 'string' && value.startsWith('data:image/')
}
@@ -16,9 +21,52 @@ export function isPasskeySignature(value: unknown): value is PasskeySignature {
)
}
export function isClassicSignature(value: unknown): value is ClassicSignature {
return (
typeof value === 'object' &&
value !== null &&
(value as ClassicSignature).kind === 'classic' &&
(value as ClassicSignature).version === 1
)
}
export function getSignaturePayload(value: SignatureValue | '' | undefined | null): string {
if (!value) return ''
if (isClassicSignature(value)) return value.payload
if (isPasskeySignature(value)) return ''
return value
}
export function getSignatureAttribution(value: SignatureValue | '' | undefined | null): SignatureAttribution | null {
if (!value || typeof value === 'string') return null
if (isPasskeySignature(value) || isClassicSignature(value)) {
return { username: value.username, signedAt: value.signedAt }
}
return null
}
export function createClassicSignature(input: {
role: 'skipper' | 'crew'
userId: string
username: string
signedAt: string
payload: string
}): ClassicSignature {
return {
kind: 'classic',
version: 1,
role: input.role,
userId: input.userId,
username: input.username,
signedAt: input.signedAt,
payload: input.payload
}
}
export function normalizeSignature(value: unknown): SignatureValue | undefined {
if (value === null || value === undefined || value === '') return undefined
if (isPasskeySignature(value)) return value
if (isClassicSignature(value)) return value
if (typeof value === 'string') return value
return undefined
}
@@ -47,6 +95,7 @@ export async function getSkipperSignStatus(
export interface SignatureExportLabels {
imagePlaceholder: string
passkeyLabel: (username: string, signedAt: string) => string
attributionLabel: (username: string, signedAt: string) => string
}
export function formatSignatureForExport(
@@ -57,14 +106,27 @@ export function formatSignatureForExport(
if (isPasskeySignature(value)) {
return labels.passkeyLabel(value.username, value.signedAt)
}
if (isClassicSignature(value)) {
return labels.attributionLabel(value.username, value.signedAt)
}
if (isSignatureImage(value)) return labels.imagePlaceholder
return value
}
export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined {
if (!value) return undefined
if (isPasskeySignature(value)) return value
if (isSignatureImage(value)) return value
const trimmed = value.trim()
if (isPasskeySignature(value) || isClassicSignature(value)) return value
const payload = typeof value === 'string' ? value : getSignaturePayload(value)
if (isSignatureImage(payload)) return payload
const trimmed = payload.trim()
return trimmed || undefined
}
/** Normalize then serialize — canonical form for persistence and dirty-check fingerprints. */
export function normalizedSerializedSignature(value: unknown): SignatureValue | undefined {
return serializeSignature(normalizeSignature(value) || '')
}
export function fingerprintSignature(value: unknown): SignatureValue | '' {
return normalizedSerializedSignature(value) ?? ''
}
+9
View File
@@ -1,5 +1,14 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />
/// <reference types="vite-plugin-pwa/client" />
interface ImportMetaEnv {
readonly VITE_VAPID_PUBLIC_KEY?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*?raw' {
const content: string
+7 -3
View File
@@ -38,16 +38,20 @@ export default defineConfig({
plugins: [
react(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
registerType: 'prompt',
includeAssets: ['favicon.ico', 'logo.png'],
workbox: {
cleanupOutdatedCaches: true,
injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}']
},
manifest: {
name: 'Kapteins Daagbok',
short_name: 'Daagbok',
description: 'Digital maritime ship logbook with E2E encryption and Passkeys',
lang: 'de',
description:
'Digitales Yacht-Logbuch — E2E-verschlüsselt, offline-fähig.',
theme_color: '#1e293b',
background_color: '#0f172a',
display: 'standalone',
+8
View File
@@ -26,6 +26,14 @@ services:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/daagbox?schema=public"
RP_ID: ${RP_ID:-localhost}
ORIGIN: ${ORIGIN:-http://localhost}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
SESSION_SECRET: ${SESSION_SECRET:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
NTFY_TOPIC: ${NTFY_TOPIC:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
command: sh -c "npx prisma db push && node dist/index.js"
depends_on:
db:
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+380
View File
@@ -0,0 +1,380 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Kapteins Daagbok — Beta-Flyer</title>
<style>
@page {
size: A4 portrait;
margin: 0;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 210mm;
height: 297mm;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
color: #e2e8f0;
background: #0f172a;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page {
width: 210mm;
height: 297mm;
max-height: 297mm;
padding: 12mm 15mm 10mm;
display: flex;
flex-direction: column;
gap: 5mm;
background:
radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%),
radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%),
linear-gradient(165deg, #0f172a 0%, #1e293b 45%, #0f172a 100%);
position: relative;
overflow: hidden;
}
.page::before {
content: "";
position: absolute;
inset: 8mm;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 4mm;
pointer-events: none;
}
header {
display: flex;
align-items: center;
gap: 5mm;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.logo {
width: 16mm;
height: 16mm;
flex-shrink: 0;
object-fit: contain;
}
.title-block h1 {
font-size: 23pt;
font-weight: 700;
letter-spacing: -0.02em;
color: #f8fafc;
line-height: 1.1;
}
.title-block p {
font-size: 12pt;
color: #94a3b8;
margin-top: 1.5mm;
}
.badge {
margin-left: auto;
align-self: flex-start;
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b;
font-size: 11pt;
font-weight: 800;
letter-spacing: 0.12em;
padding: 2.5mm 4.5mm;
border-radius: 2mm;
text-transform: uppercase;
}
.intro {
font-size: 12pt;
line-height: 1.5;
color: #cbd5e1;
flex-shrink: 0;
max-width: 95%;
position: relative;
z-index: 1;
}
.intro strong {
color: #f8fafc;
}
.screenshots {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3mm;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.screenshot-card {
border-radius: 2.5mm;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(15, 23, 42, 0.55);
display: flex;
flex-direction: column;
min-width: 0;
}
.screenshot-card img {
width: 100%;
height: 50mm;
object-fit: contain;
object-position: top center;
display: block;
background: #0b1220;
}
.screenshot-caption {
font-size: 9pt;
color: #94a3b8;
text-align: center;
padding: 1.5mm 2mm;
line-height: 1.3;
flex-shrink: 0;
}
.features {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2.5mm 6mm;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.feature {
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 10.5pt;
line-height: 1.4;
color: #e2e8f0;
}
.feature-icon {
color: #38bdf8;
font-weight: 700;
flex-shrink: 0;
width: 4mm;
}
.lang-list {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 1.5mm;
}
.lang-item {
display: inline-flex;
align-items: center;
gap: 1.2mm;
white-space: nowrap;
}
.feature-flag {
display: inline-block;
width: 5mm;
height: 3.5mm;
border-radius: 0.3mm;
flex-shrink: 0;
box-shadow: 0 0 0 0.15mm rgba(0, 0, 0, 0.25);
}
.lang-sep {
color: #94a3b8;
}
.beta-box {
background: rgba(30, 41, 59, 0.85);
border: 1px solid rgba(251, 191, 36, 0.35);
border-left: 3px solid #fbbf24;
border-radius: 3mm;
padding: 5mm 6mm;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.beta-box h2 {
font-size: 12.5pt;
color: #fbbf24;
margin-bottom: 2mm;
font-weight: 700;
}
.beta-box p {
font-size: 10.5pt;
line-height: 1.5;
color: #cbd5e1;
}
.cta {
display: flex;
align-items: center;
gap: 7mm;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 4mm;
padding: 5mm 6mm;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.qr {
width: 32mm;
height: 32mm;
background: #fff;
padding: 2mm;
border-radius: 2mm;
flex-shrink: 0;
}
.qr img {
width: 100%;
height: 100%;
display: block;
}
.cta-text h3 {
font-size: 14.5pt;
color: #38bdf8;
font-weight: 700;
margin-bottom: 2mm;
}
.cta-text p {
font-size: 11pt;
color: #94a3b8;
line-height: 1.5;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 2mm;
margin-top: 3mm;
}
.tag {
font-size: 9.5pt;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
border: 1px solid rgba(100, 116, 139, 0.4);
border-radius: 1.5mm;
padding: 1mm 2.5mm;
}
footer {
border-top: 1px solid rgba(148, 163, 184, 0.15);
padding-top: 3mm;
margin-top: auto;
flex-shrink: 0;
font-size: 9.5pt;
line-height: 1.5;
color: #64748b;
position: relative;
z-index: 1;
}
footer strong {
color: #94a3b8;
font-weight: 600;
}
</style>
</head>
<body>
<article class="page">
<header>
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
<div class="title-block">
<h1>Kapteins Daagbok</h1>
<p>Digitales Yacht-Logbuch — kostenlos &amp; werbefrei</p>
</div>
<span class="badge">Beta</span>
</header>
<p class="intro">
Führe dein Bordlogbuch digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und
<strong>auch offline</strong> auf See nutzbar.
</p>
<section class="features" aria-label="Funktionen">
<div class="feature"><span class="feature-icon"></span><span>Reisetage im nautischen Logbuch-Format (Hafen, Wetter, Besegelung, Crew, Tankstände)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Offline-fähige PWA — läuft auf jedem Smartphone &amp; Tablet</span></div>
<div class="feature"><span class="feature-icon"></span><span>Einfache passwortlose Passkey-Anmeldung</span></div>
<div class="feature"><span class="feature-icon"></span><span>Ende-zu-Ende Verschlüsselung</span></div>
<div class="feature"><span class="feature-icon"></span><span>GPS-Track Upload (GPX/KML) mit Kartendarstellung</span></div>
<div class="feature"><span class="feature-icon"></span><span>Streckenstatistik</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Anhänge pro Reisetag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Anhänge für Skipper und Crew</span></div>
<div class="feature"><span class="feature-icon"></span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
<div class="feature"><span class="feature-icon"></span><span>PDF- &amp; CSV-Export</span></div>
<div class="feature"><span class="feature-icon"></span><span>Verschlüsseltes Backup &amp; Wiederherstellung</span></div>
<div class="feature"><span class="feature-icon"></span><span>Logbuch mit Freunden teilen</span></div>
<div class="feature"><span class="feature-icon"></span><span>Beliebig viele Schiffe und Logbücher</span></div>
<div class="feature"><span class="feature-icon"></span><span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">&amp;</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>Englisch</span></span></div>
<div class="feature"><span class="feature-icon"></span><span>Crafted in Kiel.Sailing.City.</span></div>
</section>
<section class="screenshots" aria-label="App-Screenshots">
<figure class="screenshot-card">
<img src="assets/screenshot-login.png" alt="Anmeldung mit Passkey und Demo" />
<figcaption class="screenshot-caption">Anmeldung &amp; Passkey</figcaption>
</figure>
<figure class="screenshot-card">
<img src="assets/screenshot-logbook.png" alt="Logbuch-Journal mit Reisetagen" />
<figcaption class="screenshot-caption">Logbuch-Journal</figcaption>
</figure>
<figure class="screenshot-card">
<img src="assets/screenshot-vessel.png" alt="Schiffs-Stammdaten mit Yachtfoto" />
<figcaption class="screenshot-caption">Schiffsdaten</figcaption>
</figure>
</section>
<section class="beta-box">
<h2>Beta-Phase — Dein Feedback zählt</h2>
<p>
Kapteins Daagbok ist ein <strong>privates Hobbyprojekt ohne Gewinnabsicht</strong>.
Als Beta-Tester hilfst du, die App für Skipper und Crew im Alltag zu verbessern —
Rückmeldungen sind ausdrücklich willkommen.
</p>
</section>
<section class="cta">
<div class="qr">
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR-Code: kapteins-daagbok.eu" />
</div>
<div class="cta-text">
<h3>kapteins-daagbok.eu</h3>
<p>Im Browser öffnen oder als App zum Home-Bildschirm hinzufügen. Registrierung mit Passkey — kein App-Store nötig.</p>
<div class="tags">
<span class="tag">Kostenlos</span>
<span class="tag">Werbefrei</span>
<span class="tag">E2E-verschlüsselt</span>
</div>
</div>
</section>
<footer>
<strong>Impressum</strong><br />
KnorrLabs · Markus F.J. Busche · Knorrstr. 16 · 24106 Kiel · elpatron+kd@mailbox.org
</footer>
</article>
</body>
</html>
Binary file not shown.
+8 -2
View File
@@ -29,12 +29,17 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Demo Opened | Public-Demo unter `/demo` geöffnet (`DemoViewer.tsx`) | — |
| Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — |
| Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — |
| Logbook Shared | Öffentlicher Freigabelink aktiviert (`SettingsForm.tsx`) | — |
| Public Link Opened | Freigabelink unter `/share` erfolgreich geladen (`ReadOnlyViewer.tsx`) | — |
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
## Bewusst nicht getrackt
@@ -49,8 +54,9 @@ Empfohlene Goal-Ketten für Auswertung:
1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
3. **Kollaboration:** Invite Generated → Invite Accepted
4. **Export:** Travel Day Saved → PDF Exported / CSV Exported
5. **Datensicherung:** Backup Exported → Backup Restored
4. **Öffentliche Freigabe:** Logbook Shared → Public Link Opened
5. **Export:** Travel Day Saved → PDF Exported / CSV Exported
6. **Datensicherung:** Backup Exported → Backup Restored
## Entwicklung
+424
View File
@@ -0,0 +1,424 @@
# Implementierungsplan: Push-Benachrichtigungen für Logbuch-Owner
**Ziel:** Der Owner eines Logbuchs soll per Web Push informiert werden, wenn ein eingeladenes Crewmitglied (Collaborator mit WRITE) Änderungen synchronisiert — auch wenn die App geschlossen ist.
**Stand Codebase:** Push MVP ist implementiert (`web-push`, Prisma-Modelle, `routes/push.ts`, `pushNotify.ts`, Custom SW `sw.ts`, Settings-UI). API-Auth erfolgt über **HttpOnly-Session-Cookie** (`daagbok_session`) nach WebAuthn-Login — nicht mehr über `X-User-Id`.
---
## 1. Anforderungen
### Funktional (MVP)
| ID | Anforderung |
|----|-------------|
| N-01 | Owner kann Push-Benachrichtigungen global aktivieren/deaktivieren (Opt-in). |
| N-02 | Bei erfolgreichem Sync-Push durch einen **Nicht-Owner-Collaborator** erhält der Owner **eine** zusammengefasste Benachrichtigung pro Logbuch und Request (nicht pro Queue-Item). |
| N-03 | Klick auf die Benachrichtigung öffnet die App auf dem betroffenen Logbuch (Deep-Link `/logbook/:id` o. ä.). |
| N-04 | Benachrichtigungstext ist **generisch** (Zero-Knowledge: Server kann Titel/Inhalt nicht lesen). |
| N-05 | DE/EN über i18n-Keys; Sprache aus Browser/`Accept-Language` oder gespeicherter App-Sprache in Subscription-Metadaten. |
| N-06 | Abgelaufene/ungültige Subscriptions werden beim Fehlerversand gelöscht (410 Gone). |
### Nicht im MVP (später)
- Push an Collaborators bei Owner-Änderungen (bidirektional).
- Pro-Logbuch Ein/Aus (nur global reicht zunächst).
- Inhaltliche Details („Eintrag #3 bearbeitet“) — würde Klartext auf dem Server erfordern.
- E-Mail/SMS als Fallback.
- „Quiet hours“ / Do-not-disturb-Zeiten.
### Akzeptanzkriterien (UAT)
1. Owner aktiviert Push in den Einstellungen → Browser fragt Berechtigung → Subscription liegt in DB.
2. Collaborator bearbeitet Eintrag, App des Collaborators synct → Owner erhält Push innerhalb weniger Sekunden (Gerät online, Berechtigung erteilt).
3. Owner mit deaktivierten Push-Einstellungen erhält nichts.
4. Bulk-Sync (10 Items) → genau **eine** Push-Nachricht.
5. Klick öffnet installierte PWA oder Browser-Tab mit korrektem Logbuch.
---
## 2. Architektur
```mermaid
sequenceDiagram
participant Crew as Crew-Client
participant API as Express API
participant DB as PostgreSQL
participant Push as web-push (VAPID)
participant SW as Service Worker (Owner)
participant Owner as Owner-Gerät
Crew->>API: POST /api/sync/push (Session-Cookie)
API->>DB: Payloads speichern
API->>API: collaborator change? → notify owner
API->>DB: PushSubscriptions (owner)
API->>Push: sendNotification (pro Endpoint)
Push->>SW: Push Event
SW->>Owner: System-Benachrichtigung
Owner->>SW: notificationclick
SW->>Owner: openWindow(/logbook/:id)
```
### Komponenten
| Schicht | Neu/Geändert | Aufgabe |
|---------|--------------|---------|
| **Prisma** | Neu | `PushSubscription`, optional `UserNotificationPrefs` |
| **Server** | Neu | `routes/push.ts`, `services/pushNotify.ts`, Env VAPID |
| **sync.ts** | Änderung | Nach erfolgreichem Collaborator-Push Owner benachrichtigen |
| **Client SW** | Neu | Custom SW (`injectManifest`) mit `push` + `notificationclick` |
| **Client UI** | Neu | Einstellungen: Toggle, Permission-Flow, Status |
| **Client Service** | Neu | `pushNotifications.ts` — subscribe, unsubscribe, sync mit API |
---
## 3. Plattform- und Produkt-Hinweise
| Thema | Auswirkung |
|-------|------------|
| **iOS** | Web Push für installierte PWAs ab **iOS 16.4+**. Nutzer müssen App zum Home Screen hinzufügen und Push erlauben. |
| **Android / Desktop** | Chrome/Edge/Firefox: gut unterstützt; PWA installiert empfohlen. |
| **HTTPS** | Web Push nur über HTTPS (Produktion erfüllt das). |
| **Zero-Knowledge** | Text z. B. „Neue Änderung in einem Ihrer Logbücher“ + `logbookId` nur im `data`-Payload (nicht im sichtbaren Titel nötig). |
| **Datenschutz** | Push-Endpoints sind personenbezogen → in Datenschutzerklärung erwähnen; Löschung bei Account-Löschung (Cascade). |
---
## 4. Datenmodell (Prisma)
```prisma
model PushSubscription {
id String @id @default(uuid())
userId String
endpoint String @unique
p256dh String // keys.p256dh (base64url)
auth String // keys.auth (base64url)
userAgent String? // optional, Debugging
locale String? // "de" | "en" — für Notification-Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model UserNotificationPrefs {
userId String @id
collaboratorChangesEnabled Boolean @default(false)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
```
`User`-Relationen ergänzen: `pushSubscriptions`, `notificationPrefs`.
**Migration:** `npx prisma migrate dev --name add_push_subscriptions`
---
## 5. Server-Implementierung
### 5.1 Abhängigkeit & Umgebung
```bash
npm install web-push --workspace=server
```
`.env` (Beispiel):
```env
ORIGIN=https://kapteins-daagbok.eu
SESSION_SECRET=... # min. 32 Zeichen, Pflicht in Produktion
VAPID_PUBLIC_KEY=...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
```
Keys einmalig erzeugen:
```bash
npx web-push generate-vapid-keys
```
Öffentlichen Key zusätzlich als `VITE_VAPID_PUBLIC_KEY` für den Client (nur Public Key).
### 5.2 API-Routen (`/api/push`)
| Methode | Pfad | Auth | Beschreibung |
|---------|------|------|--------------|
| `GET` | `/vapid-public-key` | nein | Liefert Public Key für `pushManager.subscribe` |
| `PUT` | `/subscription` | Session-Cookie | Upsert Subscription (endpoint + keys) |
| `DELETE` | `/subscription` | Session-Cookie | Body: `{ endpoint }` — Gerät abmelden |
| `GET` | `/prefs` | Session-Cookie | Liest `collaboratorChangesEnabled` |
| `PUT` | `/prefs` | Session-Cookie | Body: `{ collaboratorChangesEnabled: boolean }` |
`requireUser` in `server/src/middleware/auth.ts` — liest und verifiziert `daagbok_session` (HMAC-signiert). Client sendet `credentials: 'include'` (`client/src/services/api.ts`).
### 5.3 Benachrichtigungs-Service
**Datei:** `server/src/services/pushNotify.ts`
```ts
// Pseudocode — Kernlogik
export async function notifyOwnerOfCollaboratorChanges(
logbookId: string,
ownerUserId: string,
actorUserId: string,
changeCount: number
): Promise<void>
```
Ablauf:
1. `UserNotificationPrefs`: wenn `collaboratorChangesEnabled !== true` → return.
2. Alle `PushSubscription` für `ownerUserId` laden.
3. Payload (Web Push JSON):
```json
{
"title": "Kapteins Daagbok",
"body": "Neue Änderung in einem Ihrer Logbücher.",
"tag": "logbook-change-{logbookId}",
"renotify": false,
"data": { "url": "/logbook/{logbookId}", "logbookId": "{logbookId}", "changeCount": 3 }
}
```
4. `webpush.sendNotification(subscription, payload, options)` parallel mit `Promise.allSettled`.
5. Bei Status **410** oder **404**: Subscription aus DB löschen.
6. Fehler loggen, Sync-Response **nicht** fehlschlagen lassen (Push ist Best-Effort).
**Deduplizierung / Rate-Limit (empfohlen):**
- In-Memory-Map `ownerId:logbookId → lastSentAt` mit TTL 25 Minuten, **oder**
- Redis/DB-Tabelle `NotificationThrottle` mit `lastSentAt`.
Verhindert Push-Spam bei großen Offline-Queues.
### 5.4 Hook in `sync.ts`
Nach der Schleife über `items` (oder innerhalb, mit Sammellogik):
```ts
// Pro Request sammeln:
const ownerNotifications = new Map<string, { logbookId: string; count: number }>()
// Bei jedem erfolgreichen Item:
if (res.status === 'success' && !isOwner && isCollaborator) {
if (action === 'create' || action === 'update') {
const ownerId = logbook.userId
const key = `${ownerId}:${logbookId}`
const prev = ownerNotifications.get(key) ?? { logbookId, count: 0 }
prev.count++
ownerNotifications.set(key, prev)
}
}
// Nach der Schleife, async fire-and-forget:
for (const [key, { logbookId, count }] of ownerNotifications) {
const ownerId = key.split(':')[0]
void notifyOwnerOfCollaboratorChanges(logbookId, ownerId, req.userId, count)
}
```
**Wichtig:** Owner, der selbst als „Crew“ irrtümlich synct, ist `isOwner` — kein Push.
**Optional später:** auch `delete`-Aktionen einbeziehen (gleiche Logik).
### 5.5 `index.ts`
```ts
import pushRouter from './routes/push.js'
app.use('/api/push', pushRouter)
```
---
## 6. Client-Implementierung
### 6.1 Service Worker (Custom `injectManifest`)
`vite.config.ts` anpassen:
```ts
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectRegister: 'auto',
// manifest unverändert
})
```
**Datei:** `client/src/sw.ts`
- `precacheAndRoute` von Workbox importieren (wie vite-plugin-pwa-Doku).
- `self.addEventListener('push', …)`:
- `event.data.json()` parsen
- `self.registration.showNotification(title, { body, tag, data, icon: '/logo.png' })`
- `notificationclick`:
- `event.notification.close()`
- `clients.openWindow(data.url || '/')` — absolute URL mit `self.location.origin`
**i18n im SW:** MVP mit serverseitigem `locale` in Subscription; alternativ nur EN/DE-Body vom Server senden.
### 6.2 Client-Service `pushNotifications.ts`
| Funktion | Beschreibung |
|----------|--------------|
| `isPushSupported()` | `'serviceWorker' in navigator && 'PushManager' in window` |
| `getPermissionState()` | `Notification.permission` |
| `subscribeToPush()` | SW ready → `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })``PUT /api/push/subscription` |
| `unsubscribeFromPush()` | `subscription.unsubscribe()` + `DELETE` API |
| `syncPrefs(enabled)` | `PUT /api/push/prefs` |
| `ensureSubscriptionOnLogin()` | Wenn Prefs an und Permission granted, Subscription erneuern (Key-Rotation) |
`applicationServerKey`: VAPID Public Key von `GET /api/push/vapid-public-key` oder Build-Time `import.meta.env.VITE_VAPID_PUBLIC_KEY`.
### 6.3 UI (Settings)
**Ort:** `SettingsForm.tsx` (nur für Owner sichtbar, nicht bei `readOnly` / Crew-Logbuch).
Ablauf beim Einschalten:
1. `Notification.requestPermission()` — bei `denied` Hinweis + Link zu Browser-Einstellungen.
2. `subscribeToPush()` + `syncPrefs(true)`.
3. Bei Erfolg: grüner Status „Push aktiv“.
Beim Ausschalten:
1. `syncPrefs(false)` + optional `unsubscribeFromPush()` auf diesem Gerät.
**Hinweis-Banner** wenn `!isPushSupported()` oder iOS & nicht installiert → Verweis auf `PwaInstallPrompt`.
### 6.4 Deep-Link beim Öffnen
In `App.tsx` oder Router: beim Start `url` aus `notificationclick` via `clients.matchAll` nicht nötig — SW öffnet direkt.
Sicherstellen, dass Route `/logbook/:logbookId` (oder bestehende Logbuch-Route) existiert und Auth-Gate passiert.
### 6.5 Bestehenden SW-Update-Flow
`usePwaUpdate.ts` bleibt kompatibel mit `injectManifest`, sofern `virtual:pwa-register` weiter registriert wird — vite-plugin-pwa-Doku für `injectManifest` + React beachten.
---
## 7. Sicherheit
| Risiko | Maßnahme |
|--------|----------|
| Fremde subscriben mit fremder `userId` | Session-Cookie nach WebAuthn; `userId` kommt aus verifiziertem Token, nicht aus Client-Header. |
| Push an falschen User | `notifyOwner` nur mit `logbook.userId` aus DB, nie aus Client-Body. |
| Endpoint-Injection | `endpoint` muss HTTPS-URL sein; Länge begrenzen. |
| Spam durch Crew | Rate-Limit + nur `create`/`update` im MVP. |
| VAPID Private Key | Nur Server-Env, nie im Client. |
---
## 8. Implementierungsphasen
### Phase 1 — Infrastruktur (12 Tage)
- [ ] VAPID-Keys für Dev/Prod
- [ ] Prisma-Modelle + Migration
- [ ] `web-push` + `pushNotify.ts` + Unit-Test mit Mock-Subscription
- [ ] Routen `/api/push/*`
- [ ] `GET /vapid-public-key`
### Phase 2 — Service Worker (1 Tag)
- [ ] Umstellung auf `injectManifest` + `sw.ts`
- [ ] `push` / `notificationclick` Handler
- [ ] Manueller Test: `web-push` CLI oder kleines Admin-Skript sendet Test-Push
### Phase 3 — Trigger & Client-Anbindung (12 Tage)
- [ ] Hook in `sync.ts` mit Aggregation
- [ ] `pushNotifications.ts`
- [ ] Settings-UI + i18n (`de.json` / `en.json`)
- [ ] Plausible-Event optional: `push_enabled`, `push_denied`
### Phase 4 — Härtung (1 Tag)
- [ ] Rate-Limit / `tag`-basierte Ersetzung gleicher Logbuch-Pushes
- [ ] 410-Cleanup
- [ ] README + Datenschutz-Hinweis
- [ ] E2E-Manual-Testmatrix (iOS PWA, Android Chrome, Desktop)
### Phase 5 — Deployment
- [ ] Env-Variablen in Produktion (Docker/Hosting)
- [ ] Nginx: `sw.js` weiterhin `no-cache` (bereits in `nginx.conf`)
- [ ] Smoke-Test nach Deploy
**Geschätzter Gesamtaufwand:** 46 Entwicklertage für MVP.
---
## 9. Testplan
| # | Szenario | Erwartung |
|---|----------|-----------|
| T1 | Push nicht unterstützt (alter Browser) | UI zeigt „nicht verfügbar“, kein Fehler |
| T2 | Permission denied | Toggle aus, erklärender Hinweis |
| T3 | Owner aktiviert, Crew synct 1 Eintrag | 1 Push |
| T4 | Crew synct 5 Einträge in einem Request | 1 Push |
| T5 | Owner Prefs aus | Kein Push |
| T6 | Ungültige Subscription | 410 → DB-Eintrag weg, nächster Push an andere Geräte ok |
| T7 | notificationclick | App öffnet richtiges Logbuch |
| T8 | Owner ändert selbst | Kein Push an sich selbst |
**Dev-Test ohne zweites Gerät:** Zwei Browser-Profile (Owner + Crew), Crew-Einladung wie in Produktion.
---
## 10. Offene Entscheidungen (vor Start klären)
1. **Nur Owner oder auch andere Collaborators?** — MVP: nur Owner.
2. **Rate-Limit-Dauer:** 2 min vs. 5 min — Empfehlung: **3 min** pro Logbuch.
3. **Mehrere Geräte des Owners:** alle Subscriptions benachrichtigen — ja (Standard).
4. ~~**Auth verbessern**~~ — erledigt: HttpOnly-Session-Cookie für alle geschützten Routen inkl. Push.
---
## 11. Referenzen
- [web-push (npm)](https://www.npmjs.com/package/web-push)
- [MDN: Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
- [vite-plugin-pwa: injectManifest](https://vite-pwa-org.netlify.app/guide/inject-manifest.html)
- [Apple: Web Push for PWAs (iOS 16.4+)](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/)
---
## 12. Datei-Checkliste (neu/geändert)
```
server/
prisma/schema.prisma # PushSubscription, UserNotificationPrefs
prisma/migrations/.../
src/routes/push.ts # neu
src/services/pushNotify.ts # neu
src/routes/sync.ts # Hook notifyOwner
src/index.ts # Router mount
package.json # web-push
client/
src/sw.ts # neu (injectManifest)
vite.config.ts # strategies: injectManifest
src/services/pushNotifications.ts # neu
src/components/PushNotificationSettings.tsx # neu (optional)
src/components/SettingsForm.tsx # Integration
src/i18n/locales/de.json, en.json
.env.example # VITE_VAPID_PUBLIC_KEY
src/services/api.ts # apiFetch (credentials: include)
server/
src/session.ts # Session-Cookie signieren/verifizieren
src/middleware/auth.ts # requireUser, requireReauth
docs/
push-notifications-plan.md # dieses Dokument
README.md # Auth/Session, Env-Hinweise
```
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* Generates the beta flyer PDF from docs/marketing/beta-flyer.html
* Usage: npm run generate:flyer --prefix client
*/
import { execSync } from 'node:child_process'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '..')
const clientDir = resolve(repoRoot, 'client')
const marketingDir = resolve(repoRoot, 'docs/marketing')
const assetsDir = resolve(marketingDir, 'assets')
const htmlPath = resolve(marketingDir, 'beta-flyer.html')
const qrPath = resolve(assetsDir, 'qr-kapteins-daagbok.eu.png')
const pdfPath = resolve(marketingDir, 'kapteins-daagbok-beta-flyer.pdf')
const appUrl = 'https://kapteins-daagbok.eu'
const require = createRequire(resolve(clientDir, 'package.json'))
function isMissingBrowserError(err) {
const msg = err instanceof Error ? err.message : String(err)
return msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')
}
async function ensurePlaywrightChromium(playwright) {
try {
const browser = await playwright.chromium.launch({ headless: true })
await browser.close()
return
} catch (err) {
if (!isMissingBrowserError(err)) throw err
}
console.log('Playwright Chromium fehlt — installiere Browser (einmalig)…')
execSync('npx playwright install chromium', {
cwd: clientDir,
stdio: 'inherit'
})
}
async function ensureQrCode() {
let QRCode
try {
QRCode = require('qrcode')
} catch {
console.error('Fehlende Abhängigkeit: "npm install -D qrcode playwright" in client/ ausführen.')
process.exit(1)
}
await mkdir(assetsDir, { recursive: true })
const png = await QRCode.toBuffer(appUrl, {
type: 'png',
width: 512,
margin: 1,
color: { dark: '#0f172a', light: '#ffffff' }
})
await writeFile(qrPath, png)
console.log('QR code written:', qrPath)
}
async function renderPdf() {
let playwright
try {
playwright = require('playwright')
} catch {
console.error('Fehlende Abhängigkeit: "npm install -D playwright" in client/ ausführen.')
process.exit(1)
}
await ensurePlaywrightChromium(playwright)
const browser = await playwright.chromium.launch({ headless: true })
try {
const page = await browser.newPage()
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
await page.pdf({
path: pdfPath,
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 }
})
console.log('PDF written:', pdfPath)
} finally {
await browser.close()
}
}
await ensureQrCode()
await renderPdf()
+1
View File
@@ -44,6 +44,7 @@ if [ "$IS_READY" = true ]; then
echo "SUCCESS: Services are up and healthy!"
echo " -> App Frontend (Nginx): http://localhost"
echo " -> Backend API Health: http://localhost/api/health"
echo " -> Auth: session cookie (set ORIGIN=http://localhost, SESSION_SECRET in .env)"
echo "=================================================="
else
echo "WARNING: Backend did not transition to healthy in time."
+117 -4
View File
@@ -3,12 +3,96 @@
# Configuration
SERVER_PORT=5000
CLIENT_PORT=5173
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
resolve_node_toolchain() {
# Common install locations when login shell PATH is not loaded
if command -v npm >/dev/null 2>&1; then
return 0
fi
if [ -s "$HOME/.nvm/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$HOME/.nvm/nvm.sh"
elif [ -s "/usr/local/nvm/nvm.sh" ]; then
# shellcheck disable=SC1090
. "/usr/local/nvm/nvm.sh"
fi
if [ -d "$HOME/.fnm" ] && command -v fnm >/dev/null 2>&1; then
eval "$(fnm env)"
fi
for candidate in \
/usr/local/bin/npm \
/usr/bin/npm \
"$HOME/.local/share/fnm/current/bin/npm" \
"$HOME/.nvm/versions/node/"*/bin/npm; do
if [ -x "$candidate" ]; then
export PATH="$(dirname "$candidate"):$PATH"
break
fi
done
command -v npm >/dev/null 2>&1
}
check_dev_env() {
local env_file="$REPO_ROOT/.env"
if [ ! -f "$env_file" ]; then
echo "Warning: $env_file missing — copy from .env.example (RP_ID, ORIGIN, SESSION_SECRET)."
return
fi
local origin_line origin_val
origin_line=$(grep -E '^ORIGIN=' "$env_file" | tail -1 || true)
origin_val="${origin_line#ORIGIN=}"
origin_val="${origin_val%\"}"
origin_val="${origin_val#\"}"
local expected_origin="http://localhost:$CLIENT_PORT"
if [ -n "$origin_val" ] && [ "$origin_val" != "$expected_origin" ]; then
echo "Warning: ORIGIN=$origin_val — for Vite dev use ORIGIN=$expected_origin (session cookie + CORS)."
fi
local secret_line secret_val
secret_line=$(grep -E '^SESSION_SECRET=' "$env_file" | tail -1 || true)
secret_val="${secret_line#SESSION_SECRET=}"
secret_val="${secret_val%\"}"
secret_val="${secret_val#\"}"
if [ -z "$secret_val" ]; then
echo "Note: SESSION_SECRET is empty — backend uses a dev-only fallback (not for production)."
elif [ "${#secret_val}" -lt 32 ]; then
echo "Warning: SESSION_SECRET should be at least 32 characters."
fi
}
require_node_toolchain() {
if resolve_node_toolchain; then
echo "Using Node $(node -v), npm $(npm -v)"
return 0
fi
echo "Error: npm was not found in PATH."
echo ""
echo "This script starts local Vite/Express dev servers and requires Node.js 20+ with npm."
echo "Install Node.js on this machine, or use the Docker-based stack instead:"
echo " ./scripts/start-dev-docker.sh"
echo ""
echo "On the production host, prefer updating the running stack:"
echo " docker compose -f docker-compose.yml up -d --build"
echo " # or from your workstation: ./scripts/update-prod.sh"
exit 1
}
echo "========================================"
echo " Kapteins Daagbok Dev Environment "
echo "========================================"
echo "Preparing to (re)start services..."
require_node_toolchain
check_dev_env
# Clean up processes running on ports
cleanup_port() {
local port=$1
@@ -77,23 +161,52 @@ fi
# Start backend server
echo "Starting backend API server..."
cd server
cd "$REPO_ROOT/server" || exit 1
if [ ! -d node_modules ]; then
echo "Error: server/node_modules missing. Run: cd server && npm ci"
exit 1
fi
npm run dev &
cd ..
BACKEND_PID=$!
cd "$REPO_ROOT" || exit 1
# Sleep briefly to let server start up
sleep 1.5
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
echo "Error: Backend dev server exited immediately. Check server logs above."
exit 1
fi
# Start frontend client
echo "Starting frontend dev server..."
cd client
cd "$REPO_ROOT/client" || exit 1
if [ ! -d node_modules ]; then
echo "Error: client/node_modules missing. Run: cd client && npm ci"
kill "$BACKEND_PID" 2>/dev/null
exit 1
fi
# Vite 6+ via plugin-react 4; refresh lockfile after package.json changes
if ! node -e "require.resolve('vite/package.json')" 2>/dev/null; then
echo "Client dependencies incomplete — running npm ci..."
npm ci || exit 1
fi
npm run dev &
cd ..
CLIENT_PID=$!
cd "$REPO_ROOT" || exit 1
sleep 1.5
if ! kill -0 "$CLIENT_PID" 2>/dev/null; then
echo "Error: Frontend dev server exited immediately. Check client logs above."
kill "$BACKEND_PID" 2>/dev/null
exit 1
fi
echo "========================================"
echo "Dev services are now running:"
echo " -> Backend: http://localhost:$SERVER_PORT"
echo " -> Frontend: http://localhost:$CLIENT_PORT"
echo " -> API auth: HttpOnly session cookie (after Passkey login)"
echo " -> Health: http://localhost:$SERVER_PORT/api/health"
echo "========================================"
echo "Press Ctrl+C to terminate both servers."
echo "========================================"
+227 -1
View File
@@ -10,15 +10,21 @@
"dependencies": {
"@prisma/client": "^5.10.2",
"@simplewebauthn/server": "^9.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"prisma": "^5.10.2"
"express-rate-limit": "^8.5.2",
"helmet": "^8.2.0",
"prisma": "^5.10.2",
"web-push": "^3.6.7"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.11.24",
"@types/web-push": "^3.6.4",
"tsx": "^4.7.1",
"typescript": "^5.3.3"
}
@@ -655,6 +661,16 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -762,6 +778,16 @@
"@types/node": "*"
}
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -775,12 +801,33 @@
"node": ">= 0.6"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -795,6 +842,12 @@
"node": ">=12.0.0"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@@ -819,6 +872,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -887,6 +946,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -973,6 +1051,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -1121,6 +1208,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1253,6 +1358,27 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1273,6 +1399,42 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/https-proxy-agent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -1291,6 +1453,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1300,6 +1471,27 @@
"node": ">= 0.10"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1369,6 +1561,21 @@
"node": ">= 0.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -1800,6 +2007,25 @@
"node": ">= 0.8"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+7 -1
View File
@@ -12,15 +12,21 @@
"dependencies": {
"@prisma/client": "^5.10.2",
"@simplewebauthn/server": "^9.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"prisma": "^5.10.2"
"express-rate-limit": "^8.5.2",
"helmet": "^8.2.0",
"prisma": "^5.10.2",
"web-push": "^3.6.7"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.11.24",
"@types/web-push": "^3.6.4",
"tsx": "^4.7.1",
"typescript": "^5.3.3"
}
+26
View File
@@ -20,6 +20,32 @@ model User {
credentials Credential[]
logbooks Logbook[]
collaborations Collaboration[]
pushSubscriptions PushSubscription[]
notificationPrefs UserNotificationPrefs?
}
model PushSubscription {
id String @id @default(uuid())
userId String
endpoint String @unique
p256dh String
auth String
userAgent String?
locale String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model UserNotificationPrefs {
userId String @id
collaboratorChangesEnabled Boolean @default(false)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Credential {
+58
View File
@@ -0,0 +1,58 @@
import type { CorsOptions } from 'cors'
function normalizeOrigin(origin: string): string {
return origin.trim().replace(/\/$/, '')
}
/** Origins allowed for credentialed CORS (must match the browser frontend URL exactly). */
export function getAllowedCorsOrigins(): Set<string> {
const raw =
process.env.CORS_ORIGINS?.trim() ||
process.env.ORIGIN?.trim() ||
'http://localhost:5173'
const origins = raw
.split(',')
.map(normalizeOrigin)
.filter(Boolean)
const allowed = new Set(origins)
if (process.env.NODE_ENV !== 'production') {
for (const dev of [
'http://localhost:5173',
'http://127.0.0.1:5173',
'http://localhost:4173'
]) {
allowed.add(dev)
}
}
return allowed
}
export function buildCorsOptions(): CorsOptions {
const allowed = getAllowedCorsOrigins()
return {
origin(origin, callback) {
// Non-browser clients, same-origin via reverse proxy (no Origin header)
if (!origin) {
callback(null, true)
return
}
const normalized = normalizeOrigin(origin)
if (allowed.has(normalized)) {
callback(null, normalized)
return
}
console.warn(
`[cors] Rejected origin "${origin}". Allowed: ${[...allowed].join(', ')}`
)
callback(new Error('Not allowed by CORS'))
},
credentials: true
}
}
+43 -4
View File
@@ -1,27 +1,67 @@
import express from 'express'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import dotenv from 'dotenv'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import authRouter from './routes/auth.js'
import logbooksRouter from './routes/logbooks.js'
import syncRouter from './routes/sync.js'
import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js'
import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js'
import feedbackRouter from './routes/feedback.js'
import { prisma } from './db.js'
import { buildCorsOptions } from './cors.js'
dotenv.config()
const __dirname = dirname(fileURLToPath(import.meta.url))
dotenv.config({ path: resolve(__dirname, '../../.env') })
dotenv.config({ path: resolve(__dirname, '../.env') })
const app = express()
const PORT = process.env.PORT || 5000
app.use(cors())
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
})
)
app.use(cors(buildCorsOptions()))
app.use(cookieParser())
// Encrypted sync payloads (photos, GPS tracks) can be large — align with nginx client_max_body_size
app.use(express.json({ limit: '50mb' }))
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false
})
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false
})
app.use('/api/auth', authLimiter)
app.use('/api', apiLimiter)
// Mount routes
app.use('/api/auth', authRouter)
app.use('/api/logbooks', logbooksRouter)
app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter)
app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter)
app.use('/api/feedback', feedbackRouter)
// Health check endpoint
app.get('/api/health', async (req, res) => {
@@ -33,11 +73,10 @@ app.get('/api/health', async (req, res) => {
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
} catch (err: any) {
} catch {
res.status(500).json({
status: 'error',
database: 'disconnected',
error: err.message,
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
+33
View File
@@ -0,0 +1,33 @@
import type { Request, Response, NextFunction } from 'express'
import { hasValidReauth, readSessionFromRequest } from '../session.js'
export interface AuthedRequest extends Request {
userId: string
session: NonNullable<ReturnType<typeof readSessionFromRequest>>
}
export function requireUser(req: Request, res: Response, next: NextFunction): void {
const session = readSessionFromRequest(req)
if (!session) {
res.status(401).json({ error: 'Unauthorized: valid session required' })
return
}
;(req as AuthedRequest).userId = session.userId
;(req as AuthedRequest).session = session
next()
}
export function requireReauth(req: Request, res: Response, next: NextFunction): void {
const session = readSessionFromRequest(req)
if (!session) {
res.status(401).json({ error: 'Unauthorized: valid session required' })
return
}
if (!hasValidReauth(session)) {
res.status(403).json({ error: 'Recent passkey confirmation required' })
return
}
;(req as AuthedRequest).userId = session.userId
;(req as AuthedRequest).session = session
next()
}
@@ -0,0 +1,79 @@
import rateLimit from 'express-rate-limit'
import type { AuthedRequest } from './auth.js'
const MIN_SUBMIT_MS = 2_000
const MAX_SUBMIT_MS = 60 * 60 * 1000
const DUPLICATE_WINDOW_MS = 10 * 60 * 1000
const MAX_URLS = 8
const MAX_REPEATED_CHAR = 40
const recentByUser = new Map<string, { hash: string; at: number }>()
function normalizeMessage(message: string): string {
return message.trim().toLowerCase().replace(/\s+/g, ' ')
}
function countUrls(message: string): number {
const matches = message.match(/https?:\/\/|www\./gi)
return matches?.length ?? 0
}
function hasExcessiveRepeatedChars(message: string): boolean {
return /(.)\1{39,}/.test(message)
}
function pruneRecentEntries(now: number): void {
for (const [userId, entry] of recentByUser) {
if (now - entry.at > DUPLICATE_WINDOW_MS) {
recentByUser.delete(userId)
}
}
}
export type FeedbackSpamVerdict = 'ok' | 'silent_reject' | 'reject'
export function analyzeFeedbackSpam(
userId: string,
payload: { message: string; website?: unknown; openedAt?: unknown }
): FeedbackSpamVerdict {
if (typeof payload.website === 'string' && payload.website.trim()) {
return 'silent_reject'
}
if (typeof payload.openedAt === 'number' && Number.isFinite(payload.openedAt)) {
const elapsed = Date.now() - payload.openedAt
if (elapsed < MIN_SUBMIT_MS || elapsed > MAX_SUBMIT_MS) {
return 'silent_reject'
}
}
const normalized = normalizeMessage(payload.message)
const now = Date.now()
pruneRecentEntries(now)
const previous = recentByUser.get(userId)
if (previous && previous.hash === normalized && now - previous.at < DUPLICATE_WINDOW_MS) {
return 'reject'
}
if (countUrls(payload.message) > MAX_URLS || hasExcessiveRepeatedChars(payload.message)) {
return 'reject'
}
recentByUser.set(userId, { hash: normalized, at: now })
return 'ok'
}
export const feedbackLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => (req as AuthedRequest).userId ?? req.ip ?? 'unknown',
handler: (_req, res) => {
res.status(429).json({
error: 'Too many feedback submissions. Please try again later.',
code: 'RATE_LIMITED'
})
}
})
+119 -37
View File
@@ -6,6 +6,14 @@ import {
verifyAuthenticationResponse
} from '@simplewebauthn/server'
import { prisma } from '../db.js'
import { requireReauth, requireUser } from '../middleware/auth.js'
import {
clearSessionCookie,
extendReauth,
readSessionFromRequest,
setSessionCookie,
setSessionTokenCookie
} from '../session.js'
const router = Router()
@@ -13,12 +21,9 @@ const rpName = 'Kapteins Daagbok'
const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173'
// In-memory challenge stores
const registrationChallenges = new Map<string, string>()
const authenticationChallenges = new Map<string, { challenge: string; userId: string }>()
const activeChallenges = new Set<string>()
// 1. Generate Registration Options
router.post('/register-options', async (req, res) => {
try {
const { username } = req.body
@@ -34,13 +39,6 @@ router.post('/register-options', async (req, res) => {
return res.status(400).json({ error: 'User already exists' })
}
// NOTE: @simplewebauthn/server v9 places `userID` verbatim into the
// emitted `user.id` JSON field. The browser client (v13) however decodes
// `user.id` as a base64url string. Passing a raw username therefore either
// corrupts the user handle or, for usernames containing characters outside
// the base64url alphabet (".", " ", "@", umlauts, ...), makes the browser
// throw "Invalid character" before the passkey prompt even appears.
// Encoding the username as base64url keeps the value spec-compliant.
const userID = Buffer.from(username, 'utf8').toString('base64url')
const options = await generateRegistrationOptions({
@@ -54,10 +52,9 @@ router.post('/register-options', async (req, res) => {
residentKey: 'required',
userVerification: 'preferred'
},
supportedAlgorithmIDs: [-7, -257] // ES256 and RS256
supportedAlgorithmIDs: [-7, -257]
})
// Store challenge
registrationChallenges.set(username, options.challenge)
return res.json(options)
@@ -67,7 +64,6 @@ router.post('/register-options', async (req, res) => {
}
})
// 2. Verify Registration Response
router.post('/register-verify', async (req, res) => {
try {
const {
@@ -103,7 +99,6 @@ router.post('/register-verify', async (req, res) => {
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo
// Save user and credential
const user = await prisma.user.create({
data: {
username,
@@ -125,6 +120,7 @@ router.post('/register-verify', async (req, res) => {
})
registrationChallenges.delete(username)
setSessionCookie(res, user.id, true)
return res.json({ verified: true, userId: user.id })
} catch (error: any) {
@@ -133,12 +129,10 @@ router.post('/register-verify', async (req, res) => {
}
})
// 3. Generate Authentication Options
router.post('/login-options', async (req, res) => {
try {
const { username } = req.body
// If username is supplied, we do a targeted login, otherwise usernameless
let allowCredentials: any[] = []
if (username) {
const user = await prisma.user.findUnique({
@@ -146,7 +140,7 @@ router.post('/login-options', async (req, res) => {
include: { credentials: true }
})
if (user) {
allowCredentials = user.credentials.map(cred => ({
allowCredentials = user.credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key',
transports: cred.transports as any[]
@@ -160,7 +154,6 @@ router.post('/login-options', async (req, res) => {
userVerification: 'preferred'
})
// Store challenge
activeChallenges.add(options.challenge)
return res.json(options)
@@ -170,7 +163,6 @@ router.post('/login-options', async (req, res) => {
}
})
// 4. Verify Authentication Response
router.post('/login-verify', async (req, res) => {
try {
const { credentialResponse, challenge } = req.body
@@ -178,13 +170,11 @@ router.post('/login-verify', async (req, res) => {
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
}
// Verify challenge
if (!activeChallenges.has(challenge)) {
return res.status(400).json({ error: 'Challenge not found or expired' })
}
activeChallenges.delete(challenge)
// Find the credential in DB
const dbCred = await prisma.credential.findUnique({
where: { credentialId: credentialResponse.id },
include: { user: true }
@@ -212,12 +202,13 @@ router.post('/login-verify', async (req, res) => {
return res.status(400).json({ error: 'Authentication failed' })
}
// Update counter
await prisma.credential.update({
where: { id: dbCred.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
})
setSessionCookie(res, user.id, true)
return res.json({
verified: true,
userId: user.id,
@@ -235,16 +226,112 @@ router.post('/login-verify', async (req, res) => {
}
})
// 5. Delete own account
router.delete('/delete-account', async (req: any, res) => {
router.get('/session', (req, res) => {
const session = readSessionFromRequest(req)
if (!session) {
return res.status(401).json({ authenticated: false })
}
return res.json({ authenticated: true, userId: session.userId })
})
router.post('/logout', (req, res) => {
clearSessionCookie(res)
return res.json({ success: true })
})
router.post('/reauth-options', requireUser, async (req: any, res) => {
try {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
const user = await prisma.user.findUnique({
where: { id: req.userId },
include: { credentials: true }
})
if (!user || user.credentials.length === 0) {
return res.status(400).json({ error: 'No passkey credentials found' })
}
const allowCredentials = user.credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key' as const,
transports: cred.transports as any[]
}))
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification: 'required'
})
activeChallenges.add(options.challenge)
return res.json(options)
} catch (error: any) {
console.error('Error generating reauth options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.post('/reauth-verify', requireUser, async (req: any, res) => {
try {
const { credentialResponse, challenge } = req.body
if (!credentialResponse || !challenge) {
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
}
if (!activeChallenges.has(challenge)) {
return res.status(400).json({ error: 'Challenge not found or expired' })
}
activeChallenges.delete(challenge)
const dbCred = await prisma.credential.findUnique({
where: { credentialId: credentialResponse.id },
include: { user: true }
})
if (!dbCred || dbCred.userId !== req.userId) {
return res.status(403).json({ error: 'Credential does not belong to this account' })
}
const verification = await verifyAuthenticationResponse({
response: credentialResponse,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: Buffer.from(dbCred.credentialId, 'base64url'),
credentialPublicKey: dbCred.publicKey,
counter: Number(dbCred.counter)
}
})
if (!verification.verified || !verification.authenticationInfo) {
return res.status(400).json({ error: 'Reauthentication failed' })
}
await prisma.credential.update({
where: { id: dbCred.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
})
const currentToken = req.cookies?.daagbok_session
const extended = typeof currentToken === 'string' ? extendReauth(currentToken) : null
if (extended) {
setSessionTokenCookie(res, extended)
} else {
setSessionCookie(res, req.userId, true)
}
return res.json({ verified: true })
} catch (error: any) {
console.error('Error verifying reauth:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.delete('/delete-account', requireReauth, async (req: any, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: userId }
where: { id: req.userId }
})
if (!user) {
@@ -252,9 +339,10 @@ router.delete('/delete-account', async (req: any, res) => {
}
await prisma.user.delete({
where: { id: userId }
where: { id: req.userId }
})
clearSessionCookie(res)
return res.json({ success: true })
} catch (error: any) {
console.error('Error deleting account:', error)
@@ -262,14 +350,8 @@ router.delete('/delete-account', async (req: any, res) => {
}
})
// 6. Enroll PRF encrypted master key
router.post('/enroll-prf', async (req: any, res) => {
router.post('/enroll-prf', requireReauth, async (req: any, res) => {
try {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
const { encryptedMasterKeyPrf, encryptedMasterKeyPrfIv, encryptedMasterKeyPrfTag } = req.body
if (!encryptedMasterKeyPrf || !encryptedMasterKeyPrfIv || !encryptedMasterKeyPrfTag) {
return res.status(400).json({ error: 'Missing required PRF key fields' })
@@ -284,7 +366,7 @@ router.post('/enroll-prf', async (req: any, res) => {
}
await prisma.user.update({
where: { id: userId },
where: { id: req.userId },
data: {
encryptedMasterKeyPrf,
encryptedMasterKeyPrfIv,
+1 -10
View File
@@ -1,18 +1,9 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js'
const router = Router()
// Middleware to extract user ID from headers (for authenticated routes)
const requireUser = (req: any, res: any, next: any) => {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
req.userId = userId
next()
}
// 1. Get invitation details (public route, does not require authentication)
router.get('/invite-details', async (req: any, res) => {
try {
+103
View File
@@ -0,0 +1,103 @@
import { Router } from 'express'
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
import { requireUser } from '../middleware/auth.js'
import { analyzeFeedbackSpam, feedbackLimiter } from '../middleware/feedbackProtection.js'
const router = Router()
const VALID_CATEGORIES = new Set(['bug', 'feature', 'general'])
const MAX_MESSAGE_LENGTH = 2000
const MAX_EMAIL_LENGTH = 254
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function parseOptionalEmail(value: unknown): string | undefined {
if (value === undefined || value === null || value === '') return undefined
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
if (trimmed.length > MAX_EMAIL_LENGTH) return undefined
if (!EMAIL_PATTERN.test(trimmed)) return undefined
return trimmed
}
router.get('/status', requireUser, (_req, res) => {
res.json({ enabled: isNtfyConfigured() })
})
router.post('/', requireUser, feedbackLimiter, async (req: any, res) => {
try {
if (!isNtfyConfigured()) {
return res.status(503).json({ error: 'Feedback is not configured on this server' })
}
const {
category,
message,
username,
contactEmail,
logbookId,
logbookTitle,
appVersion,
pageUrl,
website,
openedAt
} = req.body ?? {}
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category)) {
return res.status(400).json({ error: 'Invalid category' })
}
if (typeof message !== 'string' || !message.trim()) {
return res.status(400).json({ error: 'Message is required' })
}
const trimmedMessage = message.trim()
if (trimmedMessage.length > MAX_MESSAGE_LENGTH) {
return res.status(400).json({ error: `Message must be at most ${MAX_MESSAGE_LENGTH} characters` })
}
let parsedContactEmail: string | undefined
if (contactEmail !== undefined && contactEmail !== null && String(contactEmail).trim()) {
parsedContactEmail = parseOptionalEmail(contactEmail)
if (!parsedContactEmail) {
return res.status(400).json({ error: 'Invalid email address' })
}
}
const spamVerdict = analyzeFeedbackSpam(req.userId, {
message: trimmedMessage,
website,
openedAt
})
if (spamVerdict === 'silent_reject') {
return res.json({ ok: true })
}
if (spamVerdict === 'reject') {
return res.status(400).json({
error: 'This feedback could not be sent. Please change your message and try again.',
code: 'SPAM_DETECTED'
})
}
await sendFeedbackViaNtfy({
category,
message: trimmedMessage,
username: typeof username === 'string' ? username.trim() : undefined,
contactEmail: parsedContactEmail,
userId: req.userId,
logbookId: typeof logbookId === 'string' ? logbookId.trim() : undefined,
logbookTitle: typeof logbookTitle === 'string' ? logbookTitle.trim() : undefined,
appVersion: typeof appVersion === 'string' ? appVersion.trim() : undefined,
pageUrl: typeof pageUrl === 'string' ? pageUrl.trim() : undefined
})
return res.json({ ok: true })
} catch (error: any) {
console.error('Error sending feedback via Ntfy:', error)
return res.status(502).json({ error: error.message || 'Failed to send feedback' })
}
})
export default router
+1 -10
View File
@@ -1,18 +1,9 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js'
const router = Router()
// Middleware to extract user ID from headers
const requireUser = (req: any, res: any, next: any) => {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
req.userId = userId
next()
}
router.use(requireUser)
// 1. Get all logbooks for the authenticated user (owned and shared)
+131
View File
@@ -0,0 +1,131 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js'
const router = Router()
function isValidHttpsEndpoint(endpoint: unknown): endpoint is string {
if (typeof endpoint !== 'string' || endpoint.length > 2048) return false
try {
const url = new URL(endpoint)
return url.protocol === 'https:'
} catch {
return false
}
}
router.get('/vapid-public-key', (_req, res) => {
const publicKey = process.env.VAPID_PUBLIC_KEY
if (!publicKey) {
return res.status(503).json({ error: 'Push notifications are not configured on this server' })
}
return res.json({ publicKey })
})
router.use(requireUser)
router.get('/prefs', async (req: any, res) => {
try {
const prefs = await prisma.userNotificationPrefs.findUnique({
where: { userId: req.userId }
})
return res.json({
collaboratorChangesEnabled: prefs?.collaboratorChangesEnabled ?? false
})
} catch (error: any) {
console.error('Error reading push prefs:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.put('/prefs', async (req: any, res) => {
try {
const { collaboratorChangesEnabled } = req.body
if (typeof collaboratorChangesEnabled !== 'boolean') {
return res.status(400).json({ error: 'collaboratorChangesEnabled must be a boolean' })
}
const prefs = await prisma.userNotificationPrefs.upsert({
where: { userId: req.userId },
create: {
userId: req.userId,
collaboratorChangesEnabled,
updatedAt: new Date()
},
update: {
collaboratorChangesEnabled,
updatedAt: new Date()
}
})
return res.json({
collaboratorChangesEnabled: prefs.collaboratorChangesEnabled
})
} catch (error: any) {
console.error('Error updating push prefs:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.put('/subscription', async (req: any, res) => {
try {
const { endpoint, keys, locale, userAgent } = req.body
if (!isValidHttpsEndpoint(endpoint)) {
return res.status(400).json({ error: 'Invalid push subscription endpoint' })
}
if (!keys?.p256dh || !keys?.auth || typeof keys.p256dh !== 'string' || typeof keys.auth !== 'string') {
return res.status(400).json({ error: 'Invalid subscription keys' })
}
const normalizedLocale =
typeof locale === 'string' && (locale === 'de' || locale === 'en') ? locale : null
await prisma.pushSubscription.upsert({
where: { endpoint },
create: {
userId: req.userId,
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
locale: normalizedLocale,
userAgent: typeof userAgent === 'string' ? userAgent.slice(0, 512) : null
},
update: {
userId: req.userId,
p256dh: keys.p256dh,
auth: keys.auth,
locale: normalizedLocale,
userAgent: typeof userAgent === 'string' ? userAgent.slice(0, 512) : null,
updatedAt: new Date()
}
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error saving push subscription:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.delete('/subscription', async (req: any, res) => {
try {
const { endpoint } = req.body
if (!isValidHttpsEndpoint(endpoint)) {
return res.status(400).json({ error: 'Invalid push subscription endpoint' })
}
await prisma.pushSubscription.deleteMany({
where: {
endpoint,
userId: req.userId
}
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error deleting push subscription:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
export default router
+37 -22
View File
@@ -5,6 +5,7 @@ import {
verifyAuthenticationResponse
} from '@simplewebauthn/server'
import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js'
const router = Router()
@@ -31,15 +32,6 @@ function pruneExpiredChallenges() {
}
}
const requireUser = (req: any, res: any, next: any) => {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
req.userId = userId
next()
}
router.use(requireUser)
async function getLogbookWithAccess(logbookId: string, userId: string) {
@@ -65,6 +57,13 @@ function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: stri
return access.isOwner || access.collaboration?.role === 'WRITE'
}
async function hasWriteCollaborators(logbookId: string): Promise<boolean> {
const count = await prisma.collaboration.count({
where: { logbookId, role: 'WRITE' }
})
return count > 0
}
async function getAllowCredentialsForRole(
logbookId: string,
role: 'skipper' | 'crew',
@@ -87,7 +86,16 @@ async function getAllowCredentialsForRole(
})
const userIds = collaborations.map((c) => c.userId)
if (userIds.length === 0) return []
if (userIds.length === 0) {
const credentials = await prisma.credential.findMany({
where: { userId: requestingUserId }
})
return credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key' as const,
transports: cred.transports as any[]
}))
}
const credentials = await prisma.credential.findMany({
where: { userId: { in: userIds } }
@@ -107,14 +115,7 @@ async function isAuthorizedSigner(
role: 'skipper' | 'crew'
): Promise<boolean> {
if (role === 'skipper') {
// Skipper signing: owner or WRITE collaborator (design §2.1), using their own passkey.
if (signerUserId === ownerUserId) return true
const collaboration = await prisma.collaboration.findUnique({
where: {
logbookId_userId: { logbookId, userId: signerUserId }
}
})
return collaboration?.role === 'WRITE'
return signerUserId === ownerUserId
}
const collaboration = await prisma.collaboration.findUnique({
@@ -122,7 +123,13 @@ async function isAuthorizedSigner(
logbookId_userId: { logbookId, userId: signerUserId }
}
})
return collaboration?.role === 'WRITE'
if (collaboration?.role === 'WRITE') return true
if (signerUserId === ownerUserId) {
return !(await hasWriteCollaborators(logbookId))
}
return false
}
router.post('/options', async (req: any, res) => {
@@ -146,6 +153,16 @@ router.post('/options', async (req: any, res) => {
return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
}
const authorized = await isAuthorizedSigner(
logbookId,
access.logbook.userId,
req.userId,
role
)
if (!authorized) {
return res.status(403).json({ error: 'Forbidden: Signer not authorized for this role' })
}
const allowCredentials = await getAllowCredentialsForRole(
logbookId,
role,
@@ -154,9 +171,7 @@ router.post('/options', async (req: any, res) => {
if (allowCredentials.length === 0) {
return res.status(400).json({
error: role === 'crew'
? 'No write collaborators with passkeys found'
: 'No passkey credentials found for signer'
error: 'No passkey credentials found for signer'
})
}
+55 -14
View File
@@ -1,18 +1,10 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { notifyOwnerOfCollaboratorChanges } from '../services/pushNotify.js'
import { requireUser } from '../middleware/auth.js'
const router = Router()
// Middleware to extract user ID from headers
const requireUser = (req: any, res: any, next: any) => {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
req.userId = userId
next()
}
router.use(requireUser)
// 1. Push local changes to the server
@@ -24,6 +16,27 @@ router.post('/push', async (req: any, res) => {
}
const results = []
const ownerNotifications = new Map<
string,
{ ownerId: string; logbookId: string; count: number }
>()
const recordCollaboratorChange = (
ownerId: string,
logbookId: string,
isOwner: boolean,
isCollaborator: unknown,
action: string,
type: string
) => {
if (isOwner || !isCollaborator) return
if (action !== 'create' && action !== 'update') return
if (type === 'logbook') return
const key = `${ownerId}:${logbookId}`
const entry = ownerNotifications.get(key) ?? { ownerId, logbookId, count: 0 }
entry.count += 1
ownerNotifications.set(key, entry)
}
for (const item of items) {
const { action, type, payloadId, logbookId, data, updatedAt } = item
@@ -77,7 +90,7 @@ router.post('/push', async (req: any, res) => {
}
const isOwner = logbook.userId === req.userId
const isCollaborator = await prisma.collaboration.findUnique({
const collaboration = await prisma.collaboration.findUnique({
where: {
logbookId_userId: {
logbookId,
@@ -86,11 +99,16 @@ router.post('/push', async (req: any, res) => {
}
})
if (!isOwner && !isCollaborator) {
if (!isOwner && !collaboration) {
results.push({ payloadId, status: 'error', error: 'Forbidden: Access denied' })
continue
}
if (!isOwner && (!collaboration || collaboration.role !== 'WRITE')) {
results.push({ payloadId, status: 'error', error: 'Forbidden: WRITE access required' })
continue
}
if (type === 'logbook' && action === 'delete') {
if (!isOwner) {
results.push({ payloadId, status: 'error', error: 'Forbidden: Only owner can delete logbook' })
@@ -103,6 +121,17 @@ router.post('/push', async (req: any, res) => {
continue
}
if (!isOwner && (type === 'yacht' || (type === 'crew' && payloadId === 'skipper'))) {
results.push({
payloadId,
status: 'error',
error: type === 'yacht'
? 'Forbidden: Only owner can modify vessel data'
: 'Forbidden: Only owner can modify skipper profile'
})
continue
}
if (action === 'delete') {
if (type === 'yacht') {
await prisma.yachtPayload.deleteMany({ where: { logbookId } })
@@ -218,6 +247,14 @@ router.post('/push', async (req: any, res) => {
}
}
recordCollaboratorChange(
logbook.userId,
logbookId,
isOwner,
collaboration,
action,
type
)
results.push({ payloadId, status: 'success' })
} catch (err: any) {
console.error(`Error processing sync item ${payloadId}:`, err)
@@ -225,6 +262,10 @@ router.post('/push', async (req: any, res) => {
}
}
for (const { ownerId, logbookId, count } of ownerNotifications.values()) {
void notifyOwnerOfCollaboratorChanges(logbookId, ownerId, req.userId, count)
}
return res.json({ results })
} catch (error: any) {
console.error('Error during sync push:', error)
@@ -250,7 +291,7 @@ router.get('/pull', async (req: any, res) => {
}
const isOwner = logbook.userId === req.userId
const isCollaborator = await prisma.collaboration.findUnique({
const collaboration = await prisma.collaboration.findUnique({
where: {
logbookId_userId: {
logbookId,
@@ -259,7 +300,7 @@ router.get('/pull', async (req: any, res) => {
}
})
if (!isOwner && !isCollaborator) {
if (!isOwner && !collaboration) {
return res.status(403).json({ error: 'Forbidden: Access denied' })
}
+51
View File
@@ -0,0 +1,51 @@
import { Router } from 'express'
import { requireUser } from '../middleware/auth.js'
const router = Router()
function resolveOwmApiKey(userProvidedKey: unknown): string | null {
if (typeof userProvidedKey === 'string' && userProvidedKey.trim()) {
return userProvidedKey.trim()
}
const fromEnv =
process.env.OpenWeatherMapAPIKey?.trim() ||
process.env.OPENWEATHERMAP_API_KEY?.trim()
return fromEnv || null
}
router.get('/current', requireUser, async (req, res) => {
try {
const { lat, lon, q } = req.query
const apiKey = resolveOwmApiKey(req.headers['x-owm-api-key'])
if (!apiKey) {
return res.status(503).json({
error: 'No OpenWeatherMap API key configured (user settings or server environment)'
})
}
let url: URL
if (lat && lon) {
url = new URL('https://api.openweathermap.org/data/2.5/weather')
url.searchParams.set('lat', String(lat))
url.searchParams.set('lon', String(lon))
} else if (q && typeof q === 'string' && q.trim()) {
url = new URL('https://api.openweathermap.org/data/2.5/weather')
url.searchParams.set('q', q.trim())
} else {
return res.status(400).json({ error: 'lat and lon, or q (location name) is required' })
}
url.searchParams.set('appid', apiKey)
url.searchParams.set('units', 'metric')
const owmRes = await fetch(url)
const data = await owmRes.json()
return res.status(owmRes.status).json(data)
} catch (error: any) {
console.error('Error fetching OpenWeatherMap data:', error)
return res.status(502).json({ error: error.message || 'Weather lookup failed' })
}
})
export default router
+79
View File
@@ -0,0 +1,79 @@
export interface FeedbackPayload {
category: string
message: string
username?: string
contactEmail?: string
userId: string
logbookId?: string
logbookTitle?: string
appVersion?: string
pageUrl?: string
}
function resolveNtfyConfig(): { server: string; topic: string; token?: string } | null {
const server = (process.env.NTFY_SERVER || 'https://ntfy.sh').replace(/\/+$/, '')
const topic = process.env.NTFY_TOPIC?.trim()
const token = process.env.NTFY_TOKEN?.trim()
if (!topic) return null
return { server, topic, token: token || undefined }
}
export function isNtfyConfigured(): boolean {
return resolveNtfyConfig() !== null
}
export async function sendFeedbackViaNtfy(payload: FeedbackPayload): Promise<void> {
const config = resolveNtfyConfig()
if (!config) {
throw new Error('NTFY_TOPIC is not configured')
}
const categoryLabel = payload.category.charAt(0).toUpperCase() + payload.category.slice(1)
const title = `Kapteins Daagbok - ${categoryLabel}`
const lines = [
payload.message,
'',
'---',
`User: ${payload.username || '(unknown)'}`,
`User ID: ${payload.userId}`
]
if (payload.contactEmail) {
lines.push(`Contact: ${payload.contactEmail}`)
}
if (payload.logbookTitle || payload.logbookId) {
lines.push(`Logbook: ${payload.logbookTitle || payload.logbookId}`)
}
if (payload.appVersion) {
lines.push(`App version: ${payload.appVersion}`)
}
if (payload.pageUrl) {
lines.push(`Page: ${payload.pageUrl}`)
}
const headers: Record<string, string> = {
Title: title,
Tags: 'speech_balloon,ship',
'Content-Type': 'text/plain; charset=utf-8'
}
if (config.token) {
headers.Authorization = `Bearer ${config.token}`
}
const url = `${config.server}/${encodeURIComponent(config.topic)}`
const res = await fetch(url, {
method: 'POST',
headers,
body: lines.join('\n')
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Ntfy request failed (${res.status})${body ? `: ${body}` : ''}`)
}
}
+105
View File
@@ -0,0 +1,105 @@
import webpush from 'web-push'
import { prisma } from '../db.js'
const THROTTLE_MS = 3 * 60 * 1000
const lastSentByLogbook = new Map<string, number>()
let vapidConfigured = false
function ensureVapid(): boolean {
if (vapidConfigured) return true
const publicKey = process.env.VAPID_PUBLIC_KEY
const privateKey = process.env.VAPID_PRIVATE_KEY
const subject = process.env.VAPID_SUBJECT
if (!publicKey || !privateKey || !subject) {
return false
}
webpush.setVapidDetails(subject, publicKey, privateKey)
vapidConfigured = true
return true
}
function isThrottled(ownerUserId: string, logbookId: string): boolean {
const key = `${ownerUserId}:${logbookId}`
const last = lastSentByLogbook.get(key) ?? 0
return Date.now() - last < THROTTLE_MS
}
function markSent(ownerUserId: string, logbookId: string): void {
lastSentByLogbook.set(`${ownerUserId}:${logbookId}`, Date.now())
}
function notificationCopy(locale: string | null | undefined, changeCount: number): { title: string; body: string } {
const isDe = !locale || locale.startsWith('de')
const title = 'Kapteins Daagbok'
if (isDe) {
const body =
changeCount > 1
? `${changeCount} neue Änderungen in einem Ihrer Logbücher.`
: 'Neue Änderung in einem Ihrer Logbücher.'
return { title, body }
}
const body =
changeCount > 1
? `${changeCount} new changes in one of your logbooks.`
: 'New change in one of your logbooks.'
return { title, body }
}
export async function notifyOwnerOfCollaboratorChanges(
logbookId: string,
ownerUserId: string,
_actorUserId: string,
changeCount: number
): Promise<void> {
if (!ensureVapid() || changeCount < 1) return
if (isThrottled(ownerUserId, logbookId)) return
const prefs = await prisma.userNotificationPrefs.findUnique({
where: { userId: ownerUserId }
})
if (!prefs?.collaboratorChangesEnabled) return
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: ownerUserId }
})
if (subscriptions.length === 0) return
markSent(ownerUserId, logbookId)
const payloadBase = {
tag: `logbook-change-${logbookId}`,
renotify: false,
data: {
url: `/?logbook=${encodeURIComponent(logbookId)}`,
logbookId,
changeCount
}
}
await Promise.allSettled(
subscriptions.map(async (sub) => {
const { title, body } = notificationCopy(sub.locale, changeCount)
const payload = JSON.stringify({ title, body, ...payloadBase })
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth }
},
payload
)
} catch (err: unknown) {
const statusCode =
err && typeof err === 'object' && 'statusCode' in err
? (err as { statusCode: number }).statusCode
: undefined
if (statusCode === 404 || statusCode === 410) {
await prisma.pushSubscription.delete({ where: { id: sub.id } }).catch(() => {})
} else {
console.warn('[push] Failed to send notification:', err)
}
}
})
)
}
+101
View File
@@ -0,0 +1,101 @@
import crypto from 'crypto'
import type { CookieOptions, Request, Response } from 'express'
export const SESSION_COOKIE = 'daagbok_session'
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
export const REAUTH_MAX_AGE_MS = 10 * 60 * 1000
export interface SessionPayload {
userId: string
exp: number
reauthExp?: number
}
function sessionSecret(): string {
const secret = process.env.SESSION_SECRET?.trim()
if (secret && secret.length >= 32) return secret
if (process.env.NODE_ENV === 'production') {
throw new Error('SESSION_SECRET must be set in production (min. 32 characters)')
}
return 'dev-only-insecure-session-secret-change-me!!'
}
function sign(data: string): string {
return crypto.createHmac('sha256', sessionSecret()).update(data).digest('base64url')
}
export function createSessionToken(userId: string, withReauth = true): string {
const payload: SessionPayload = {
userId,
exp: Date.now() + SESSION_MAX_AGE_MS,
...(withReauth ? { reauthExp: Date.now() + REAUTH_MAX_AGE_MS } : {})
}
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
const signature = sign(body)
return `${body}.${signature}`
}
export function extendReauth(token: string): string | null {
const payload = verifySessionToken(token)
if (!payload) return null
payload.reauthExp = Date.now() + REAUTH_MAX_AGE_MS
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${body}.${sign(body)}`
}
export function verifySessionToken(token: string | undefined): SessionPayload | null {
if (!token || typeof token !== 'string') return null
const dot = token.lastIndexOf('.')
if (dot <= 0) return null
const body = token.slice(0, dot)
const sig = token.slice(dot + 1)
if (sig !== sign(body)) return null
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as SessionPayload
if (!payload.userId || typeof payload.exp !== 'number') return null
if (payload.exp <= Date.now()) return null
return payload
} catch {
return null
}
}
export function readSessionFromRequest(req: Request): SessionPayload | null {
const raw = req.cookies?.[SESSION_COOKIE]
if (typeof raw !== 'string') return null
return verifySessionToken(raw)
}
export function sessionCookieOptions(): CookieOptions {
const origin = process.env.ORIGIN || 'http://localhost:5173'
const secure = origin.startsWith('https://')
return {
httpOnly: true,
secure,
sameSite: 'lax',
path: '/',
maxAge: SESSION_MAX_AGE_MS
}
}
export function setSessionCookie(res: Response, userId: string, withReauth = true): void {
res.cookie(SESSION_COOKIE, createSessionToken(userId, withReauth), sessionCookieOptions())
}
export function setSessionTokenCookie(res: Response, token: string): void {
res.cookie(SESSION_COOKIE, token, sessionCookieOptions())
}
export function clearSessionCookie(res: Response): void {
res.clearCookie(SESSION_COOKIE, {
httpOnly: true,
secure: sessionCookieOptions().secure,
sameSite: 'lax',
path: '/'
})
}
export function hasValidReauth(payload: SessionPayload): boolean {
return typeof payload.reauthExp === 'number' && payload.reauthExp > Date.now()
}