Compare commits

...

19 Commits

Author SHA1 Message Date
elpatron 646d316a36 chore: release v0.1.0.5 2026-05-29 17:45:27 +02:00
elpatron 593d1aea20 fix: Memory-Leak bei PWA-Update-Checks durch Cleanup beheben.
Entfernt Listener und Intervalle beim erneuten SW-Register und beim Unmount des Hooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:44:31 +02:00
elpatron f01c5dc86f chore: release v0.1.0.4 2026-05-29 17:40:31 +02:00
elpatron 1f089fdaa7 feat: PWA-Updates erkennen und Nutzer zum Reload auffordern.
Wechselt auf prompt-Modus mit Update-Banner, periodischer SW-Prüfung und no-cache-Headern für Service Worker und index.html.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:40:23 +02:00
elpatron b2a28f5782 chore: release v0.1.0.3 2026-05-29 17:36:48 +02:00
elpatron 4d2e309967 fix: Einstellungs-Dropdowns durch ThemedSelect mit lesbarem Kontrast ersetzen.
Native Select-Optionen waren in Light/Dark Mode schlecht lesbar; ein eigenes Dropdown steuert Hintergrund und Textfarbe zuverlässig.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:35:57 +02:00
elpatron 2f6c668ca4 feat: Light Mode mit System-Erkennung und konfigurierbarem Erscheinungsbild.
Stellt hell/dunkel für Ocean, Material und Cupertino bereit, migriert die Kern-UI auf CSS-Variablen und ergänzt die Einstellungen inkl. i18n und Select-Kontrast.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:26:50 +02:00
elpatron 42736fedf3 fix: Schiffs-Maße als Zahlen statt Strings speichern
Länge, Tiefgang und Höhe werden beim Speichern geparst und numerisch persistiert; Legacy-String-Werte beim Laden weiter unterstützt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:10:26 +02:00
elpatron ac84fef832 fix: Auto-Accept-Retry bei fehlendem Logbuch-Schlüssel ermöglichen
autoAcceptStarted wird zurückgesetzt, wenn logbookKey oder logbookId fehlen, damit der Einladungsflow erneut starten kann.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:09:11 +02:00
elpatron 404eb79add feat: Schiffs-Stammdaten erweitern und Ablenkungstabelle ausblenden
Neue Felder für Yachttyp, Länge, Tiefgang und Höhe; Compass Deviation Table ist für Freizeit-Skipper vorerst aus der Navigation entfernt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:08:21 +02:00
elpatron 14b52c684d fix: Einladungs-Auto-Accept, isShared-Cache und Recovery-Validierung
Auto-Accept kann nach Session-Verlust erneut starten, isShared wird offline in Dexie persistiert, und leere Recovery-Benutzernamen werden abgefangen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:00:49 +02:00
elpatron 6f0385ee1b chore: release v0.1.0.2 2026-05-29 16:57:16 +02:00
elpatron 1710007efe fix: Skipper-Signatur für WRITE-Collaborators und Events-Hash
WRITE-Collaborators dürfen Skipper-Freigaben leisten; der Eintrags-Hash sortiert events nach time, damit Umordnungen die Passkey-Signatur invalidieren.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:57:00 +02:00
elpatron 241b2fdf63 fix: Einladungsflow für geteilte Logbücher reparieren
Eingeladene Nutzer konnten nach Registrierung/Login kein Logbuch öffnen, weil der Beitritt nicht abgeschlossen wurde und der Collaboration-Schlüssel falsch importiert wurde.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:54:22 +02:00
elpatron f87f5e382d fix: PDF-Passkey-Datum i18n und Challenge erst nach Verify löschen
Passkey-Signaturen im PDF nutzen die App-Sprache für Datumsformatierung.
Signing-Challenge bleibt bei fehlgeschlagener WebAuthn-Verifikation retry-fähig.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:48:13 +02:00
elpatron 81da01e786 feat: Unterschriften bei Logbuchänderungen invalidieren
Änderungen am Eintrag (außer Fotos) entfernen Skipper- und Crew-Signaturen
automatisch. Vor dem Unterschreiben erscheinen Hinweis-Banner und Bestätigung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:45:02 +02:00
elpatron 878a18e9f7 fix: Passkey-Sign Challenge und Signatur-Moduswechsel
WebAuthn-Challenge wird als Bytes übergeben und unter options.challenge
gespeichert. Passkey/Klassisch-Toggle erlaubt Wechsel zwischen Freigabearten.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:40:01 +02:00
elpatron ce47fe5fdc feat: Hybride Passkey-Freigabe für Skipper und Crew
Skipper (nur Owner) und Crew (WRITE-Collaborators) können Logbuchseiten
optional per WebAuthn freigeben; klassische Unterschrift bleibt als Fallback.
Signatur ist an den Eintrags-Hash gebunden, Export in CSV/PDF angepasst.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:28:52 +02:00
elpatron 5706d1762d fix: Login zentrieren und CSV-Unterschrift-Platzhalter übersetzen
Auth-Screens werden per auth-screen über die volle Viewport-Höhe zentriert.
Bild-Unterschriften im CSV-Export nutzen i18n statt festem deutschen Text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:18:01 +02:00
36 changed files with 3343 additions and 565 deletions
@@ -0,0 +1,479 @@
# Implementierungsvorschlag: Hybride elektronische Signatur (Variante C)
**Status:** Entwurf
**Datum:** 2026-05-29
**Scope:** Skipper-Freigabe per Passkey, Crew-Freigabe per Passkey *oder* klassische Unterschrift (Pad/Text)
---
## 1. Ziel
Die bestehenden Felder `signSkipper` und `signCrew` sollen um eine **identitätsgebundene Passkey-Freigabe** ergänzt werden, ohne die Papier-Tradition vollständig zu ersetzen:
| Rolle | Primär | Fallback |
|-------|--------|----------|
| **Skipper** | Passkey (WebAuthn-Assertion, an Eintrags-Hash gebunden) | SignaturePad / getippter Name (Offline, Gastgerät ohne Passkey) |
| **Crew** | Passkey (nur für eingeladene Collaborators mit WRITE) | SignaturePad / getippter Name (Gäste ohne Konto) |
**Nicht-Ziel (v1):** Qualifizierte elektronische Signatur (QES/eIDAS), serverseitiges Audit-Log (optional Phase 2), Multi-Device-Signatur-Workflow für Crew auf separatem Gerät.
---
## 2. Produktverhalten (UX)
### 2.1 Skipper-Bereich
```
┌─────────────────────────────────────────────┐
│ Skipper-Freigabe │
│ ┌─────────────────────────────────────────┐ │
│ │ ✓ Signiert von max@see 29.05.26 14:32 │ │ ← Passkey-Signatur vorhanden
│ │ [Erneut freigeben] [Klassisch …] │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
**Standard-Flow beim Speichern:**
1. Nutzer füllt Logbuchseite aus und tippt auf „Speichern“.
2. Wenn `signSkipper` leer ist und Netzwerk verfügbar → **Passkey-Dialog** (User Verification).
3. Nach erfolgreicher Assertion wird der Eintrag verschlüsselt gespeichert.
4. Wenn Offline oder Passkey fehlschlägt → Dialog: *„Offline / Passkey nicht verfügbar — klassische Unterschrift verwenden?“* → SignaturePad.
**Regeln:**
- Passkey-Skipper-Signatur ist an den **Eintragsinhalt ohne Signaturfelder** gebunden. Änderungen an Datum, Route, Tankständen etc. **invalidieren** die Signatur (Badge „Signatur ungültig — erneut freigeben“).
- Der Logbuch-**Owner** oder ein Collaborator mit **WRITE** darf Skipper-Freigabe leisten (konfigurierbar: v1 = jeder WRITE-Nutzer auf dem Gerät).
- Bereits signierte Einträge im **readOnly**-Modus: nur Anzeige, kein erneutes Signieren.
### 2.2 Crew-Bereich
```
┌─────────────────────────────────────────────┐
│ Crew-Freigabe │
│ ○ Passkey (empfohlen) ○ Klassisch │ ← Toggle nur wenn Collaborators existieren
│ [Mit Passkey freigeben] │
│ — oder — │
│ [SignaturePad wie bisher] │
└─────────────────────────────────────────────┘
```
**Crew-Passkey:**
- Button „Mit Passkey freigeben“ startet WebAuthn mit `allowCredentials` **nur für Collaborators dieses Logbuchs** (Server liefert Credential-IDs).
- Auf einem gemeinsamen Tablet kann die Crew-Mitperson ihren Passkey wählen, ohne das Kapitäns-Konto zu verlassen.
- Ohne Collaborators: nur SignaturePad (wie heute).
**Crew ohne Konto:** unverändert Pad oder Text — kein Passkey-Zwang.
### 2.3 Zusammenfassung der Flows
```mermaid
sequenceDiagram
participant U as Nutzer
participant E as LogEntryEditor
participant S as entrySigning.ts
participant A as /api/sign/*
participant DB as IndexedDB (E2E)
U->>E: Speichern
E->>E: entryHash = hash(canonicalEntry ohne Signaturen)
alt Skipper Passkey (online)
E->>S: signEntry(logbookId, entryId, hash, role=skipper)
S->>A: POST /sign/options
A-->>S: WebAuthn options
S->>U: Passkey-Dialog
U-->>S: assertion
S->>A: POST /sign/verify
A-->>S: verified + Metadaten
S-->>E: PasskeySignature
else Offline / abgebrochen
E->>U: Fallback SignaturePad
end
E->>DB: encryptJson(entry inkl. signSkipper)
```
---
## 3. Datenmodell
### 3.1 Neue Typen (`client/src/types/signatures.ts`)
```typescript
/** Passkey-Freigabe v1 — rein in E2E-Payload, kein Klartext auf dem Server */
export interface PasskeySignature {
kind: 'passkey'
version: 1
role: 'skipper' | 'crew'
userId: string
username: string
credentialId: string // base64url
signedAt: string // ISO-8601 UTC
entryHash: string // base64url SHA-256
/** Client-seitig gespeichert für Offline-Anzeige; Server verifiziert bei Erstellung */
clientVerified: boolean
}
/** Legacy: string = PNG data URL oder getippter Name */
export type SignatureValue = string | PasskeySignature
export interface LogEntrySignatures {
signSkipper?: SignatureValue
signCrew?: SignatureValue
}
```
### 3.2 Abwärtskompatibilität
Bestehende Einträge speichern `signSkipper`/`signCrew` als `string`. Keine Migration nötig.
Hilfsfunktionen in `client/src/utils/signatures.ts` erweitern:
```typescript
export function isPasskeySignature(v: unknown): v is PasskeySignature
export function normalizeSignature(v: unknown): SignatureValue | undefined
export function formatSignatureForExport(v: SignatureValue | undefined, labels: ExportLabels): string
export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: string): boolean
```
**Export-Texte (i18n):**
| Key | DE | EN |
|-----|----|----|
| `logs.sign_passkey_export` | `Passkey: {{username}} ({{date}})` | `Passkey: {{username}} ({{date}})` |
| `logs.sign_invalid` | Signatur ungültig | Signature invalid |
| `logs.sign_with_passkey` | Mit Passkey freigeben | Sign with Passkey |
| `logs.sign_classic_fallback` | Klassische Unterschrift | Classic signature |
| `logs.sign_offline_hint` | Passkey-Freigabe erfordert Internet | Passkey signing requires internet |
### 3.3 Kanonischer Eintrags-Hash
Neue Datei: `client/src/utils/entryCanonicalHash.ts`
```typescript
const SIGNATURE_KEYS = ['signSkipper', 'signCrew'] as const
/** Stabil sortiertes JSON → SHA-256 → base64url */
export async function hashEntryForSigning(entry: Record<string, unknown>): Promise<string>
```
**In Hash einbeziehen:** alle Felder außer `signSkipper`, `signCrew` und transienten UI-Feldern.
**Reihenfolge:** Keys alphabetisch sortieren, Arrays in definierter Reihenfolge (events nach `time`), Zahlen normalisiert.
Beim Laden eines Eintrags: `computedHash !== sig.entryHash` → UI-Warnung.
---
## 4. Server-API
Neuer Router: `server/src/routes/sign.ts` → Mount unter `/api/sign`
Auth wie bestehend: Header `X-User-Id` (siehe `sync.ts`).
### 4.1 `POST /api/sign/options`
**Request:**
```json
{
"logbookId": "uuid",
"entryId": "uuid",
"entryHash": "base64url-sha256",
"role": "skipper" | "crew"
}
```
**Autorisierung:**
- Nutzer ist Owner **oder** Collaborator mit WRITE auf `logbookId`.
- Für `role: "crew"`: optional prüfen, dass mindestens ein anderer Collaborator existiert (Skipper darf auch Crew signieren in v1).
**Challenge-Konstruktion:**
```typescript
const payload = `${entryId}:${entryHash}:${role}:${randomNonce}`
const challenge = base64url(sha256(payload))
```
In-Memory-Store (analog `activeChallenges` in `auth.ts`):
```typescript
signingChallenges.set(challenge, {
userId, logbookId, entryId, entryHash, role, expiresAt
})
```
**Response:** WebAuthn `PublicKeyCredentialRequestOptions`
- `allowCredentials`: Credentials des **anfragenden** Users (Skipper) **oder** bei `role: crew` alle Credentials der Logbook-Collaborators (Query über `Collaboration` + `Credential`).
- **Kein PRF-Extension** — reine User-Verifikation, nicht Key-Derivation.
- `userVerification: 'required'`
### 4.2 `POST /api/sign/verify`
**Request:**
```json
{
"credentialResponse": { /* WebAuthn */ },
"challenge": "...",
"logbookId": "...",
"entryId": "...",
"entryHash": "...",
"role": "skipper" | "crew"
}
```
**Verifikation:**
1. Challenge aus Store (TTL 5 Min, one-time).
2. `entryHash` und Metadaten müssen mit gespeichertem Kontext übereinstimmen.
3. `verifyAuthenticationResponse` wie in `auth.ts` `/login-verify`.
4. Credential-User muss berechtigt sein (Owner/Collaborator WRITE; bei Crew-Signatur: Credential gehört zu einem Collaborator des Logbuchs).
**Response:**
```json
{
"verified": true,
"userId": "...",
"username": "...",
"credentialId": "...",
"signedAt": "2026-05-29T12:00:00.000Z"
}
```
**Wichtig:** Server speichert in v1 **keinen** Eintragsinhalt und keine Assertion — nur verifiziert und gibt Metadaten zurück. Der Client packt `PasskeySignature` ins E2E-Blob.
### 4.3 Optional Phase 2: Audit-Tabelle
```prisma
model EntrySignatureAudit {
id String @id @default(uuid())
logbookId String
entryId String
userId String
role String // skipper | crew
entryHash String
credentialId String
signedAt DateTime @default(now())
@@index([logbookId, entryId])
}
```
Ermöglicht spätere Prüfung ohne Entschlüsselung des Eintrags. Bewusst **ohne** Klartext-Inhalt.
---
## 5. Client-Implementierung
### 5.1 Neuer Service: `client/src/services/entrySigning.ts`
```typescript
export async function signLogEntry(params: {
logbookId: string
entryId: string
entryHash: string
role: 'skipper' | 'crew'
}): Promise<PasskeySignature>
export async function listCollaboratorCredentialIds(logbookId: string): Promise<string[]>
// Wrapper für /api/sign/options + startAuthentication + /api/sign/verify
// Kein PRF — separates Flow von loginUser()
```
Wiederverwendung: `@simplewebauthn/browser` `startAuthentication`, Muster aus `auth.ts` (Retry ohne PRF entfällt hier).
### 5.2 UI-Komponenten
| Datei | Aufgabe |
|-------|---------|
| `client/src/components/SignatureSection.tsx` | Container für Skipper + Crew, Modus-Toggle |
| `client/src/components/PasskeySignButton.tsx` | Button, Loading, Fehler, Erfolgs-Badge |
| `client/src/components/SignaturePad.tsx` | unverändert für Fallback |
**`LogEntryEditor.tsx` Änderungen:**
- State: `signSkipper: SignatureValue | ''`, `signCrew: SignatureValue | ''`
- Beim Submit: zuerst `entryData` ohne Signaturen hashen, dann Skipper-Passkey anstoßen (wenn gewählt/leer+online).
- `readOnly`: Passkey-Badge + „Signiert von … am …“; Pad disabled.
**`LogEntriesList.tsx`:** Default für neue Einträge `signSkipper: undefined` (nicht leerer String erzwingen).
### 5.3 Speichern-Logik (Pseudocode)
```typescript
async function handleSubmit() {
const entryData = buildEntryPayload({ signSkipper: undefined, signCrew: undefined })
const entryHash = await hashEntryForSigning(entryData)
let skipperSig = signSkipper
if (skipperSignMode === 'passkey' && !isPasskeySignature(skipperSig)) {
skipperSig = await signLogEntry({ logbookId, entryId, entryHash, role: 'skipper' })
}
const finalEntry = { ...entryData, signSkipper: skipperSig, signCrew: signCrew }
await encryptAndSave(finalEntry)
}
```
Crew-Passkey: separater Button (nicht zwingend beim Speichern), damit Crew-Mitglied nach dem Skipper signieren kann.
### 5.4 Export-Anpassungen
**CSV** (`csvExport.ts`):
```typescript
formatSignatureForExport(value, {
imagePlaceholder: t('logs.sign_export_image'),
passkeyTemplate: (sig) => i18n.t('logs.sign_passkey_export', { username: sig.username, date: format(sig.signedAt) })
})
```
**PDF** (`pdfExport.ts`):
- Passkey: zweizeilig — `SIGNIERT / SIGNED` + Username + Datum (kein Bild).
- Bild/Text: bestehende Logik.
---
## 6. Offline-Verhalten
| Situation | Verhalten |
|-----------|-----------|
| Online + Passkey | Standard Skipper-Flow |
| Offline | Passkey deaktiviert; Hinweis + SignaturePad |
| Eintrag offline gespeichert, später online | Kein Auto-Nachsignieren; Nutzer tippt „Mit Passkey freigeben“ |
| Passkey-Signatur vorhanden, Inhalt geändert | Signatur als ungültig markieren, erneute Freigabe nötig |
Kein Offline-Queue für WebAuthn in v1 — zu komplex (Challenge-Ablauf, Counter-Sync).
---
## 7. Sicherheit
| Risiko | Mitigation |
|--------|------------|
| Signatur ohne Inhaltsbindung | `entryHash` in Challenge + im `PasskeySignature`-Objekt |
| Fremder signiert Skipper-Feld | Server prüft WRITE auf Logbook + Credential-Zugehörigkeit |
| Replay der Assertion | Challenge one-time, 5 Min TTL |
| Manipulation nach Signatur | Client prüft Hash bei Anzeige; Export zeigt „invalid“ |
| E2E vs. Audit | v1 nur E2E-Metadaten; Audit optional Phase 2 |
| Login-Session vs. Signatur | Separater Endpoint, `userVerification: required`, kein PRF |
**Hinweis:** Gespeicherte `PasskeySignature` im E2E-Blob ist **selbst nicht kryptografisch signiert** durch den Server (Server sieht Payload nicht). Vertrauen basiert auf: (a) erfolgreiche Server-Verifikation zum Zeitpunkt der Erstellung, (b) Hash-Bindung, (c) optional Audit-Log in Phase 2. Für stärkere Non-Repudiation: Assertion-Response oder Server-Signatur über Hash in Audit speichern.
---
## 8. Implementierungsphasen
### Phase 1 — Fundament (MVP)
**Ziel:** Skipper Passkey + Crew Pad, Hash, Export, Fallback.
| # | Task | Dateien |
|---|------|---------|
| 1.1 | Typen + Signature-Utils | `types/signatures.ts`, `utils/signatures.ts`, `utils/entryCanonicalHash.ts` |
| 1.2 | Server `/api/sign/*` | `server/src/routes/sign.ts`, `server/src/index.ts` (mount) |
| 1.3 | Client `entrySigning.ts` | `client/src/services/entrySigning.ts` |
| 1.4 | UI Skipper Passkey + Pad-Fallback | `PasskeySignButton.tsx`, `SignatureSection.tsx`, `LogEntryEditor.tsx` |
| 1.5 | Crew nur Pad (unverändert) | `SignatureSection.tsx` |
| 1.6 | Export CSV/PDF | `csvExport.ts`, `pdfExport.ts` |
| 1.7 | i18n DE/EN | `locales/de.json`, `locales/en.json` |
**Akzeptanzkriterien:**
1. Skipper kann Eintrag online per Passkey freigeben; PDF/CSV zeigen Username + Datum.
2. Offline → Pad-Fallback funktioniert.
3. Alte Einträge (String-Signaturen) laden und exportieren unverändert.
4. Geänderte Felder nach Passkey-Signatur → Warnung „Signatur ungültig“.
### Phase 2 — Crew Passkey
| # | Task | Dateien |
|---|------|---------|
| 2.1 | Collaborator-Credentials in `/sign/options` | `sign.ts`, ggf. `collaboration.ts` |
| 2.2 | Crew-Toggle Passkey vs. Pad | `SignatureSection.tsx` |
| 2.3 | Collaborator-Liste für UI | `SettingsForm` / neuer Hook `useLogbookCollaborators` |
**Akzeptanzkriterien:**
1. Eingeladener WRITE-Collaborator kann Crew-Feld per eigenem Passkey signieren.
2. Gäste ohne Konto nutzen weiterhin Pad.
### Phase 3 — Härtung (optional)
- Prisma `EntrySignatureAudit`
- „Signatur prüfen“-Button (Re-Verify gegen Server, wenn online)
- Einstellung im Logbook: „Skipper-Freigabe nur Passkey“ (Pad-Fallback deaktivieren)
- Tests: Unit-Tests für `hashEntryForSigning`, Integrationstest `/sign/verify`
---
## 9. Testplan
### Manuell
- [ ] DE/EN: Export-Texte für Passkey, Pad, leer
- [ ] Skipper Passkey → Speichern → Reload → Badge sichtbar
- [ ] Eintrag ändern → ungültige Signatur
- [ ] Offline speichern mit Pad
- [ ] Legacy-Eintrag mit PNG-Signatur lädt korrekt
- [ ] Crew-Collaborator Passkey auf zweitem Account
- [ ] READ-only Collaborator darf nicht signieren (403)
### Automatisiert (empfohlen)
```typescript
// entryCanonicalHash.test.ts
test('stable hash ignores signature fields')
test('different tank values produce different hash')
// signatures.test.ts
test('formatSignatureForExport passkey vs image vs text')
test('isSignatureValidForEntry')
```
---
## 10. Aufwandsschätzung
| Phase | Aufwand |
|-------|---------|
| Phase 1 (MVP) | ~23 Tage |
| Phase 2 (Crew Passkey) | ~1 Tag |
| Phase 3 (Audit + Tests) | ~12 Tage |
---
## 11. Offene Entscheidungen (vor Implementierung klären)
1. **Skipper-Pflicht:** Muss jeder Eintrag Passkey-signiert sein, oder optional wie heute?
- *Empfehlung v1:* Optional; Passkey wird beim Speichern **angeboten**, Pad bei Offline.
2. **Wer darf Skipper signieren?** Nur Owner oder jeder WRITE-Nutzer?
- *Empfehlung v1:* Jeder WRITE-Nutzer (typisch: Kapitän auf eigenem Gerät).
3. **Pad-Fallback dauerhaft erlauben?**
- *Empfehlung:* Ja (Variante C); später Logbook-Setting zum Erzwingen von Passkey.
4. **Crew-Passkey beim Speichern oder separater Schritt?**
- *Empfehlung:* Separater Button — Crew signiert oft nach dem Skipper.
---
## 12. Referenzen im Code
| Bereich | Pfad |
|---------|------|
| Aktuelle Signaturen | `client/src/components/LogEntryEditor.tsx` (ca. Z. 611612, 13121336) |
| Signature-Utils | `client/src/utils/signatures.ts` |
| 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` |
---
*Entwurf für Variante C — Hybrid elektronische Signatur im Kapteins Daagbok.*
+1 -1
View File
@@ -1 +1 @@
0.1.0.2
0.1.0.6
+11
View File
@@ -3,6 +3,17 @@ server {
server_name localhost;
client_max_body_size 50M;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate";
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
+514 -335
View File
File diff suppressed because it is too large Load Diff
+29 -28
View File
@@ -5,18 +5,26 @@ import AuthOnboarding from './components/AuthOnboarding.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx'
import VesselForm from './components/VesselForm.tsx'
import CrewForm from './components/CrewForm.tsx'
import DeviationForm from './components/DeviationForm.tsx'
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
// import DeviationForm from './components/DeviationForm.tsx'
import LogEntriesList from './components/LogEntriesList.tsx'
import SettingsForm from './components/SettingsForm.tsx'
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import { getActiveMasterKey, logoutUser } from './services/auth.js'
import {
applyAppearanceToDocument,
resolveAppTheme,
resolveColorScheme,
subscribeToSystemColorScheme
} from './services/appearance.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx'
import { db } from './services/db.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
function App() {
@@ -24,10 +32,9 @@ function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'deviation' | 'logs' | 'settings'>('logs')
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'logs' | 'settings'>('logs')
const [online, setOnline] = useState(navigator.onLine)
const [isSyncing, setIsSyncing] = useState(false)
const [appliedTheme, setAppliedTheme] = useState<'ocean' | 'material' | 'cupertino'>('ocean')
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
// Viewer mode for read-only shared links
@@ -40,27 +47,16 @@ function App() {
[activeLogbookId]
)
const updateAppliedTheme = () => {
const configTheme = localStorage.getItem('active_theme') || 'auto'
if (configTheme === 'auto') {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera
if (/iPad|iPhone|iPod|Macintosh/.test(userAgent)) {
setAppliedTheme('cupertino')
} else if (/Android|Linux/.test(userAgent)) {
setAppliedTheme('material')
} else {
setAppliedTheme('ocean')
}
} else {
setAppliedTheme(configTheme as 'ocean' | 'material' | 'cupertino')
}
}
useEffect(() => {
updateAppliedTheme()
window.addEventListener('theme-changed', updateAppliedTheme)
const syncAppearance = () => {
applyAppearanceToDocument(resolveAppTheme(), resolveColorScheme())
}
syncAppearance()
window.addEventListener('appearance-changed', syncAppearance)
const unsubscribeSystem = subscribeToSystemColorScheme(syncAppearance)
return () => {
window.removeEventListener('theme-changed', updateAppliedTheme)
window.removeEventListener('appearance-changed', syncAppearance)
unsubscribeSystem()
}
}, [])
@@ -158,7 +154,7 @@ function App() {
if (isViewerMode) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div style={{ display: 'contents' }}>
<ReadOnlyViewer token={shareToken} hexKey={shareKey} />
</div>
)
@@ -166,7 +162,7 @@ function App() {
if (isAcceptingInvite) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div className="auth-screen">
<InvitationAcceptance
onAccepted={(logbookId, title) => {
setIsAuthenticated(true)
@@ -186,7 +182,7 @@ function App() {
if (!isAuthenticated) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} />
</div>
)
@@ -196,7 +192,7 @@ function App() {
if (!activeLogbookId) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
<LogbookDashboard
onSelectLogbook={handleSelectLogbook}
@@ -207,7 +203,7 @@ function App() {
}
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
{isSyncing && <div className="sync-progress-bar" />}
<div className="app-layout">
@@ -271,6 +267,7 @@ function App() {
{t('nav.crew')}
</button>
{/* Compass Deviation Table — für Freizeit-Skipper vorerst ausgeblendet
<button
className={`sidebar-btn ${activeTab === 'deviation' ? 'active' : ''}`}
onClick={() => setActiveTab('deviation')}
@@ -278,6 +275,7 @@ function App() {
<Compass size={18} />
{t('nav.deviation')}
</button>
*/}
<button
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
@@ -302,9 +300,11 @@ function App() {
<CrewForm logbookId={activeLogbookId} />
)}
{/* Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert
{activeTab === 'deviation' && (
<DeviationForm logbookId={activeLogbookId} />
)}
*/}
{activeTab === 'settings' && (
<SettingsForm logbookId={activeLogbookId} />
@@ -319,6 +319,7 @@ function App() {
export default function AppWrapper() {
return (
<DialogProvider>
<PwaUpdatePrompt />
<App />
<AppFooter />
</DialogProvider>
+217 -102
View File
@@ -1,18 +1,23 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight } from 'lucide-react'
import { getActiveMasterKey, registerUser, loginUser } from '../services/auth.js'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
import {
getActiveMasterKey,
registerUser,
loginUser,
completeLoginWithRecovery,
getKnownUsernames
} from '../services/auth.js'
import { decryptJson, encryptBuffer } from '../services/crypto.js'
import { saveLogbookKey } from '../services/logbookKeys.js'
import { syncLogbook } from '../services/sync.js'
import { db } from '../services/db.js'
import { useDialog } from './ModalDialog.tsx'
interface InvitationAcceptanceProps {
onAccepted: (logbookId: string, title: string) => void
onCancel: () => void
}
// Convert Hex String back to ArrayBuffer
const hexToBuffer = (hex: string): ArrayBuffer => {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) {
@@ -22,65 +27,73 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
}
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
const { i18n } = useTranslation()
const { showAlert } = useDialog()
const { t, i18n } = useTranslation()
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Link parameters
const [token, setToken] = useState('')
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
// Details loaded from server
const [ownerUsername, setOwnerUsername] = useState('')
const [decryptedTitle, setDecryptedTitle] = useState('')
const [logbookId, setLogbookId] = useState('')
const [rawEncryptedTitle, setRawEncryptedTitle] = useState('')
// Authentication states
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [username, setUsername] = useState('')
const [loginMode, setLoginMode] = useState<'options' | 'login' | 'register'>('options')
const [loginMode, setLoginMode] = useState<'options' | 'register'>('options')
const [regUsername, setRegUsername] = useState('')
const [authError, setAuthError] = useState<string | null>(null)
// Check login state on mount
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
const [showRecoveryFallback, setShowRecoveryFallback] = useState(false)
const [recoveryInput, setRecoveryInput] = useState('')
const [encryptedPayloads, setEncryptedPayloads] = useState<any>(null)
const autoAcceptStarted = useRef(false)
const isDe = i18n.language.startsWith('de')
const sessionReady = (): boolean => {
return !!(getActiveMasterKey() && localStorage.getItem('active_userid'))
}
useEffect(() => {
const key = getActiveMasterKey()
const savedUser = localStorage.getItem('active_username')
if (key && savedUser) {
const savedUserId = localStorage.getItem('active_userid')
if (key && savedUser && savedUserId) {
setIsLoggedIn(true)
setUsername(savedUser)
}
// Extract parameters from URL
const params = new URLSearchParams(window.location.search)
const tokenVal = params.get('token') || ''
setToken(tokenVal)
// Hash anchor (#key=xxx)
const hash = window.location.hash
if (hash.startsWith('#key=')) {
const hexKey = hash.substring(5)
try {
const keyBuffer = hexToBuffer(hexKey)
setLogbookKey(keyBuffer)
setLogbookKey(hexToBuffer(hexKey))
} catch (err) {
console.error('Invalid key in URL fragment:', err)
setError('The invitation link is cryptographically invalid or corrupted (missing key).')
setError(isDe
? 'Der Einladungslink ist kryptografisch ungültig (Schlüssel fehlerhaft).'
: 'The invitation link is cryptographically invalid (corrupted key).')
}
} else {
setError('The invitation link is missing the necessary decryption key fragment (#key=...).')
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.')
}
// Suggest a random guest skipper username
const rand = Math.floor(1000 + Math.random() * 9000)
setRegUsername(`CrewSkipper_${rand}`)
}, [])
}, [isDe])
// Load invitation details once parameters are ready
useEffect(() => {
if (token && logbookKey) {
loadDetails()
@@ -92,44 +105,54 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setError(null)
try {
const res = await fetch(`/api/collaboration/invite-details?token=${token}`)
if (res.status === 410) {
setError('This invitation link has expired (valid for 48 hours only).')
setError(isDe
? 'Diese Einladung ist abgelaufen (48 Stunden gültig).'
: 'This invitation link has expired (valid for 48 hours only).')
return
}
if (!res.ok) {
throw new Error('Failed to verify invitation token.')
throw new Error(isDe ? 'Einladungstoken ungültig.' : 'Failed to verify invitation token.')
}
const details = await res.json()
setOwnerUsername(details.ownerUsername)
setLogbookId(details.logbookId)
setRawEncryptedTitle(details.encryptedTitle)
// Decrypt title client-side using URL key
const parsed = JSON.parse(details.encryptedTitle)
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey!)
setDecryptedTitle(title)
} catch (err: any) {
console.error('Failed to load invitation details:', err)
setError(err.message || 'Invitation details could not be retrieved from the server.')
setError(err.message || (isDe ? 'Einladungsdetails konnten nicht geladen werden.' : 'Invitation details could not be retrieved.'))
} finally {
setLoading(false)
}
}
const handleAccept = async () => {
const handleAccept = useCallback(async () => {
const masterKey = getActiveMasterKey()
const activeUserId = localStorage.getItem('active_userid')
if (!masterKey || !activeUserId || !logbookKey || !logbookId) return
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).')
setIsLoggedIn(false)
return
}
if (!logbookKey || !logbookId) {
autoAcceptStarted.current = false
return
}
setAccepting(true)
setError(null)
try {
// 1. Encrypt logbook key with user's master key
const aesMasterKey = await window.crypto.subtle.importKey(
'raw',
masterKey,
@@ -139,7 +162,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
)
const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
// 2. Register collaboration on server
const res = await fetch('/api/collaboration/accept', {
method: 'POST',
headers: {
@@ -155,49 +177,98 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
})
if (!res.ok) {
const serverError = await res.json()
throw new Error(serverError.error || 'Failed to join logbook on the server.')
const serverError = await res.json().catch(() => ({}))
throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.'))
}
// 3. Save key locally in Dexie
await saveLogbookKey(logbookId, logbookKey)
// 3b. Save logbook index locally in Dexie so sync is triggered immediately
if (rawEncryptedTitle) {
await db.logbooks.put({
id: logbookId,
encryptedTitle: rawEncryptedTitle,
updatedAt: new Date().toISOString(),
isSynced: 1
isSynced: 1,
isShared: 1
})
}
// 4. Redirect to workspace
await syncLogbook(logbookId)
onAccepted(logbookId, decryptedTitle)
} catch (err: any) {
console.error('Accepting invitation failed:', err)
setError(err.message || 'Acceptance failed.')
setError(err.message || (isDe ? 'Beitritt fehlgeschlagen.' : 'Acceptance failed.'))
autoAcceptStarted.current = false
} finally {
setAccepting(false)
}
}
}, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted, isDe])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
useEffect(() => {
if (loading || accepting || autoAcceptStarted.current) return
if (!isLoggedIn || !logbookId || !logbookKey || !token) return
if (!sessionReady()) {
autoAcceptStarted.current = false
return
}
autoAcceptStarted.current = true
void handleAccept()
}, [isLoggedIn, logbookId, logbookKey, token, loading, accepting, handleAccept])
const handleLogin = async () => {
setAuthError(null)
setLoading(true)
try {
const result = await loginUser()
if (result.verified && result.prfSuccess) {
const remembered = getKnownUsernames()
const target = remembered.length === 1 ? remembered[0] : undefined
const result = await loginUser(target)
if (!result.verified) return
if (result.prfSuccess) {
setIsLoggedIn(true)
setUsername(result.username || 'Skipper')
} else if (result.verified) {
// Biometrics succeeded but fallback phrase is needed
setAuthError('Device doesn\'t support PRF key derivation. Traditional login is not supported in the invitation screen. Please log in normally on the main page first.')
return
}
setEncryptedPayloads(result.encryptedPayloads)
const resolvedUser = result.username || result.encryptedPayloads?.username || ''
if (resolvedUser) setUsername(resolvedUser)
setShowRecoveryFallback(true)
} catch (err: any) {
setAuthError(err.message || (isDe ? 'Passkey-Anmeldung fehlgeschlagen.' : 'Passkey authentication failed.'))
} finally {
setLoading(false)
}
}
const handleRecoverySubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!recoveryInput.trim() || !encryptedPayloads) return
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.')
return
}
setLoading(true)
setAuthError(null)
try {
const success = await completeLoginWithRecovery(resolvedUser, recoveryInput.trim(), encryptedPayloads)
if (success) {
setShowRecoveryFallback(false)
setIsLoggedIn(true)
setUsername(resolvedUser)
} else {
setAuthError(t('auth.error_incorrect_recovery'))
}
} catch (err: any) {
setAuthError(err.message || 'Passkey authentication failed.')
setAuthError(err.message || t('auth.error_decryption_failed'))
} finally {
setLoading(false)
}
@@ -213,31 +284,92 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
try {
const result = await registerUser(regUsername.trim())
if (result.verified) {
setIsLoggedIn(true)
setUsername(regUsername.trim())
showAlert(`Account created successfully! Your 12-word recovery phrase is: ${result.recoveryPhrase}. Write it down securely!`)
setRecoveryPhrase(result.recoveryPhrase)
}
} catch (err: any) {
setAuthError(err.message || 'Registration failed.')
setAuthError(err.message || (isDe ? 'Registrierung fehlgeschlagen.' : 'Registration failed.'))
} finally {
setLoading(false)
}
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
const handleConfirmRecovery = () => {
setRecoveryPhrase(null)
setIsLoggedIn(true)
}
if (loading && !accepting) {
const toggleLanguage = () => {
i18n.changeLanguage(i18n.language.startsWith('de') ? 'en' : 'de')
}
if (recoveryPhrase) {
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.recovery_title')}</h2>
</div>
<p className="recovery-warning">{t('auth.recovery_warning')}</p>
<div className="recovery-phrase-grid">
{recoveryPhrase.split(' ').map((word, idx) => (
<div key={idx} className="recovery-word">
<span className="word-index">{idx + 1}</span>
{word}
</div>
))}
</div>
<div className="auth-actions mt-6">
<button className="btn primary" onClick={handleConfirmRecovery} style={{ width: '100%' }}>
{t('auth.confirm_recovery')}
</button>
</div>
</div>
)
}
if (showRecoveryFallback) {
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.enter_recovery')}</h2>
</div>
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
<form onSubmit={handleRecoverySubmit}>
<textarea
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
rows={3}
required
/>
<div className="auth-actions mt-4">
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
{isDe ? 'Zurück' : '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>}
</div>
)
}
if ((loading || accepting) && !error) {
return (
<div className="auth-card glass">
<div className="auth-header">
<Ship className="auth-icon accent spin" size={48} />
<h2>{i18n.language.startsWith('de') ? 'Einladung wird geprüft...' : 'Checking Invitation...'}</h2>
<h2>{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Einladung wird geprüft...' : 'Checking Invitation...')}</h2>
</div>
<p className="recovery-warning">
{i18n.language.startsWith('de') ? 'Lade Verschlüsselungsschlüssel und Verifizierungstoken...' : 'Retrieving credentials and secure key components...'}
{accepting
? (isDe ? 'Logbuch wird freigeschaltet und synchronisiert...' : 'Unlocking logbook and syncing data...')
: (isDe ? 'Lade Verschlüsselungsschlüssel...' : 'Retrieving encryption key...')}
</p>
</div>
)
@@ -248,13 +380,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>{i18n.language.startsWith('de') ? 'Einladungsfehler' : 'Invitation Error'}</h2>
<h2>{isDe ? 'Einladungsfehler' : 'Invitation Error'}</h2>
</div>
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p>
<div className="auth-actions mt-6">
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
{i18n.language.startsWith('de') ? 'Zurück zum Start' : 'Back to Dashboard'}
{isDe ? 'Zurück zum Start' : 'Back to Dashboard'}
</button>
</div>
</div>
@@ -265,18 +396,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>{i18n.language.startsWith('de') ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2>
<h2>{isDe ? 'Logbuch-Einladung' : 'Logbook Invitation'}</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' }}>
{i18n.language.startsWith('de') ? 'Einladung von' : 'INVITED BY'}
{isDe ? 'Einladung von' : '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' }}>
{i18n.language.startsWith('de') ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
{isDe ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
</p>
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
{decryptedTitle}
@@ -284,53 +415,43 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
</div>
{isLoggedIn ? (
/* If logged in: Accept and Join immediately */
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{i18n.language.startsWith('de')
? `Sie sind angemeldet als ${username}. Möchten Sie diesem Logbuch als Crewmitglied beitreten?`
: `You are logged in as ${username}. Would you like to join this logbook with write permissions?`
}
{isDe
? `Angemeldet als ${username}. Beitritt wird vorbereitet...`
: `Signed in as ${username}. Preparing to join...`}
</p>
<div className="auth-actions mt-4" style={{ display: 'flex', gap: '12px' }}>
<button className="btn secondary" onClick={onCancel} disabled={accepting} style={{ flex: 1 }}>
{i18n.language.startsWith('de') ? 'Abbrechen' : 'Cancel'}
</button>
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ flex: 2 }}>
{accepting ? (i18n.language.startsWith('de') ? 'Beitritt...' : 'Joining...') : (i18n.language.startsWith('de') ? 'Beitreten' : 'Accept & Join')}
<ArrowRight size={16} />
</button>
</div>
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ width: '100%' }}>
{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Erneut beitreten' : 'Join again')}
<ArrowRight size={16} />
</button>
</div>
) : (
/* If not logged in: Ask to authenticate or register */
<div style={{ width: '100%' }}>
{loginMode === 'options' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{i18n.language.startsWith('de')
? 'Sie müssen ein Passkey-Konto besitzen oder erstellen, um E2E-verschlüsselte Einträge zu schreiben.'
: 'You must authenticate or register an E2E-secured crew account to write entries.'
}
{isDe
? 'Melden Sie sich an oder registrieren Sie ein Konto, um dem Logbuch beizutreten.'
: 'Sign in or register an account to join this logbook.'}
</p>
<button className="btn primary" onClick={handleLogin} style={{ width: '100%', padding: '14px' }}>
<button className="btn primary" onClick={handleLogin} disabled={loading} style={{ width: '100%', padding: '14px' }}>
<LogIn size={16} />
{i18n.language.startsWith('de') ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
{isDe ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
</button>
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
{i18n.language.startsWith('de') ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
{isDe ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
</span>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div>
<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} />
{i18n.language.startsWith('de') ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
{isDe ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
</button>
</div>
)}
@@ -339,41 +460,35 @@ 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' }}>
{i18n.language.startsWith('de') ? 'Skipper- / Benutzername' : 'Skipper / User Name'}
{isDe ? 'Benutzername' : 'Username'}
</label>
<input
type="text"
className="input-text"
placeholder="e.g. Max Mustermann"
value={regUsername}
onChange={(e) => setRegUsername(e.target.value)}
required
/>
</div>
<div className="auth-actions">
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
{i18n.language.startsWith('de') ? 'Zurück' : 'Back'}
{isDe ? 'Zurück' : 'Back'}
</button>
<button type="submit" className="btn primary" disabled={!regUsername.trim()}>
{i18n.language.startsWith('de') ? 'Passkey erstellen & beitreten' : 'Create Passkey & Join'}
<button type="submit" className="btn primary" disabled={!regUsername.trim() || loading}>
{isDe ? 'Passkey erstellen' : 'Create Passkey'}
</button>
</div>
</form>
)}
{authError && (
<div className="auth-error mt-4" style={{ fontSize: '13px' }}>
{authError}
</div>
)}
{authError && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authError}</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} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
{isDe ? 'English' : 'Deutsch'}
</button>
</div>
</div>
+189 -58
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
@@ -8,10 +8,21 @@ 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 PhotoCapture from './PhotoCapture.tsx'
import SignaturePad from './SignaturePad.tsx'
import SignatureSection from './SignatureSection.tsx'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import { isSignatureImage } from '../utils/signatures.js'
import {
normalizeSignature,
serializeSignature,
isPasskeySignature,
isSignatureValidForEntry,
hasAnySignature
} from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
import {
getDecryptedTrack,
saveUploadedTrack,
@@ -63,7 +74,7 @@ export default function LogEntryEditor({
preloadedYacht
}: LogEntryEditorProps) {
const { t, i18n } = useTranslation()
const { showAlert } = useDialog()
const { showAlert, showConfirm } = useDialog()
// General details state
const [date, setDate] = useState('')
@@ -85,8 +96,12 @@ export default function LogEntryEditor({
const [fuelConsumption, setFuelConsumption] = useState('0')
// Signatures
const [signSkipper, setSignSkipper] = useState('')
const [signCrew, setSignCrew] = useState('')
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
const [canSignSkipper, setCanSignSkipper] = useState(false)
const [hasWriteCollaborators, setHasWriteCollaborators] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [entryHash, setEntryHash] = useState('')
// GPS track stats (from uploaded track)
const [trackDistanceNm, setTrackDistanceNm] = useState('')
@@ -127,6 +142,8 @@ export default function LogEntryEditor({
const [dragOver, setDragOver] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(waypoints)
@@ -149,6 +166,142 @@ export default function LogEntryEditor({
}
}
const buildPayloadForSigning = useCallback(() => {
return buildLogEntryPayload({
date,
dayOfTravel,
departure,
destination,
freshwater: {
morning: parseFloat(fwMorning) || 0,
refilled: parseFloat(fwRefilled) || 0,
evening: parseFloat(fwEvening) || 0,
consumption: parseFloat(fwConsumption) || 0
},
fuel: {
morning: parseFloat(fuelMorning) || 0,
refilled: parseFloat(fuelRefilled) || 0,
evening: parseFloat(fuelEvening) || 0,
consumption: parseFloat(fuelConsumption) || 0
},
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events
})
}, [
date, dayOfTravel, departure, destination,
fwMorning, fwRefilled, fwEvening, fwConsumption,
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn,
events
])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
useEffect(() => {
getLogbookAccess(logbookId).then((access) => {
if (!access) return
setCanSignSkipper(access.isOwner || access.role === 'WRITE')
setHasWriteCollaborators(access.writeCollaboratorCount > 0)
})
}, [logbookId])
useEffect(() => {
let cancelled = false
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
if (!cancelled) setEntryHash(hash)
})
return () => { cancelled = true }
}, [buildPayloadForSigning])
useEffect(() => {
contentReadyRef.current = false
if (loading) return
const timer = window.setTimeout(() => {
contentReadyRef.current = true
}, 0)
return () => window.clearTimeout(timer)
}, [loading])
useEffect(() => {
if (!entryHash || !contentReadyRef.current || readOnly) return
const hasSig = hasAnySignature(signSkipper, signCrew)
if (!hasSig) {
lockedContentHashRef.current = null
return
}
if (!lockedContentHashRef.current) {
lockedContentHashRef.current = entryHash
return
}
if (entryHash !== lockedContentHashRef.current) {
lockedContentHashRef.current = null
setSignSkipper('')
setSignCrew('')
void showAlert(
t('logs.sign_cleared_re_sign'),
t('logs.sign_cleared_re_sign_title')
)
}
}, [entryHash, signSkipper, signCrew, readOnly, showAlert, t])
const confirmSignWarning = useCallback(async (): Promise<boolean> => {
return showConfirm(
t('logs.sign_lock_warning'),
t('logs.sign_lock_warning_title'),
t('logs.sign_proceed'),
t('logs.sign_cancel')
)
}, [showConfirm, t])
const skipperSignatureValid = !isPasskeySignature(signSkipper) || isSignatureValidForEntry(signSkipper, entryHash)
const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash)
const handlePasskeySignSkipper = async () => {
const confirmed = await confirmSignWarning()
if (!confirmed) return
const hash = await hashEntryForSigning(buildPayloadForSigning())
const signature = await signLogEntry({
logbookId,
entryId,
entryHash: hash,
role: 'skipper'
})
setSignSkipper(signature)
setEntryHash(hash)
lockedContentHashRef.current = hash
}
const handlePasskeySignCrew = async () => {
const confirmed = await confirmSignWarning()
if (!confirmed) return
const hash = await hashEntryForSigning(buildPayloadForSigning())
const signature = await signLogEntry({
logbookId,
entryId,
entryHash: hash,
role: 'crew'
})
setSignCrew(signature)
setEntryHash(hash)
lockedContentHashRef.current = hash
}
// Auto-calculate Freshwater Consumption
useEffect(() => {
const morning = parseFloat(fwMorning) || 0
@@ -197,6 +350,8 @@ export default function LogEntryEditor({
async function loadEntry() {
setLoading(true)
setError(null)
lockedContentHashRef.current = null
contentReadyRef.current = false
try {
if (readOnly && preloadedEntry) {
setDate(preloadedEntry.date || '')
@@ -208,15 +363,17 @@ export default function LogEntryEditor({
setFwMorning(String(preloadedEntry.freshwater.morning || 0))
setFwRefilled(String(preloadedEntry.freshwater.refilled || 0))
setFwEvening(String(preloadedEntry.freshwater.evening || 0))
setFwConsumption(String(preloadedEntry.freshwater.consumption ?? 0))
}
if (preloadedEntry.fuel) {
setFuelMorning(String(preloadedEntry.fuel.morning || 0))
setFuelRefilled(String(preloadedEntry.fuel.refilled || 0))
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
}
setSignSkipper(preloadedEntry.signSkipper || '')
setSignCrew(preloadedEntry.signCrew || '')
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || [])
return
@@ -238,15 +395,17 @@ export default function LogEntryEditor({
setFwMorning(String(decrypted.freshwater.morning || 0))
setFwRefilled(String(decrypted.freshwater.refilled || 0))
setFwEvening(String(decrypted.freshwater.evening || 0))
setFwConsumption(String(decrypted.freshwater.consumption ?? 0))
}
if (decrypted.fuel) {
setFuelMorning(String(decrypted.fuel.morning || 0))
setFuelRefilled(String(decrypted.fuel.refilled || 0))
setFuelEvening(String(decrypted.fuel.evening || 0))
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
}
setSignSkipper(decrypted.signSkipper || '')
setSignCrew(decrypted.signCrew || '')
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || [])
}
@@ -591,29 +750,11 @@ export default function LogEntryEditor({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryPayload = buildPayloadForSigning()
const entryData = {
date,
dayOfTravel: dayOfTravel.trim(),
departure: departure.trim(),
destination: destination.trim(),
freshwater: {
morning: parseFloat(fwMorning) || 0,
refilled: parseFloat(fwRefilled) || 0,
evening: parseFloat(fwEvening) || 0,
consumption: parseFloat(fwConsumption) || 0
},
fuel: {
morning: parseFloat(fuelMorning) || 0,
refilled: parseFloat(fuelRefilled) || 0,
evening: parseFloat(fuelEvening) || 0,
consumption: parseFloat(fuelConsumption) || 0
},
signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(),
signCrew: isSignatureImage(signCrew) ? signCrew : signCrew.trim(),
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events
...entryPayload,
signSkipper: serializeSignature(signSkipper),
signCrew: serializeSignature(signCrew)
}
// E2E encrypt
@@ -1309,32 +1450,22 @@ export default function LogEntryEditor({
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
{/* Section 4: Sign-Off Signatures */}
<div className="form-card">
<div className="form-header">
<Check size={20} className="form-icon" />
<h3>{t('logs.signatures')}</h3>
</div>
<div className="form-grid signature-grid">
<SignaturePad
id="sign-skipper"
label={t('logs.sign_skipper')}
value={signSkipper}
onChange={setSignSkipper}
disabled={saving}
readOnly={readOnly}
/>
<SignaturePad
id="sign-crew"
label={t('logs.sign_crew')}
value={signCrew}
onChange={setSignCrew}
disabled={saving}
readOnly={readOnly}
/>
</div>
</div>
<SignatureSection
readOnly={readOnly}
disabled={saving}
isOnline={isOnline}
canSignSkipper={canSignSkipper}
hasWriteCollaborators={hasWriteCollaborators}
signSkipper={signSkipper}
signCrew={signCrew}
skipperSignatureValid={skipperSignatureValid}
crewSignatureValid={crewSignatureValid}
onSignSkipperChange={setSignSkipper}
onSignCrewChange={setSignCrew}
onPasskeySignSkipper={handlePasskeySignSkipper}
onPasskeySignCrew={handlePasskeySignCrew}
onBeforeSign={confirmSignWarning}
/>
{/* Save Controls */}
{!readOnly && (
+6 -1
View File
@@ -219,7 +219,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div>
</div>
<button className="btn-delete" onClick={(e) => handleDelete(lb.id, e)} title="Delete Logbook">
<button
className="btn-delete"
onClick={(e) => handleDelete(lb.id, e)}
title={t('dashboard.delete_btn')}
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
>
<Trash2 size={18} />
</button>
</div>
@@ -0,0 +1,90 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Fingerprint, Loader2, AlertTriangle } from 'lucide-react'
import type { PasskeySignature } from '../types/signatures.js'
interface PasskeySignButtonProps {
label: string
signature?: PasskeySignature
signatureValid?: boolean
disabled?: boolean
canSign: boolean
onSign: () => Promise<void>
onClear?: () => void
}
export default function PasskeySignButton({
label,
signature,
signatureValid = true,
disabled = false,
canSign,
onSign,
onClear
}: PasskeySignButtonProps) {
const { t, i18n } = useTranslation()
const [signing, setSigning] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSign = async () => {
setSigning(true)
setError(null)
try {
await onSign()
} catch (err: any) {
if (err?.name === 'NotAllowedError') {
setError(t('logs.sign_passkey_cancelled'))
} else {
setError(err?.message || t('logs.sign_passkey_failed'))
}
} finally {
setSigning(false)
}
}
const formattedDate = signature
? new Date(signature.signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
: ''
return (
<div className="passkey-sign-block">
<div className="passkey-sign-label">{label}</div>
{signature ? (
<div className={`passkey-sign-badge ${signatureValid ? 'valid' : 'invalid'}`}>
<Fingerprint size={16} />
<div className="passkey-sign-badge-text">
<span>{t('logs.sign_passkey_signed', { username: signature.username })}</span>
<span className="passkey-sign-date">{formattedDate}</span>
</div>
{!signatureValid && (
<span className="passkey-sign-invalid-hint">
<AlertTriangle size={14} />
{t('logs.sign_invalid')}
</span>
)}
</div>
) : null}
{canSign && !disabled && (
<button
type="button"
className="btn secondary passkey-sign-btn"
onClick={handleSign}
disabled={signing}
>
{signing ? <Loader2 size={16} className="spin" /> : <Fingerprint size={16} />}
{signing ? t('logs.sign_passkey_signing') : t('logs.sign_with_passkey')}
</button>
)}
{signature && onClear && !disabled && (
<button type="button" className="btn text-btn passkey-sign-clear" onClick={onClear}>
{t('logs.sign_passkey_clear')}
</button>
)}
{error && <p className="passkey-sign-error">{error}</p>}
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RefreshCw, X } from 'lucide-react'
import { usePwaUpdate } from '../hooks/usePwaUpdate.js'
export default function PwaUpdatePrompt() {
const { t } = useTranslation()
const { needRefresh, updateApp } = usePwaUpdate()
const [updating, setUpdating] = useState(false)
const [dismissed, setDismissed] = useState(false)
if (!needRefresh || dismissed) return null
const handleUpdate = async () => {
setUpdating(true)
try {
await updateApp()
} finally {
setUpdating(false)
}
}
return (
<div className="pwa-update-banner" role="alert" aria-live="polite">
<div className="pwa-update-icon" aria-hidden="true">
<RefreshCw size={22} />
</div>
<div className="pwa-update-body">
<p className="pwa-update-title">{t('pwa.update_title')}</p>
<p className="pwa-update-text">{t('pwa.update_desc')}</p>
</div>
<div className="pwa-update-actions">
<button
type="button"
className="btn primary pwa-update-btn"
onClick={handleUpdate}
disabled={updating}
>
{updating ? t('pwa.update_reloading') : t('pwa.update_now')}
</button>
<button
type="button"
className="pwa-update-link"
onClick={() => setDismissed(true)}
>
{t('pwa.later')}
</button>
</div>
<button
type="button"
className="pwa-update-close"
onClick={() => setDismissed(true)}
aria-label={t('pwa.later')}
>
<X size={18} />
</button>
</div>
)
}
+52 -15
View File
@@ -5,6 +5,8 @@ import { ensureLogbookKey } from '../services/logbookKeys.js'
import AccountDangerZone from './AccountDangerZone.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import { useDialog } from './ModalDialog.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import ThemedSelect from './ThemedSelect.tsx'
interface SettingsFormProps {
logbookId?: string | null
@@ -30,6 +32,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
const { showConfirm, showAlert } = useDialog()
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
const [colorScheme, setColorScheme] = useState(localStorage.getItem('active_color_scheme') || 'auto')
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
@@ -245,17 +248,29 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
}
}
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
localStorage.setItem('active_theme', nextTheme)
localStorage.setItem('active_color_scheme', nextColorScheme)
notifyAppearanceChanged()
}
const handleThemeChange = (nextTheme: string) => {
setTheme(nextTheme)
persistAppearance(nextTheme, colorScheme)
}
const handleColorSchemeChange = (nextColorScheme: string) => {
setColorScheme(nextColorScheme)
persistAppearance(theme, nextColorScheme)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setSuccess(false)
// Save to localStorage
localStorage.setItem('owm_api_key', apiKey.trim())
localStorage.setItem('active_theme', theme)
// Notify App of theme change
window.dispatchEvent(new Event('theme-changed'))
persistAppearance(theme, colorScheme)
setSaving(false)
setSuccess(true)
@@ -312,19 +327,41 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
</p>
<div className="input-group">
<select
<ThemedSelect
id="app-theme"
className="input-text"
value={theme}
onChange={(e) => setTheme(e.target.value)}
disabled={saving}
style={{ background: 'rgba(11, 12, 16, 0.85)', color: '#f1f5f9' }}
>
<option value="auto">{t('settings.theme_auto')}</option>
<option value="ocean">{t('settings.theme_ocean')}</option>
<option value="material">{t('settings.theme_material')}</option>
<option value="cupertino">{t('settings.theme_cupertino')}</option>
</select>
onChange={handleThemeChange}
options={[
{ value: 'auto', label: t('settings.theme_auto') },
{ value: 'ocean', label: t('settings.theme_ocean') },
{ value: 'material', label: t('settings.theme_material') },
{ value: 'cupertino', label: t('settings.theme_cupertino') }
]}
/>
</div>
</div>
<div className="member-editor-card glass mt-4">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.color_scheme_title')}
</h3>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.color_scheme_label')}
</p>
<div className="input-group">
<ThemedSelect
id="app-color-scheme"
value={colorScheme}
disabled={saving}
onChange={handleColorSchemeChange}
options={[
{ value: 'auto', label: t('settings.color_scheme_auto') },
{ value: 'light', label: t('settings.color_scheme_light') },
{ value: 'dark', label: t('settings.color_scheme_dark') }
]}
/>
</div>
</div>
+10 -2
View File
@@ -10,6 +10,7 @@ interface SignaturePadProps {
onChange: (value: string) => void
disabled?: boolean
readOnly?: boolean
onBeforeSign?: () => Promise<boolean> | boolean
}
const STROKE_COLOR = '#0f172a'
@@ -21,7 +22,8 @@ export default function SignaturePad({
value,
onChange,
disabled = false,
readOnly = false
readOnly = false,
onBeforeSign
}: SignaturePadProps) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
@@ -138,9 +140,15 @@ export default function SignaturePad({
onChange(canvas.toDataURL('image/png'))
}
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
const handlePointerDown = async (event: React.PointerEvent<HTMLCanvasElement>) => {
if (readOnly || disabled) return
event.preventDefault()
if (!value && !hasInk.current && onBeforeSign) {
const allowed = await onBeforeSign()
if (!allowed) return
}
const point = getPoint(event)
if (!point) return
+258
View File
@@ -0,0 +1,258 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
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'
type SignatureMode = 'passkey' | 'classic'
interface SignatureSectionProps {
readOnly?: boolean
disabled?: boolean
isOnline: boolean
canSignSkipper: boolean
hasWriteCollaborators: boolean
signSkipper: SignatureValue | ''
signCrew: SignatureValue | ''
skipperSignatureValid: boolean
crewSignatureValid: boolean
onSignSkipperChange: (value: SignatureValue | '') => void
onSignCrewChange: (value: SignatureValue | '') => void
onPasskeySignSkipper: () => Promise<void>
onPasskeySignCrew: () => Promise<void>
onBeforeSign?: () => Promise<boolean>
}
function padValue(value: SignatureValue | ''): string {
if (!value || isPasskeySignature(value)) return ''
return value
}
function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode {
if (isPasskeySignature(value)) return 'passkey'
if (value) return 'classic'
return passkeyAvailable ? 'passkey' : 'classic'
}
interface RoleSignatureBlockProps {
roleLabel: string
passkeyLabel: string
padId: string
value: SignatureValue | ''
passkeySignature?: PasskeySignature
signatureValid: boolean
showPasskey: boolean
readOnly: boolean
disabled: boolean
classicHint?: string
offlineHint?: string
onChange: (value: SignatureValue | '') => void
onPasskeySign: () => Promise<void>
onBeforeSign?: () => Promise<boolean>
}
function RoleSignatureBlock({
roleLabel,
passkeyLabel,
padId,
value,
passkeySignature,
signatureValid,
showPasskey,
readOnly,
disabled,
classicHint,
offlineHint,
onChange,
onPasskeySign,
onBeforeSign
}: RoleSignatureBlockProps) {
const { t } = useTranslation()
const [mode, setMode] = useState<SignatureMode>(() => modeFromValue(value, showPasskey))
useEffect(() => {
setMode(modeFromValue(value, showPasskey))
}, [value, showPasskey])
const switchToClassic = () => {
setMode('classic')
if (isPasskeySignature(value)) onChange('')
}
const switchToPasskey = () => {
setMode('passkey')
if (value && !isPasskeySignature(value)) onChange('')
}
const handlePadChange = (next: string) => {
setMode('classic')
onChange(next)
}
if (readOnly) {
if (isPasskeySignature(value)) {
return (
<div className="signature-role-block">
<PasskeySignButton
label={passkeyLabel}
signature={value}
signatureValid={signatureValid}
disabled={disabled}
canSign={false}
onSign={onPasskeySign}
/>
</div>
)
}
return (
<div className="signature-role-block">
<SignaturePad
id={padId}
label={roleLabel}
value={padValue(value)}
onChange={() => {}}
disabled={disabled}
readOnly
/>
</div>
)
}
const showPasskeyPanel = showPasskey && mode === 'passkey'
const showClassicPanel = !showPasskey || mode === 'classic'
return (
<div className="signature-role-block">
{showPasskey && (
<div className="signature-mode-toggle" role="tablist" aria-label={passkeyLabel}>
<button
type="button"
role="tab"
aria-selected={mode === 'passkey'}
className={`signature-mode-btn ${mode === 'passkey' ? 'active' : ''}`}
onClick={switchToPasskey}
>
{t('logs.sign_mode_passkey')}
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'classic'}
className={`signature-mode-btn ${mode === 'classic' ? 'active' : ''}`}
onClick={switchToClassic}
>
{t('logs.sign_mode_classic')}
</button>
</div>
)}
{showPasskeyPanel && (
<PasskeySignButton
label={passkeyLabel}
signature={passkeySignature}
signatureValid={signatureValid}
disabled={disabled}
canSign
onSign={onPasskeySign}
onClear={passkeySignature ? switchToClassic : undefined}
/>
)}
{showClassicPanel && (
<>
<SignaturePad
id={padId}
label={roleLabel}
value={padValue(value)}
onChange={handlePadChange}
disabled={disabled}
readOnly={false}
onBeforeSign={onBeforeSign}
/>
{classicHint && !passkeySignature && (
<p className="signature-hint">{classicHint}</p>
)}
</>
)}
{offlineHint && !showPasskey && (
<p className="signature-hint">{offlineHint}</p>
)}
</div>
)
}
export default function SignatureSection({
readOnly = false,
disabled = false,
isOnline,
canSignSkipper,
hasWriteCollaborators,
signSkipper,
signCrew,
skipperSignatureValid,
crewSignatureValid,
onSignSkipperChange,
onSignCrewChange,
onPasskeySignSkipper,
onPasskeySignCrew,
onBeforeSign
}: SignatureSectionProps) {
const { t } = useTranslation()
const showSkipperPasskey = canSignSkipper && isOnline
const showCrewPasskey = hasWriteCollaborators && isOnline
const hasSignature = !!(signSkipper || signCrew)
return (
<div className="form-card">
<div className="form-header">
<Check size={20} className="form-icon" />
<h3>{t('logs.signatures')}</h3>
</div>
{!readOnly && (
<p className={`signature-lock-notice ${hasSignature ? 'locked' : ''}`}>
{hasSignature ? t('logs.sign_lock_active') : t('logs.sign_lock_notice')}
</p>
)}
<div className="form-grid signature-grid">
<RoleSignatureBlock
roleLabel={t('logs.sign_skipper')}
passkeyLabel={t('logs.sign_skipper')}
padId="sign-skipper"
value={signSkipper}
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
signatureValid={skipperSignatureValid}
showPasskey={showSkipperPasskey}
readOnly={readOnly}
disabled={disabled}
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined}
onChange={onSignSkipperChange}
onPasskeySign={onPasskeySignSkipper}
onBeforeSign={onBeforeSign}
/>
<RoleSignatureBlock
roleLabel={t('logs.sign_crew')}
passkeyLabel={t('logs.sign_crew')}
padId="sign-crew"
value={signCrew}
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
signatureValid={crewSignatureValid}
showPasskey={showCrewPasskey}
readOnly={readOnly}
disabled={disabled}
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
onChange={onSignCrewChange}
onPasskeySign={onPasskeySignCrew}
onBeforeSign={onBeforeSign}
/>
</div>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
export interface ThemedSelectOption {
value: string
label: string
}
interface ThemedSelectProps {
id?: string
value: string
options: ThemedSelectOption[]
onChange: (value: string) => void
disabled?: boolean
}
export default function ThemedSelect({
id,
value,
options,
onChange,
disabled = false
}: ThemedSelectProps) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const selected = options.find((option) => option.value === value)
useEffect(() => {
if (!open) return
const closeOnOutsideClick = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', closeOnOutsideClick)
document.addEventListener('keydown', closeOnEscape)
return () => {
document.removeEventListener('mousedown', closeOnOutsideClick)
document.removeEventListener('keydown', closeOnEscape)
}
}, [open])
const selectOption = (nextValue: string) => {
onChange(nextValue)
setOpen(false)
}
return (
<div className={`themed-select${open ? ' is-open' : ''}`} ref={rootRef}>
<button
type="button"
id={id}
className="themed-select-trigger input-text"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => !disabled && setOpen((current) => !current)}
>
<span>{selected?.label ?? value}</span>
<ChevronDown size={16} className="themed-select-chevron" aria-hidden="true" />
</button>
{open && (
<ul className="themed-select-menu" role="listbox" aria-labelledby={id}>
{options.map((option) => (
<li
key={option.value}
role="option"
aria-selected={option.value === value}
className={`themed-select-option${option.value === value ? ' is-selected' : ''}`}
onClick={() => selectOption(option.value)}
>
{option.label}
</li>
))}
</ul>
)}
</div>
)
}
+99
View File
@@ -13,9 +13,30 @@ interface VesselFormProps {
preloadedData?: any
}
function metricInputFromStored(value: unknown): string {
if (value == null || value === '') return ''
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'string') return value.trim()
return ''
}
function parseOptionalMetricMeters(input: string): number | undefined {
const trimmed = input.trim().replace(',', '.')
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error('invalid_metric')
}
return parsed
}
export default function VesselForm({ logbookId, readOnly = false, preloadedData }: VesselFormProps) {
const { t } = useTranslation()
const [name, setName] = useState('')
const [vesselType, setVesselType] = useState<'sailing' | 'motor' | ''>('')
const [lengthM, setLengthM] = useState('')
const [draftM, setDraftM] = useState('')
const [airDraftM, setAirDraftM] = useState('')
const [homePort, setHomePort] = useState('')
const [charterCompany, setCharterCompany] = useState('')
const [owner, setOwner] = useState('')
@@ -43,6 +64,10 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
try {
if (readOnly && preloadedData) {
setName(preloadedData.name || '')
setVesselType(preloadedData.vesselType || '')
setLengthM(metricInputFromStored(preloadedData.lengthM))
setDraftM(metricInputFromStored(preloadedData.draftM))
setAirDraftM(metricInputFromStored(preloadedData.airDraftM))
setHomePort(preloadedData.homePort || '')
setCharterCompany(preloadedData.charterCompany || '')
setOwner(preloadedData.owner || '')
@@ -64,6 +89,10 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
const decrypted = await decryptJson(local.encryptedData, local.iv, local.tag, masterKey)
if (decrypted) {
setName(decrypted.name || '')
setVesselType(decrypted.vesselType || '')
setLengthM(metricInputFromStored(decrypted.lengthM))
setDraftM(metricInputFromStored(decrypted.draftM))
setAirDraftM(metricInputFromStored(decrypted.airDraftM))
setHomePort(decrypted.homePort || '')
setCharterCompany(decrypted.charterCompany || '')
setOwner(decrypted.owner || '')
@@ -168,8 +197,25 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
let parsedLengthM: number | undefined
let parsedDraftM: number | undefined
let parsedAirDraftM: number | undefined
try {
parsedLengthM = parseOptionalMetricMeters(lengthM)
parsedDraftM = parseOptionalMetricMeters(draftM)
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
} catch {
setError(t('vessel.invalid_metric'))
setSaving(false)
return
}
const yachtData = {
name: name.trim(),
vesselType: vesselType || undefined,
lengthM: parsedLengthM,
draftM: parsedDraftM,
airDraftM: parsedAirDraftM,
homePort: homePort.trim(),
charterCompany: charterCompany.trim(),
owner: owner.trim(),
@@ -302,6 +348,59 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
/>
</div>
<div className="input-group">
<label>{t('vessel.type')}</label>
<select
className="input-text"
value={vesselType}
onChange={(e) => setVesselType(e.target.value as 'sailing' | 'motor' | '')}
disabled={saving || readOnly}
>
<option value="">{t('vessel.type_unset')}</option>
<option value="sailing">{t('vessel.type_sailing')}</option>
<option value="motor">{t('vessel.type_motor')}</option>
</select>
</div>
<div className="input-group">
<label>{t('vessel.length_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={lengthM}
onChange={(e) => setLengthM(e.target.value)}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.draft_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={draftM}
onChange={(e) => setDraftM(e.target.value)}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.air_draft_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={airDraftM}
onChange={(e) => setAirDraftM(e.target.value)}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.port')}</label>
<input
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useRef } from 'react'
import { useRegisterSW } from 'virtual:pwa-register/react'
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
const checkForUpdate = () => {
registration.update().catch(() => {})
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
checkForUpdate()
}
}
document.addEventListener('visibilitychange', onVisibilityChange)
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.clearInterval(intervalId)
}
}
export function usePwaUpdate() {
const cleanupRef = useRef<(() => void) | null>(null)
const {
needRefresh: [needRefresh],
updateServiceWorker
} = useRegisterSW({
immediate: true,
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
if (!registration) return
cleanupRef.current?.()
cleanupRef.current = scheduleUpdateChecks(registration)
}
})
useEffect(() => {
return () => {
cleanupRef.current?.()
cleanupRef.current = null
}
}, [])
const updateApp = async () => {
await updateServiceWorker(true)
}
return { needRefresh, updateApp }
}
+42 -2
View File
@@ -66,7 +66,11 @@
"platform_ios": "Installation über Safari",
"platform_android": "Installation über den Browser",
"platform_desktop": "Installation als Desktop-App",
"settings_section": "App-Installation"
"settings_section": "App-Installation",
"update_title": "Update verfügbar",
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
"update_now": "Jetzt aktualisieren",
"update_reloading": "Wird geladen…"
},
"sync": {
"status_synced": "Synchronisiert",
@@ -76,6 +80,14 @@
"vessel": {
"title": "Schiffs-Stammdaten",
"name": "Yachtname",
"type": "Yachttyp",
"type_unset": "— nicht angegeben —",
"type_sailing": "Segelyacht",
"type_motor": "Motoryacht",
"length_m": "Länge (m)",
"draft_m": "Tiefgang (m)",
"air_draft_m": "Höhe (m)",
"invalid_metric": "Ungültiger Zahlenwert — bitte Meter als Dezimalzahl eingeben (z. B. 12,5).",
"port": "Heimathafen",
"owner": "Eigner",
"charter": "Charterfirma",
@@ -117,6 +129,28 @@
"sign_crew": "Crew-Unterschrift",
"sign_hint": "Mit Finger, Stift oder Maus unterschreiben",
"sign_clear": "Löschen",
"sign_export_image": "[Unterschrift]",
"sign_with_passkey": "Mit Passkey freigeben",
"sign_passkey_signing": "Passkey wird angefordert…",
"sign_passkey_signed": "Freigegeben von {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_passkey_clear": "Passkey-Freigabe entfernen",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Klassisch",
"sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen",
"sign_passkey_cancelled": "Passkey-Freigabe abgebrochen",
"sign_invalid": "Signatur ungültig — Inhalt wurde geändert",
"sign_classic_or_passkey": "Optional: klassisch unterschreiben oder Passkey-Freigabe oben",
"sign_crew_passkey_hint": "Crew-Mitglieder mit Schreibzugriff können per Passkey freigeben",
"sign_offline_hint": "Passkey-Freigabe erfordert Internet — klassische Unterschrift offline möglich",
"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_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!",
"back_to_list": "Zurück zur Journal-Liste",
"save": "Logbuchseite speichern",
@@ -200,7 +234,8 @@
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...",
"status_synced": "Synchronisiert",
"status_local": "Nur lokaler Cache"
"status_local": "Nur lokaler Cache",
"delete_btn": "Logbuch löschen"
},
"crew": {
"title": "Skipper- & Crew-Profile",
@@ -255,6 +290,11 @@
"theme_ocean": "Ocean (Glassmorphismus)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_title": "Erscheinungsbild",
"color_scheme_label": "Hell- oder Dunkelmodus (Standard: Systemeinstellung)",
"color_scheme_auto": "Automatisch (System)",
"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_enable": "Öffentlichen Link aktivieren",
+42 -2
View File
@@ -66,7 +66,11 @@
"platform_ios": "Install via Safari",
"platform_android": "Install via browser",
"platform_desktop": "Install as desktop app",
"settings_section": "App installation"
"settings_section": "App installation",
"update_title": "Update available",
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
"update_now": "Reload now",
"update_reloading": "Reloading…"
},
"sync": {
"status_synced": "Synced",
@@ -76,6 +80,14 @@
"vessel": {
"title": "Vessel Master Data",
"name": "Yacht Name",
"type": "Vessel Type",
"type_unset": "— not specified —",
"type_sailing": "Sailing yacht",
"type_motor": "Motor yacht",
"length_m": "Length (m)",
"draft_m": "Draft (m)",
"air_draft_m": "Air draft (m)",
"invalid_metric": "Invalid number — please enter meters as a decimal (e.g. 12.5).",
"port": "Home Port",
"owner": "Owner",
"charter": "Charter Company",
@@ -117,6 +129,28 @@
"sign_crew": "Crew signature",
"sign_hint": "Sign with finger, stylus, or mouse",
"sign_clear": "Clear",
"sign_export_image": "[Signature]",
"sign_with_passkey": "Sign with Passkey",
"sign_passkey_signing": "Requesting Passkey…",
"sign_passkey_signed": "Signed by {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_passkey_clear": "Remove Passkey signature",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Classic",
"sign_passkey_failed": "Passkey signing failed",
"sign_passkey_cancelled": "Passkey signing cancelled",
"sign_invalid": "Signature invalid — entry content changed",
"sign_classic_or_passkey": "Optional: sign classically below or use Passkey above",
"sign_crew_passkey_hint": "Write collaborators can sign with their Passkey",
"sign_offline_hint": "Passkey signing requires internet — classic signature works offline",
"sign_lock_notice": "After signing, log entry changes (except photos) require Skipper and Crew to sign again.",
"sign_lock_active": "This entry is signed. Changes to the log (except photos) will automatically remove Skipper and Crew signatures.",
"sign_lock_warning_title": "Confirm signature",
"sign_lock_warning": "After signing, changes to the log entry (except photos) are not possible without Skipper and Crew signing again.\n\nDo you want to proceed?",
"sign_proceed": "Sign",
"sign_cancel": "Cancel",
"sign_cleared_re_sign_title": "Signatures removed",
"sign_cleared_re_sign": "The log entry was changed. Skipper and Crew signatures were removed. Please sign again.",
"no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!",
"back_to_list": "Back to Journal List",
"save": "Save Logbook Page",
@@ -200,7 +234,8 @@
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...",
"status_synced": "Synced",
"status_local": "Local Cache Only"
"status_local": "Local Cache Only",
"delete_btn": "Delete logbook"
},
"crew": {
"title": "Skipper & Crew Profiles",
@@ -255,6 +290,11 @@
"theme_ocean": "Ocean (Glassmorphism)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_title": "Appearance",
"color_scheme_label": "Light or dark mode (default: follow system)",
"color_scheme_auto": "Auto (System)",
"color_scheme_light": "Light",
"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_enable": "Enable Public Link",
+4
View File
@@ -1,9 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css'
import './themes.css'
import './index.css'
import App from './App.tsx'
import './i18n'
import { applyAppearanceToDocument } from './services/appearance.ts'
applyAppearanceToDocument()
createRoot(document.getElementById('root')!).render(
<StrictMode>
+53
View File
@@ -0,0 +1,53 @@
export type ColorSchemePreference = 'auto' | 'light' | 'dark'
export type ResolvedColorScheme = 'light' | 'dark'
export type AppTheme = 'ocean' | 'material' | 'cupertino'
const THEME_CLASSES = ['theme-ocean', 'theme-material', 'theme-cupertino'] as const
const SCHEME_CLASSES = ['scheme-light', 'scheme-dark'] as const
export function getColorSchemePreference(): ColorSchemePreference {
const stored = localStorage.getItem('active_color_scheme')
if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored
return 'auto'
}
export function resolveColorScheme(pref?: ColorSchemePreference): ResolvedColorScheme {
const preference = pref ?? getColorSchemePreference()
if (preference === 'light') return 'light'
if (preference === 'dark') return 'dark'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
export function resolveAppTheme(): AppTheme {
const configTheme = localStorage.getItem('active_theme') || 'auto'
if (configTheme === 'material' || configTheme === 'cupertino' || configTheme === 'ocean') {
return configTheme
}
const userAgent = navigator.userAgent || navigator.vendor || ''
if (/iPad|iPhone|iPod|Macintosh/.test(userAgent)) return 'cupertino'
if (/Android|Linux/.test(userAgent)) return 'material'
return 'ocean'
}
export function applyAppearanceToDocument(
theme: AppTheme = resolveAppTheme(),
scheme: ResolvedColorScheme = resolveColorScheme()
): void {
const root = document.documentElement
root.classList.remove(...THEME_CLASSES, ...SCHEME_CLASSES)
root.classList.add(`theme-${theme}`, `scheme-${scheme}`)
root.style.colorScheme = scheme
}
export function subscribeToSystemColorScheme(onChange: () => void): () => void {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => {
if (getColorSchemePreference() === 'auto') onChange()
}
media.addEventListener('change', handler)
return () => media.removeEventListener('change', handler)
}
export function notifyAppearanceChanged(): void {
window.dispatchEvent(new Event('appearance-changed'))
}
+11 -3
View File
@@ -2,7 +2,8 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { formatSignatureForExport } from '../utils/signatures.js'
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
import i18n from '../i18n/index.js'
function escapeCsvValue(val: string | number | undefined | null): string {
if (val === null || val === undefined) return '';
@@ -89,14 +90,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
];
const rows: string[][] = [headers];
const exportLabels = {
imagePlaceholder: i18n.t('logs.sign_export_image'),
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 })
}
};
for (const entry of decryptedEntries) {
const dateVal = entry.date || '';
const travelDay = entry.dayOfTravel || '';
const dep = entry.departure || '';
const dest = entry.destination || '';
const signS = formatSignatureForExport(entry.signSkipper);
const signC = formatSignatureForExport(entry.signCrew);
const signS = formatSignatureForExport(normalizeSignature(entry.signSkipper), exportLabels);
const signC = formatSignatureForExport(normalizeSignature(entry.signCrew), exportLabels);
const trackDist = entry.trackDistanceNm ?? '';
const trackMax = entry.trackSpeedMaxKn ?? '';
const trackAvg = entry.trackSpeedAvgKn ?? '';
+12
View File
@@ -5,6 +5,7 @@ export interface LocalLogbook {
encryptedTitle: string
updatedAt: string
isSynced: number // 1 = yes, 0 = pending local modifications
isShared?: number // 1 = collaborator copy, 0 or unset = owned
}
export interface LocalYacht {
@@ -120,6 +121,17 @@ class DaagboxDatabase extends Dexie {
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(4).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
}
}
+64
View File
@@ -0,0 +1,64 @@
import { startAuthentication } from '@simplewebauthn/browser'
import type { PasskeySignature } from '../types/signatures.js'
export async function signLogEntry(params: {
logbookId: string
entryId: string
entryHash: string
role: 'skipper' | 'crew'
}): Promise<PasskeySignature> {
const userId = localStorage.getItem('active_userid')
if (!userId) throw new Error('User not authenticated')
const optionsRes = await fetch('/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', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({
credentialResponse,
challenge: options.challenge,
logbookId: params.logbookId,
entryId: params.entryId,
entryHash: params.entryHash,
role: params.role
})
})
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,
role: params.role,
userId: result.userId,
username: result.username,
credentialId: result.credentialId,
signedAt: result.signedAt,
entryHash: params.entryHash,
clientVerified: true
}
}
+26 -9
View File
@@ -10,6 +10,7 @@ export interface DecryptedLogbook {
title: string
updatedAt: string
isSynced: boolean
isShared: boolean
}
// Helper to decrypt a logbook's title using the active logbook key or master key
@@ -57,9 +58,17 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
// Decrypt and save logbook keys locally if they exist
for (const lb of serverLogbooks) {
const encryptedKeyStr = lb.encryptedKey || (lb.collaborators && lb.collaborators[0]?.encryptedLogbookKey)
const ivStr = lb.iv || (lb.collaborators && lb.collaborators[0]?.iv)
const tagStr = lb.tag || (lb.collaborators && lb.collaborators[0]?.tag)
const isShared = lb.userId !== userId
const encryptedKeyStr = isShared
? lb.collaborators?.[0]?.encryptedLogbookKey
: (lb.encryptedKey || lb.collaborators?.[0]?.encryptedLogbookKey)
const ivStr = isShared
? lb.collaborators?.[0]?.iv
: (lb.iv || lb.collaborators?.[0]?.iv)
const tagStr = isShared
? lb.collaborators?.[0]?.tag
: (lb.tag || lb.collaborators?.[0]?.tag)
if (encryptedKeyStr && ivStr && tagStr) {
try {
@@ -75,6 +84,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
} catch (err) {
console.error(`Failed to decrypt and save logbook key for logbook ${lb.id}:`, err)
}
} else if (isShared) {
console.warn(`Shared logbook ${lb.id} is missing collaboration key on server`)
}
}
// Clear local cache for any logbooks that are no longer on the server
@@ -91,7 +102,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
id: lb.id,
encryptedTitle: lb.encryptedTitle,
updatedAt: lb.updatedAt || new Date().toISOString(),
isSynced: 1
isSynced: 1,
isShared: lb.userId !== userId ? 1 : 0
}))
// Clear existing cache for this user and insert new ones
@@ -113,7 +125,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
id: lb.id,
title,
updatedAt: lb.updatedAt,
isSynced: lb.isSynced === 1
isSynced: lb.isSynced === 1,
isShared: lb.isShared === 1
})
}
@@ -180,14 +193,16 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: serverLb.id,
encryptedTitle: serverLb.encryptedTitle,
updatedAt: serverLb.updatedAt,
isSynced: 1
isSynced: 1,
isShared: 0
})
return {
id: serverLb.id,
title,
updatedAt: serverLb.updatedAt,
isSynced: true
isSynced: true,
isShared: false
}
}
} catch (error) {
@@ -200,7 +215,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: localId,
encryptedTitle: encryptedTitleStr,
updatedAt: now,
isSynced: 0
isSynced: 0,
isShared: 0
})
await db.syncQueue.put({
@@ -216,7 +232,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: localId,
title,
updatedAt: now,
isSynced: false
isSynced: false,
isShared: false
}
}
+20
View File
@@ -0,0 +1,20 @@
export interface LogbookAccess {
isOwner: boolean
role: 'OWNER' | 'READ' | 'WRITE'
writeCollaboratorCount: number
}
export async function getLogbookAccess(logbookId: string): Promise<LogbookAccess | null> {
const userId = localStorage.getItem('active_userid')
if (!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()
} catch {
return null
}
}
+19 -3
View File
@@ -3,7 +3,13 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { isSignatureImage } from '../utils/signatures.js'
import { isSignatureImage, isPasskeySignature } from '../utils/signatures.js'
import i18n from '../i18n/index.js'
function formatPasskeySignDate(signedAt: string): string {
const locale = i18n.language === 'de' ? 'de-DE' : 'en-GB'
return new Date(signedAt).toLocaleString(locale)
}
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
@@ -230,7 +236,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
doc.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2);
if (isSignatureImage(entry.signSkipper)) {
if (isPasskeySignature(entry.signSkipper)) {
doc.setFont('Helvetica', 'normal');
const skipperDate = formatPasskeySignDate(entry.signSkipper.signedAt);
doc.text(`Passkey: ${entry.signSkipper.username}`, sigX + 2, sigY + 9);
doc.text(skipperDate, sigX + 2, sigY + 13.5);
} else if (isSignatureImage(entry.signSkipper)) {
doc.addImage(entry.signSkipper, 'PNG', sigX + 2, sigY + 6, 72, 14)
} else {
doc.setFont('Helvetica', 'normal');
@@ -239,7 +250,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.setFont('Helvetica', 'bold');
doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2);
if (isSignatureImage(entry.signCrew)) {
if (isPasskeySignature(entry.signCrew)) {
doc.setFont('Helvetica', 'normal');
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 {
doc.setFont('Helvetica', 'normal');
+398
View File
@@ -0,0 +1,398 @@
/**
* Appearance tokens: scheme (light/dark) × theme (ocean/material/cupertino)
* Applied on document.documentElement via appearance.ts
*/
/* Fallback before JS hydrates (ocean · dark) */
html {
color-scheme: dark;
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
--app-text: #f1f5f9;
--app-text-heading: #f8fafc;
--app-text-muted: #94a3b8;
--app-text-subtle: #64748b;
--app-surface: rgba(11, 12, 16, 0.75);
--app-surface-alt: rgba(11, 12, 16, 0.6);
--app-surface-hover: rgba(11, 12, 16, 0.85);
--app-surface-inset: rgba(255, 255, 255, 0.02);
--app-border: rgba(212, 175, 55, 0.25);
--app-border-subtle: rgba(255, 255, 255, 0.08);
--app-border-muted: rgba(212, 175, 55, 0.15);
--app-input-bg: rgba(11, 12, 16, 0.85);
--app-input-bg-focus: #0b0c10;
--app-input-border: rgba(148, 163, 184, 0.25);
--app-input-text: #f1f5f9;
--app-accent: #d97706;
--app-accent-light: #fbbf24;
--app-accent-gradient: linear-gradient(135deg, #fef08a 0%, #d97706 100%);
--app-accent-bg: rgba(217, 119, 6, 0.1);
--app-accent-border: rgba(217, 119, 6, 0.2);
--app-accent-focus-ring: rgba(217, 119, 6, 0.2);
--app-btn-primary-text: #0b0c10;
--app-btn-secondary-bg: rgba(255, 255, 255, 0.05);
--app-btn-secondary-border: rgba(255, 255, 255, 0.12);
--app-btn-secondary-text: #e2e8f0;
--app-btn-secondary-hover-bg: rgba(255, 255, 255, 0.08);
--app-icon-btn-bg: rgba(255, 255, 255, 0.05);
--app-icon-btn-border: rgba(255, 255, 255, 0.1);
--app-divider: rgba(255, 255, 255, 0.06);
--app-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
--app-card-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
--app-error-bg: rgba(244, 63, 94, 0.08);
--app-error-text: #fda4af;
--app-error-border: #f43f5e;
--app-warning-text: #f43f5e;
--app-warning-bg: rgba(244, 63, 94, 0.08);
--app-warning-border: rgba(244, 63, 94, 0.2);
--app-empty-border: rgba(255, 255, 255, 0.08);
--app-empty-bg: rgba(255, 255, 255, 0.02);
--app-sidebar-active-bg: rgba(217, 119, 6, 0.12);
--app-sidebar-active-border: #d97706;
--app-sidebar-active-text: #fbbf24;
--app-header-border: rgba(212, 175, 55, 0.15);
--app-table-border: rgba(255, 255, 255, 0.08);
--app-progress-bar: linear-gradient(90deg, #d97706, #fbbf24, #d97706);
--app-backdrop: blur(20px);
--app-radius-card: 16px;
--app-radius-input: 10px;
--app-radius-btn: 10px;
}
/* ===== OCEAN · DARK (default) ===== */
html.scheme-dark.theme-ocean {
color-scheme: dark;
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
--app-text: #f1f5f9;
--app-text-heading: #f8fafc;
--app-text-muted: #94a3b8;
--app-text-subtle: #64748b;
--app-surface: rgba(11, 12, 16, 0.75);
--app-surface-alt: rgba(11, 12, 16, 0.6);
--app-surface-hover: rgba(11, 12, 16, 0.85);
--app-surface-inset: rgba(255, 255, 255, 0.02);
--app-border: rgba(212, 175, 55, 0.25);
--app-border-subtle: rgba(255, 255, 255, 0.08);
--app-border-muted: rgba(212, 175, 55, 0.15);
--app-input-bg: rgba(11, 12, 16, 0.85);
--app-input-bg-focus: #0b0c10;
--app-input-border: rgba(148, 163, 184, 0.25);
--app-input-text: #f1f5f9;
--app-accent: #d97706;
--app-accent-light: #fbbf24;
--app-accent-gradient: linear-gradient(135deg, #fef08a 0%, #d97706 100%);
--app-accent-bg: rgba(217, 119, 6, 0.1);
--app-accent-border: rgba(217, 119, 6, 0.2);
--app-accent-focus-ring: rgba(217, 119, 6, 0.2);
--app-btn-primary-text: #0b0c10;
--app-btn-secondary-bg: rgba(255, 255, 255, 0.05);
--app-btn-secondary-border: rgba(255, 255, 255, 0.12);
--app-btn-secondary-text: #e2e8f0;
--app-btn-secondary-hover-bg: rgba(255, 255, 255, 0.08);
--app-icon-btn-bg: rgba(255, 255, 255, 0.05);
--app-icon-btn-border: rgba(255, 255, 255, 0.1);
--app-divider: rgba(255, 255, 255, 0.06);
--app-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
--app-card-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
--app-error-bg: rgba(244, 63, 94, 0.08);
--app-error-text: #fda4af;
--app-error-border: #f43f5e;
--app-warning-text: #f43f5e;
--app-warning-bg: rgba(244, 63, 94, 0.08);
--app-warning-border: rgba(244, 63, 94, 0.2);
--app-empty-border: rgba(255, 255, 255, 0.08);
--app-empty-bg: rgba(255, 255, 255, 0.02);
--app-sidebar-active-bg: rgba(217, 119, 6, 0.12);
--app-sidebar-active-border: #d97706;
--app-sidebar-active-text: #fbbf24;
--app-header-border: rgba(212, 175, 55, 0.15);
--app-table-border: rgba(255, 255, 255, 0.08);
--app-progress-bar: linear-gradient(90deg, #d97706, #fbbf24, #d97706);
--app-backdrop: blur(20px);
--app-radius-card: 16px;
--app-radius-input: 10px;
--app-radius-btn: 10px;
}
/* ===== OCEAN · LIGHT ===== */
html.scheme-light.theme-ocean {
color-scheme: light;
--app-body-bg: linear-gradient(165deg, #dbeafe 0%, #f8fafc 42%, #e2e8f0 100%);
--app-text: #1e293b;
--app-text-heading: #0f172a;
--app-text-muted: #475569;
--app-text-subtle: #64748b;
--app-surface: rgba(255, 255, 255, 0.88);
--app-surface-alt: rgba(255, 255, 255, 0.78);
--app-surface-hover: rgba(255, 255, 255, 0.96);
--app-surface-inset: rgba(15, 23, 42, 0.03);
--app-border: rgba(217, 119, 6, 0.28);
--app-border-subtle: rgba(15, 23, 42, 0.1);
--app-border-muted: rgba(217, 119, 6, 0.18);
--app-input-bg: #ffffff;
--app-input-bg-focus: #ffffff;
--app-input-border: rgba(100, 116, 139, 0.35);
--app-input-text: #0f172a;
--app-accent: #b45309;
--app-accent-light: #d97706;
--app-accent-gradient: linear-gradient(135deg, #fcd34d 0%, #b45309 100%);
--app-accent-bg: rgba(217, 119, 6, 0.12);
--app-accent-border: rgba(217, 119, 6, 0.25);
--app-accent-focus-ring: rgba(217, 119, 6, 0.25);
--app-btn-primary-text: #0b0c10;
--app-btn-secondary-bg: rgba(15, 23, 42, 0.04);
--app-btn-secondary-border: rgba(15, 23, 42, 0.12);
--app-btn-secondary-text: #334155;
--app-btn-secondary-hover-bg: rgba(15, 23, 42, 0.07);
--app-icon-btn-bg: rgba(15, 23, 42, 0.04);
--app-icon-btn-border: rgba(15, 23, 42, 0.1);
--app-divider: rgba(15, 23, 42, 0.08);
--app-shadow: 0 16px 40px rgba(15, 23, 42, 0.12), inset 0 0 0 1px rgba(255, 255, 255, 0.6);
--app-card-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
--app-error-bg: rgba(244, 63, 94, 0.08);
--app-error-text: #be123c;
--app-error-border: #e11d48;
--app-warning-text: #be123c;
--app-warning-bg: rgba(244, 63, 94, 0.06);
--app-warning-border: rgba(244, 63, 94, 0.2);
--app-empty-border: rgba(15, 23, 42, 0.12);
--app-empty-bg: rgba(15, 23, 42, 0.02);
--app-sidebar-active-bg: rgba(217, 119, 6, 0.1);
--app-sidebar-active-border: #d97706;
--app-sidebar-active-text: #b45309;
--app-header-border: rgba(217, 119, 6, 0.2);
--app-table-border: rgba(15, 23, 42, 0.1);
--app-progress-bar: linear-gradient(90deg, #d97706, #fbbf24, #d97706);
--app-backdrop: blur(20px);
--app-radius-card: 16px;
--app-radius-input: 10px;
--app-radius-btn: 10px;
}
/* ===== MATERIAL · DARK ===== */
html.scheme-dark.theme-material {
color-scheme: dark;
--app-body-bg: #121212;
--app-text: #f1f5f9;
--app-text-heading: #f8fafc;
--app-text-muted: #94a3b8;
--app-text-subtle: #64748b;
--app-surface: #1e1e1e;
--app-surface-alt: #1e1e1e;
--app-surface-hover: #252525;
--app-surface-inset: #2a2a2a;
--app-border: #2d2d2d;
--app-border-subtle: #2d2d2d;
--app-border-muted: #2d2d2d;
--app-input-bg: #2a2a2a;
--app-input-bg-focus: #2a2a2a;
--app-input-border: #3d3d3d;
--app-input-text: #f1f5f9;
--app-accent: #00adb5;
--app-accent-light: #00adb5;
--app-accent-gradient: linear-gradient(135deg, #00adb5 0%, #008f95 100%);
--app-accent-bg: rgba(0, 173, 181, 0.12);
--app-accent-border: rgba(0, 173, 181, 0.3);
--app-accent-focus-ring: rgba(0, 173, 181, 0.2);
--app-btn-primary-text: #ffffff;
--app-btn-secondary-bg: #2a2a2a;
--app-btn-secondary-border: #3d3d3d;
--app-btn-secondary-text: #f1f5f9;
--app-btn-secondary-hover-bg: #333333;
--app-icon-btn-bg: #2a2a2a;
--app-icon-btn-border: #3d3d3d;
--app-divider: #2d2d2d;
--app-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
--app-card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
--app-error-bg: rgba(244, 63, 94, 0.08);
--app-error-text: #fda4af;
--app-error-border: #f43f5e;
--app-warning-text: #f43f5e;
--app-warning-bg: rgba(244, 63, 94, 0.08);
--app-warning-border: rgba(244, 63, 94, 0.2);
--app-empty-border: #2d2d2d;
--app-empty-bg: #1a1a1a;
--app-sidebar-active-bg: rgba(0, 173, 181, 0.08);
--app-sidebar-active-border: #00adb5;
--app-sidebar-active-text: #00adb5;
--app-header-border: #2d2d2d;
--app-table-border: #2d2d2d;
--app-progress-bar: linear-gradient(90deg, #00adb5, #008f95, #00adb5);
--app-backdrop: none;
--app-radius-card: 4px;
--app-radius-input: 4px;
--app-radius-btn: 4px;
}
/* ===== MATERIAL · LIGHT ===== */
html.scheme-light.theme-material {
color-scheme: light;
--app-body-bg: #fafafa;
--app-text: #212121;
--app-text-heading: #111827;
--app-text-muted: #616161;
--app-text-subtle: #757575;
--app-surface: #ffffff;
--app-surface-alt: #ffffff;
--app-surface-hover: #f5f5f5;
--app-surface-inset: #f5f5f5;
--app-border: #e0e0e0;
--app-border-subtle: #eeeeee;
--app-border-muted: #e0e0e0;
--app-input-bg: #ffffff;
--app-input-bg-focus: #ffffff;
--app-input-border: #bdbdbd;
--app-input-text: #212121;
--app-accent: #00838f;
--app-accent-light: #00838f;
--app-accent-gradient: linear-gradient(135deg, #00838f 0%, #006064 100%);
--app-accent-bg: rgba(0, 131, 143, 0.1);
--app-accent-border: rgba(0, 131, 143, 0.25);
--app-accent-focus-ring: rgba(0, 131, 143, 0.2);
--app-btn-primary-text: #ffffff;
--app-btn-secondary-bg: #f5f5f5;
--app-btn-secondary-border: #e0e0e0;
--app-btn-secondary-text: #424242;
--app-btn-secondary-hover-bg: #eeeeee;
--app-icon-btn-bg: #f5f5f5;
--app-icon-btn-border: #e0e0e0;
--app-divider: #e0e0e0;
--app-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
--app-card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--app-error-bg: rgba(244, 63, 94, 0.08);
--app-error-text: #be123c;
--app-error-border: #e11d48;
--app-warning-text: #be123c;
--app-warning-bg: rgba(244, 63, 94, 0.06);
--app-warning-border: rgba(244, 63, 94, 0.2);
--app-empty-border: #e0e0e0;
--app-empty-bg: #fafafa;
--app-sidebar-active-bg: rgba(0, 131, 143, 0.08);
--app-sidebar-active-border: #00838f;
--app-sidebar-active-text: #00838f;
--app-header-border: #e0e0e0;
--app-table-border: #e0e0e0;
--app-progress-bar: linear-gradient(90deg, #00838f, #00adb5, #00838f);
--app-backdrop: none;
--app-radius-card: 4px;
--app-radius-input: 4px;
--app-radius-btn: 4px;
}
/* ===== CUPERTINO · DARK ===== */
html.scheme-dark.theme-cupertino {
color-scheme: dark;
--app-body-bg: #000000;
--app-text: #ffffff;
--app-text-heading: #ffffff;
--app-text-muted: #aeaeb2;
--app-text-subtle: #8e8e93;
--app-surface: rgba(28, 28, 30, 0.72);
--app-surface-alt: rgba(28, 28, 30, 0.72);
--app-surface-hover: rgba(44, 44, 46, 0.85);
--app-surface-inset: rgba(255, 255, 255, 0.05);
--app-border: rgba(255, 255, 255, 0.1);
--app-border-subtle: rgba(255, 255, 255, 0.1);
--app-border-muted: rgba(255, 255, 255, 0.08);
--app-input-bg: rgba(255, 255, 255, 0.05);
--app-input-bg-focus: rgba(255, 255, 255, 0.07);
--app-input-border: rgba(255, 255, 255, 0.12);
--app-input-text: #ffffff;
--app-accent: #0a84ff;
--app-accent-light: #0a84ff;
--app-accent-gradient: linear-gradient(135deg, #0a84ff 0%, #007aff 100%);
--app-accent-bg: rgba(10, 132, 255, 0.12);
--app-accent-border: rgba(10, 132, 255, 0.3);
--app-accent-focus-ring: rgba(10, 132, 255, 0.25);
--app-btn-primary-text: #ffffff;
--app-btn-secondary-bg: rgba(255, 255, 255, 0.08);
--app-btn-secondary-border: rgba(255, 255, 255, 0.12);
--app-btn-secondary-text: #ffffff;
--app-btn-secondary-hover-bg: rgba(255, 255, 255, 0.12);
--app-icon-btn-bg: rgba(255, 255, 255, 0.08);
--app-icon-btn-border: rgba(255, 255, 255, 0.12);
--app-divider: rgba(255, 255, 255, 0.08);
--app-shadow: none;
--app-card-shadow: none;
--app-error-bg: rgba(255, 69, 58, 0.12);
--app-error-text: #ff6961;
--app-error-border: #ff453a;
--app-warning-text: #ff6961;
--app-warning-bg: rgba(255, 69, 58, 0.12);
--app-warning-border: rgba(255, 69, 58, 0.25);
--app-empty-border: rgba(255, 255, 255, 0.1);
--app-empty-bg: rgba(255, 255, 255, 0.04);
--app-sidebar-active-bg: rgba(10, 132, 255, 0.15);
--app-sidebar-active-border: #0a84ff;
--app-sidebar-active-text: #0a84ff;
--app-header-border: rgba(255, 255, 255, 0.1);
--app-table-border: rgba(255, 255, 255, 0.1);
--app-progress-bar: linear-gradient(90deg, #0a84ff, #007aff, #0a84ff);
--app-backdrop: blur(25px);
--app-radius-card: 12px;
--app-radius-input: 8px;
--app-radius-btn: 9999px;
}
/* ===== CUPERTINO · LIGHT ===== */
html.scheme-light.theme-cupertino {
color-scheme: light;
--app-body-bg: #f2f2f7;
--app-text: #1c1c1e;
--app-text-heading: #000000;
--app-text-muted: #636366;
--app-text-subtle: #8e8e93;
--app-surface: rgba(255, 255, 255, 0.82);
--app-surface-alt: rgba(255, 255, 255, 0.82);
--app-surface-hover: rgba(255, 255, 255, 0.95);
--app-surface-inset: rgba(0, 0, 0, 0.03);
--app-border: rgba(0, 0, 0, 0.08);
--app-border-subtle: rgba(0, 0, 0, 0.06);
--app-border-muted: rgba(0, 0, 0, 0.08);
--app-input-bg: #ffffff;
--app-input-bg-focus: #ffffff;
--app-input-border: rgba(0, 0, 0, 0.12);
--app-input-text: #1c1c1e;
--app-accent: #007aff;
--app-accent-light: #007aff;
--app-accent-gradient: linear-gradient(135deg, #007aff 0%, #0a84ff 100%);
--app-accent-bg: rgba(0, 122, 255, 0.1);
--app-accent-border: rgba(0, 122, 255, 0.25);
--app-accent-focus-ring: rgba(0, 122, 255, 0.2);
--app-btn-primary-text: #ffffff;
--app-btn-secondary-bg: rgba(0, 0, 0, 0.05);
--app-btn-secondary-border: rgba(0, 0, 0, 0.08);
--app-btn-secondary-text: #1c1c1e;
--app-btn-secondary-hover-bg: rgba(0, 0, 0, 0.08);
--app-icon-btn-bg: rgba(0, 0, 0, 0.05);
--app-icon-btn-border: rgba(0, 0, 0, 0.08);
--app-divider: rgba(0, 0, 0, 0.08);
--app-shadow: none;
--app-card-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
--app-error-bg: rgba(255, 59, 48, 0.1);
--app-error-text: #d70015;
--app-error-border: #ff3b30;
--app-warning-text: #d70015;
--app-warning-bg: rgba(255, 59, 48, 0.08);
--app-warning-border: rgba(255, 59, 48, 0.2);
--app-empty-border: rgba(0, 0, 0, 0.08);
--app-empty-bg: rgba(0, 0, 0, 0.02);
--app-sidebar-active-bg: rgba(0, 122, 255, 0.1);
--app-sidebar-active-border: #007aff;
--app-sidebar-active-text: #007aff;
--app-header-border: rgba(0, 0, 0, 0.08);
--app-table-border: rgba(0, 0, 0, 0.08);
--app-progress-bar: linear-gradient(90deg, #007aff, #0a84ff, #007aff);
--app-backdrop: blur(25px);
--app-radius-card: 12px;
--app-radius-input: 8px;
--app-radius-btn: 9999px;
}
/* Utility classes for inline-style migration */
.text-muted { color: var(--app-text-muted); }
.text-subtle { color: var(--app-text-subtle); }
.text-heading { color: var(--app-text-heading); }
html.scheme-light #root {
border-inline-color: var(--app-border-subtle);
}
+15
View File
@@ -0,0 +1,15 @@
/** Passkey-Freigabe — gespeichert im E2E-verschlüsselten Eintrag */
export interface PasskeySignature {
kind: 'passkey'
version: 1
role: 'skipper' | 'crew'
userId: string
username: string
credentialId: string
signedAt: string
entryHash: string
clientVerified: boolean
}
/** Legacy: PNG data URL oder getippter Name */
export type SignatureValue = string | PasskeySignature
+46
View File
@@ -0,0 +1,46 @@
const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew'])
function sortEventsByTime(items: unknown[]): unknown[] {
return [...items]
.sort((a, b) => {
const timeA =
typeof a === 'object' && a !== null && 'time' in a
? String((a as Record<string, unknown>).time)
: ''
const timeB =
typeof b === 'object' && b !== null && 'time' in b
? String((b as Record<string, unknown>).time)
: ''
return timeA.localeCompare(timeB)
})
.map((item) => sortValue(item))
}
function sortValue(value: unknown, parentKey?: string): unknown {
if (value === null || typeof value !== 'object') return value
if (Array.isArray(value)) {
if (parentKey === 'events') return sortEventsByTime(value)
return value.map((item) => sortValue(item))
}
const obj = value as Record<string, unknown>
const sorted: Record<string, unknown> = {}
for (const key of Object.keys(obj).sort()) {
if (SIGNATURE_KEYS.has(key)) continue
sorted[key] = sortValue(obj[key], key)
}
return sorted
}
function bufferToBase64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (const b of bytes) binary += String.fromCharCode(b)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/** Stabil sortiertes JSON → SHA-256 → base64url */
export async function hashEntryForSigning(entry: Record<string, unknown>): Promise<string> {
const canonical = JSON.stringify(sortValue(entry))
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical))
return bufferToBase64url(digest)
}
+49
View File
@@ -0,0 +1,49 @@
export interface LogEventPayload {
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
}
export interface LogEntryPayloadInput {
date: string
dayOfTravel: string
departure: string
destination: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
trackDistanceNm?: number
trackSpeedMaxKn?: number
trackSpeedAvgKn?: number
events: LogEventPayload[]
}
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
const payload: Record<string, unknown> = {
date: input.date,
dayOfTravel: input.dayOfTravel.trim(),
departure: input.departure.trim(),
destination: input.destination.trim(),
freshwater: { ...input.freshwater },
fuel: { ...input.fuel },
events: input.events.map((e) => ({ ...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
return payload
}
+50 -2
View File
@@ -1,9 +1,57 @@
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
export function isSignatureImage(value: string | undefined | null): boolean {
return typeof value === 'string' && value.startsWith('data:image/')
}
export function formatSignatureForExport(value: string | undefined | null): string {
export function isPasskeySignature(value: unknown): value is PasskeySignature {
return (
typeof value === 'object' &&
value !== null &&
(value as PasskeySignature).kind === 'passkey' &&
(value as PasskeySignature).version === 1
)
}
export function normalizeSignature(value: unknown): SignatureValue | undefined {
if (value === null || value === undefined || value === '') return undefined
if (isPasskeySignature(value)) return value
if (typeof value === 'string') return value
return undefined
}
export function hasAnySignature(
skipper: SignatureValue | '' | undefined,
crew: SignatureValue | '' | undefined
): boolean {
return !!(skipper || crew)
}
export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: string): boolean {
return sig.entryHash === entryHash
}
export interface SignatureExportLabels {
imagePlaceholder: string
passkeyLabel: (username: string, signedAt: string) => string
}
export function formatSignatureForExport(
value: SignatureValue | undefined | null,
labels: SignatureExportLabels
): string {
if (!value) return ''
if (isSignatureImage(value)) return '[Unterschrift]'
if (isPasskeySignature(value)) {
return labels.passkeyLabel(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()
return trimmed || undefined
}
+1
View File
@@ -1,3 +1,4 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />
declare const __APP_VERSION__: string
+5 -1
View File
@@ -38,8 +38,12 @@ export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
registerType: 'prompt',
includeAssets: ['favicon.ico', 'logo.png'],
workbox: {
cleanupOutdatedCaches: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}']
},
manifest: {
name: 'Kapteins Daagbok',
short_name: 'Daagbok',
+2
View File
@@ -5,6 +5,7 @@ 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 { prisma } from './db.js'
dotenv.config()
@@ -20,6 +21,7 @@ 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)
// Health check endpoint
app.get('/api/health', async (req, res) => {
+44 -1
View File
@@ -69,7 +69,50 @@ router.post('/', async (req: any, res) => {
}
})
// 3. Delete a logbook
// 3. Access metadata for a logbook (owner / collaborator)
router.get('/:id/access', async (req: any, res) => {
try {
const { id } = req.params
const logbook = await prisma.logbook.findUnique({
where: { id },
include: {
collaborators: {
where: { userId: req.userId }
},
_count: {
select: {
collaborators: {
where: { role: 'WRITE' }
}
}
}
}
})
if (!logbook) {
return res.status(404).json({ error: 'Logbook not found' })
}
const isOwner = logbook.userId === req.userId
const collaboration = logbook.collaborators[0]
if (!isOwner && !collaboration) {
return res.status(403).json({ error: 'Forbidden: Access denied' })
}
return res.json({
isOwner,
role: isOwner ? 'OWNER' : collaboration!.role,
writeCollaboratorCount: logbook._count.collaborators
})
} catch (error: any) {
console.error('Error fetching logbook access:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
// 4. Delete a logbook
router.delete('/:id', async (req: any, res) => {
try {
const { id } = req.params
+283
View File
@@ -0,0 +1,283 @@
import { Router } from 'express'
import crypto from 'crypto'
import {
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server'
import { prisma } from '../db.js'
const router = Router()
const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173'
const CHALLENGE_TTL_MS = 5 * 60 * 1000
interface SigningContext {
userId: string
logbookId: string
entryId: string
entryHash: string
role: 'skipper' | 'crew'
expiresAt: number
}
const signingChallenges = new Map<string, SigningContext>()
function pruneExpiredChallenges() {
const now = Date.now()
for (const [key, ctx] of signingChallenges) {
if (ctx.expiresAt <= now) signingChallenges.delete(key)
}
}
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) {
const logbook = await prisma.logbook.findUnique({
where: { id: logbookId },
include: {
collaborators: {
where: { userId }
}
}
})
if (!logbook) return null
const isOwner = logbook.userId === userId
const collaboration = logbook.collaborators[0]
if (!isOwner && !collaboration) return null
return { logbook, isOwner, collaboration }
}
function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: string } | null }) {
// Intentional (HYBRID-ELECTRONIC-SIGNATURES.md §2.1): owner OR WRITE collaborator may sign entries.
return access.isOwner || access.collaboration?.role === 'WRITE'
}
async function getAllowCredentialsForRole(
logbookId: string,
role: 'skipper' | 'crew',
requestingUserId: string
) {
if (role === 'skipper') {
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 collaborations = await prisma.collaboration.findMany({
where: { logbookId, role: 'WRITE' },
select: { userId: true }
})
const userIds = collaborations.map((c) => c.userId)
if (userIds.length === 0) return []
const credentials = await prisma.credential.findMany({
where: { userId: { in: userIds } }
})
return credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key' as const,
transports: cred.transports as any[]
}))
}
async function isAuthorizedSigner(
logbookId: string,
ownerUserId: string,
signerUserId: string,
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'
}
const collaboration = await prisma.collaboration.findUnique({
where: {
logbookId_userId: { logbookId, userId: signerUserId }
}
})
return collaboration?.role === 'WRITE'
}
router.post('/options', async (req: any, res) => {
try {
pruneExpiredChallenges()
const { logbookId, entryId, entryHash, role } = req.body
if (!logbookId || !entryId || !entryHash || !role) {
return res.status(400).json({ error: 'logbookId, entryId, entryHash and role are required' })
}
if (role !== 'skipper' && role !== 'crew') {
return res.status(400).json({ error: 'role must be skipper or crew' })
}
const access = await getLogbookWithAccess(logbookId, req.userId)
if (!access) {
return res.status(403).json({ error: 'Forbidden: Access denied' })
}
if (!hasWriteAccess(access)) {
return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
}
const allowCredentials = await getAllowCredentialsForRole(
logbookId,
role,
req.userId
)
if (allowCredentials.length === 0) {
return res.status(400).json({
error: role === 'crew'
? 'No write collaborators with passkeys found'
: 'No passkey credentials found for signer'
})
}
const nonce = crypto.randomBytes(16).toString('hex')
const challengePayload = `${entryId}:${entryHash}:${role}:${nonce}`
const challengeBytes = crypto
.createHash('sha256')
.update(challengePayload)
.digest()
const options = await generateAuthenticationOptions({
rpID,
challenge: challengeBytes,
allowCredentials,
userVerification: 'required'
})
// Must key by options.challenge — the base64url value returned to the client.
// Passing a string challenge would be UTF-8 re-encoded by simplewebauthn, so the
// client challenge would not match a map key derived from our pre-encoded string.
signingChallenges.set(options.challenge, {
userId: req.userId,
logbookId,
entryId,
entryHash,
role,
expiresAt: Date.now() + CHALLENGE_TTL_MS
})
return res.json(options)
} catch (error: any) {
console.error('Error generating sign options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.post('/verify', async (req: any, res) => {
try {
pruneExpiredChallenges()
const { credentialResponse, challenge, logbookId, entryId, entryHash, role } = req.body
if (!credentialResponse || !challenge || !logbookId || !entryId || !entryHash || !role) {
return res.status(400).json({ error: 'Missing required parameters' })
}
const context = signingChallenges.get(challenge)
if (!context || context.expiresAt <= Date.now()) {
return res.status(400).json({ error: 'Challenge not found or expired' })
}
if (
context.logbookId !== logbookId ||
context.entryId !== entryId ||
context.entryHash !== entryHash ||
context.role !== role
) {
return res.status(400).json({ error: 'Signing context mismatch' })
}
const access = await getLogbookWithAccess(logbookId, req.userId)
if (!access) {
return res.status(403).json({ error: 'Forbidden: Access denied' })
}
if (!hasWriteAccess(access)) {
return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
}
const dbCred = await prisma.credential.findUnique({
where: { credentialId: credentialResponse.id },
include: { user: true }
})
if (!dbCred) {
return res.status(400).json({ error: 'Credential not recognized' })
}
const authorized = await isAuthorizedSigner(
logbookId,
access.logbook.userId,
dbCred.userId,
role
)
if (!authorized) {
return res.status(403).json({ error: 'Forbidden: Signer not authorized for this role' })
}
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: 'Signature verification failed' })
}
signingChallenges.delete(challenge)
await prisma.credential.update({
where: { id: dbCred.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
})
return res.json({
verified: true,
userId: dbCred.user.id,
username: dbCred.user.username,
credentialId: dbCred.credentialId,
signedAt: new Date().toISOString()
})
} catch (error: any) {
console.error('Error verifying signature:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
export default router