Compare commits

...

8 Commits

Author SHA1 Message Date
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
24 changed files with 2052 additions and 185 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.3
+144 -3
View File
@@ -4,12 +4,27 @@ body {
background: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%); background: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
display: flex;
justify-content: center;
align-items: center;
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
} }
#root:has(.auth-screen) {
width: 100%;
max-width: none;
border-inline: none;
text-align: initial;
}
.auth-screen {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 100svh;
padding: 24px 16px calc(48px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
/* Glassmorphism Auth Card */ /* Glassmorphism Auth Card */
.auth-card { .auth-card {
background: rgba(11, 12, 16, 0.75); background: rgba(11, 12, 16, 0.75);
@@ -2162,6 +2177,132 @@ body:has(.theme-cupertino) {
text-transform: uppercase; text-transform: uppercase;
} }
.signature-role-block {
display: flex;
flex-direction: column;
gap: 10px;
}
.signature-hint {
margin: 0;
font-size: 12px;
color: rgba(226, 232, 240, 0.65);
line-height: 1.4;
}
.signature-mode-toggle {
display: inline-flex;
gap: 4px;
padding: 3px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
}
.signature-mode-btn {
border: none;
border-radius: 7px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
color: rgba(226, 232, 240, 0.75);
background: transparent;
}
.signature-mode-btn.active {
color: #0f172a;
background: #e2e8f0;
}
.signature-mode-btn:hover:not(.active) {
color: #f8fafc;
background: rgba(255, 255, 255, 0.06);
}
.signature-lock-notice {
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(212, 175, 55, 0.25);
background: rgba(212, 175, 55, 0.08);
color: rgba(254, 243, 199, 0.95);
font-size: 13px;
line-height: 1.45;
}
.signature-lock-notice.locked {
border-color: rgba(34, 197, 94, 0.3);
background: rgba(34, 197, 94, 0.08);
color: rgba(220, 252, 231, 0.95);
}
.passkey-sign-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.passkey-sign-label {
font-size: 13px;
font-weight: 600;
color: #cbd5e1;
}
.passkey-sign-badge {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(34, 197, 94, 0.35);
background: rgba(34, 197, 94, 0.08);
color: #dcfce7;
}
.passkey-sign-badge.invalid {
border-color: rgba(251, 191, 36, 0.45);
background: rgba(251, 191, 36, 0.08);
color: #fef3c7;
}
.passkey-sign-badge-text {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
}
.passkey-sign-date {
font-size: 11px;
opacity: 0.85;
}
.passkey-sign-invalid-hint {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
width: 100%;
}
.passkey-sign-btn {
align-self: flex-start;
}
.passkey-sign-clear {
align-self: flex-start;
padding: 0;
font-size: 12px;
}
.passkey-sign-error {
margin: 0;
font-size: 12px;
color: #fca5a5;
}
/* PWA install prompt */ /* PWA install prompt */
.pwa-install-banner { .pwa-install-banner {
position: fixed; position: fixed;
+2 -2
View File
@@ -166,7 +166,7 @@ function App() {
if (isAcceptingInvite) { if (isAcceptingInvite) {
return ( return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}> <div className={`theme-${appliedTheme} auth-screen`}>
<InvitationAcceptance <InvitationAcceptance
onAccepted={(logbookId, title) => { onAccepted={(logbookId, title) => {
setIsAuthenticated(true) setIsAuthenticated(true)
@@ -186,7 +186,7 @@ function App() {
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}> <div className={`theme-${appliedTheme} auth-screen`}>
<AuthOnboarding onAuthenticated={handleAuthenticated} /> <AuthOnboarding onAuthenticated={handleAuthenticated} />
</div> </div>
) )
+201 -101
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 { useTranslation } from 'react-i18next'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight } from 'lucide-react' import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
import { getActiveMasterKey, registerUser, loginUser } from '../services/auth.js' import {
getActiveMasterKey,
registerUser,
loginUser,
completeLoginWithRecovery,
getKnownUsernames
} from '../services/auth.js'
import { decryptJson, encryptBuffer } from '../services/crypto.js' import { decryptJson, encryptBuffer } from '../services/crypto.js'
import { saveLogbookKey } from '../services/logbookKeys.js' import { saveLogbookKey } from '../services/logbookKeys.js'
import { syncLogbook } from '../services/sync.js'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { useDialog } from './ModalDialog.tsx'
interface InvitationAcceptanceProps { interface InvitationAcceptanceProps {
onAccepted: (logbookId: string, title: string) => void onAccepted: (logbookId: string, title: string) => void
onCancel: () => void onCancel: () => void
} }
// Convert Hex String back to ArrayBuffer
const hexToBuffer = (hex: string): ArrayBuffer => { const hexToBuffer = (hex: string): ArrayBuffer => {
const bytes = new Uint8Array(hex.length / 2) const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) { for (let i = 0; i < bytes.length; i++) {
@@ -22,65 +27,73 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
} }
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) { export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
const { i18n } = useTranslation() const { t, i18n } = useTranslation()
const { showAlert } = useDialog()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false) const [accepting, setAccepting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Link parameters
const [token, setToken] = useState('') const [token, setToken] = useState('')
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null) const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
// Details loaded from server
const [ownerUsername, setOwnerUsername] = useState('') const [ownerUsername, setOwnerUsername] = useState('')
const [decryptedTitle, setDecryptedTitle] = useState('') const [decryptedTitle, setDecryptedTitle] = useState('')
const [logbookId, setLogbookId] = useState('') const [logbookId, setLogbookId] = useState('')
const [rawEncryptedTitle, setRawEncryptedTitle] = useState('') const [rawEncryptedTitle, setRawEncryptedTitle] = useState('')
// Authentication states
const [isLoggedIn, setIsLoggedIn] = useState(false) const [isLoggedIn, setIsLoggedIn] = useState(false)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [loginMode, setLoginMode] = useState<'options' | 'login' | 'register'>('options') const [loginMode, setLoginMode] = useState<'options' | 'register'>('options')
const [regUsername, setRegUsername] = useState('') const [regUsername, setRegUsername] = useState('')
const [authError, setAuthError] = useState<string | null>(null) 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(() => { useEffect(() => {
const key = getActiveMasterKey() const key = getActiveMasterKey()
const savedUser = localStorage.getItem('active_username') const savedUser = localStorage.getItem('active_username')
if (key && savedUser) { const savedUserId = localStorage.getItem('active_userid')
if (key && savedUser && savedUserId) {
setIsLoggedIn(true) setIsLoggedIn(true)
setUsername(savedUser) setUsername(savedUser)
} }
// Extract parameters from URL
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const tokenVal = params.get('token') || '' const tokenVal = params.get('token') || ''
setToken(tokenVal) setToken(tokenVal)
// Hash anchor (#key=xxx)
const hash = window.location.hash const hash = window.location.hash
if (hash.startsWith('#key=')) { if (hash.startsWith('#key=')) {
const hexKey = hash.substring(5) const hexKey = hash.substring(5)
try { try {
const keyBuffer = hexToBuffer(hexKey) setLogbookKey(hexToBuffer(hexKey))
setLogbookKey(keyBuffer)
} catch (err) { } catch (err) {
console.error('Invalid key in URL fragment:', 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 { } 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) const rand = Math.floor(1000 + Math.random() * 9000)
setRegUsername(`CrewSkipper_${rand}`) setRegUsername(`CrewSkipper_${rand}`)
}, []) }, [isDe])
// Load invitation details once parameters are ready
useEffect(() => { useEffect(() => {
if (token && logbookKey) { if (token && logbookKey) {
loadDetails() loadDetails()
@@ -92,44 +105,50 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setError(null) setError(null)
try { try {
const res = await fetch(`/api/collaboration/invite-details?token=${token}`) const res = await fetch(`/api/collaboration/invite-details?token=${token}`)
if (res.status === 410) { 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 return
} }
if (!res.ok) { 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() const details = await res.json()
setOwnerUsername(details.ownerUsername) setOwnerUsername(details.ownerUsername)
setLogbookId(details.logbookId) setLogbookId(details.logbookId)
setRawEncryptedTitle(details.encryptedTitle) setRawEncryptedTitle(details.encryptedTitle)
// Decrypt title client-side using URL key
const parsed = JSON.parse(details.encryptedTitle) const parsed = JSON.parse(details.encryptedTitle)
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey!) const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey!)
setDecryptedTitle(title) setDecryptedTitle(title)
} catch (err: any) { } catch (err: any) {
console.error('Failed to load invitation details:', err) 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 { } finally {
setLoading(false) setLoading(false)
} }
} }
const handleAccept = async () => { const handleAccept = useCallback(async () => {
const masterKey = getActiveMasterKey() const masterKey = getActiveMasterKey()
const activeUserId = localStorage.getItem('active_userid') const activeUserId = localStorage.getItem('active_userid')
if (!masterKey || !activeUserId || !logbookKey || !logbookId) return if (!masterKey || !activeUserId) {
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) return
setAccepting(true) setAccepting(true)
setError(null) setError(null)
try { try {
// 1. Encrypt logbook key with user's master key
const aesMasterKey = await window.crypto.subtle.importKey( const aesMasterKey = await window.crypto.subtle.importKey(
'raw', 'raw',
masterKey, masterKey,
@@ -139,7 +158,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
) )
const encrypted = await encryptBuffer(logbookKey, aesMasterKey) const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
// 2. Register collaboration on server
const res = await fetch('/api/collaboration/accept', { const res = await fetch('/api/collaboration/accept', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -155,14 +173,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
}) })
if (!res.ok) { if (!res.ok) {
const serverError = await res.json() const serverError = await res.json().catch(() => ({}))
throw new Error(serverError.error || 'Failed to join logbook on the server.') 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) await saveLogbookKey(logbookId, logbookKey)
// 3b. Save logbook index locally in Dexie so sync is triggered immediately
if (rawEncryptedTitle) { if (rawEncryptedTitle) {
await db.logbooks.put({ await db.logbooks.put({
id: logbookId, id: logbookId,
@@ -172,32 +188,72 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
}) })
} }
// 4. Redirect to workspace await syncLogbook(logbookId)
onAccepted(logbookId, decryptedTitle) onAccepted(logbookId, decryptedTitle)
} catch (err: any) { } catch (err: any) {
console.error('Accepting invitation failed:', err) console.error('Accepting invitation failed:', err)
setError(err.message || 'Acceptance failed.') setError(err.message || (isDe ? 'Beitritt fehlgeschlagen.' : 'Acceptance failed.'))
autoAcceptStarted.current = false
} finally { } finally {
setAccepting(false) setAccepting(false)
} }
} }, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted, isDe])
const handleLogin = async (e: React.FormEvent) => { useEffect(() => {
e.preventDefault() if (loading || accepting || autoAcceptStarted.current) return
if (!isLoggedIn || !logbookId || !logbookKey || !token) return
if (!sessionReady()) return
autoAcceptStarted.current = true
void handleAccept()
}, [isLoggedIn, logbookId, logbookKey, token, loading, accepting, handleAccept])
const handleLogin = async () => {
setAuthError(null) setAuthError(null)
setLoading(true) setLoading(true)
try { try {
const result = await loginUser() const remembered = getKnownUsernames()
if (result.verified && result.prfSuccess) { const target = remembered.length === 1 ? remembered[0] : undefined
const result = await loginUser(target)
if (!result.verified) return
if (result.prfSuccess) {
setIsLoggedIn(true) setIsLoggedIn(true)
setUsername(result.username || 'Skipper') setUsername(result.username || 'Skipper')
} else if (result.verified) { return
// 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.')
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
setLoading(true)
setAuthError(null)
try {
const resolvedUser = username.trim() || encryptedPayloads.username
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) { } catch (err: any) {
setAuthError(err.message || 'Passkey authentication failed.') setAuthError(err.message || t('auth.error_decryption_failed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -213,31 +269,92 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
try { try {
const result = await registerUser(regUsername.trim()) const result = await registerUser(regUsername.trim())
if (result.verified) { if (result.verified) {
setIsLoggedIn(true)
setUsername(regUsername.trim()) 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) { } catch (err: any) {
setAuthError(err.message || 'Registration failed.') setAuthError(err.message || (isDe ? 'Registrierung fehlgeschlagen.' : 'Registration failed.'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const toggleLanguage = () => { const handleConfirmRecovery = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de' setRecoveryPhrase(null)
i18n.changeLanguage(nextLang) 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 ( return (
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-header"> <div className="auth-header">
<Ship className="auth-icon accent spin" size={48} /> <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> </div>
<p className="recovery-warning"> <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> </p>
</div> </div>
) )
@@ -248,13 +365,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-header"> <div className="auth-header">
<AlertTriangle className="auth-icon warn" size={48} /> <AlertTriangle className="auth-icon warn" size={48} />
<h2>{i18n.language.startsWith('de') ? 'Einladungsfehler' : 'Invitation Error'}</h2> <h2>{isDe ? 'Einladungsfehler' : 'Invitation Error'}</h2>
</div> </div>
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p> <p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p>
<div className="auth-actions mt-6"> <div className="auth-actions mt-6">
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}> <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> </button>
</div> </div>
</div> </div>
@@ -265,18 +381,18 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-header"> <div className="auth-header">
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} /> <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>
<div style={{ textAlign: 'center', margin: '20px 0', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}> <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' }}> <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>
<p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}> <p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}>
Skipper {ownerUsername} Skipper {ownerUsername}
</p> </p>
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}> <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>
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}> <p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
{decryptedTitle} {decryptedTitle}
@@ -284,53 +400,43 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
</div> </div>
{isLoggedIn ? ( {isLoggedIn ? (
/* If logged in: Accept and Join immediately */
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}> <p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{i18n.language.startsWith('de') {isDe
? `Sie sind angemeldet als ${username}. Möchten Sie diesem Logbuch als Crewmitglied beitreten?` ? `Angemeldet als ${username}. Beitritt wird vorbereitet...`
: `You are logged in as ${username}. Would you like to join this logbook with write permissions?` : `Signed in as ${username}. Preparing to join...`}
}
</p> </p>
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ width: '100%' }}>
<div className="auth-actions mt-4" style={{ display: 'flex', gap: '12px' }}> {accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Erneut beitreten' : 'Join again')}
<button className="btn secondary" onClick={onCancel} disabled={accepting} style={{ flex: 1 }}> <ArrowRight size={16} />
{i18n.language.startsWith('de') ? 'Abbrechen' : 'Cancel'} </button>
</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>
</div> </div>
) : ( ) : (
/* If not logged in: Ask to authenticate or register */
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
{loginMode === 'options' && ( {loginMode === 'options' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}> <p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{i18n.language.startsWith('de') {isDe
? 'Sie müssen ein Passkey-Konto besitzen oder erstellen, um E2E-verschlüsselte Einträge zu schreiben.' ? 'Melden Sie sich an oder registrieren Sie ein Konto, um dem Logbuch beizutreten.'
: 'You must authenticate or register an E2E-secured crew account to write entries.' : 'Sign in or register an account to join this logbook.'}
}
</p> </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} /> <LogIn size={16} />
{i18n.language.startsWith('de') ? 'Mit Passkey anmelden' : 'Log In with Passkey'} {isDe ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
</button> </button>
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}> <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' }}> <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> </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> </div>
<button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}> <button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}>
<UserPlus size={16} /> <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> </button>
</div> </div>
)} )}
@@ -339,41 +445,35 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> <form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="input-group"> <div className="input-group">
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}> <label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>
{i18n.language.startsWith('de') ? 'Skipper- / Benutzername' : 'Skipper / User Name'} {isDe ? 'Benutzername' : 'Username'}
</label> </label>
<input <input
type="text" type="text"
className="input-text" className="input-text"
placeholder="e.g. Max Mustermann"
value={regUsername} value={regUsername}
onChange={(e) => setRegUsername(e.target.value)} onChange={(e) => setRegUsername(e.target.value)}
required required
/> />
</div> </div>
<div className="auth-actions"> <div className="auth-actions">
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}> <button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
{i18n.language.startsWith('de') ? 'Zurück' : 'Back'} {isDe ? 'Zurück' : 'Back'}
</button> </button>
<button type="submit" className="btn primary" disabled={!regUsername.trim()}> <button type="submit" className="btn primary" disabled={!regUsername.trim() || loading}>
{i18n.language.startsWith('de') ? 'Passkey erstellen & beitreten' : 'Create Passkey & Join'} {isDe ? 'Passkey erstellen' : 'Create Passkey'}
</button> </button>
</div> </div>
</form> </form>
)} )}
{authError && ( {authError && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authError}</div>}
<div className="auth-error mt-4" style={{ fontSize: '13px' }}>
{authError}
</div>
)}
</div> </div>
)} )}
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}> <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}> <button className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} /> <Languages size={18} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'} {isDe ? 'English' : 'Deutsch'}
</button> </button>
</div> </div>
</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 { useTranslation } from 'react-i18next'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey } from '../services/auth.js'
@@ -8,10 +8,21 @@ import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react' import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx' import PhotoCapture from './PhotoCapture.tsx'
import SignaturePad from './SignaturePad.tsx' import SignatureSection from './SignatureSection.tsx'
import TrackMap from './TrackMap.tsx' import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.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 { import {
getDecryptedTrack, getDecryptedTrack,
saveUploadedTrack, saveUploadedTrack,
@@ -63,7 +74,7 @@ export default function LogEntryEditor({
preloadedYacht preloadedYacht
}: LogEntryEditorProps) { }: LogEntryEditorProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { showAlert } = useDialog() const { showAlert, showConfirm } = useDialog()
// General details state // General details state
const [date, setDate] = useState('') const [date, setDate] = useState('')
@@ -85,8 +96,12 @@ export default function LogEntryEditor({
const [fuelConsumption, setFuelConsumption] = useState('0') const [fuelConsumption, setFuelConsumption] = useState('0')
// Signatures // Signatures
const [signSkipper, setSignSkipper] = useState('') const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState('') 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) // GPS track stats (from uploaded track)
const [trackDistanceNm, setTrackDistanceNm] = useState('') const [trackDistanceNm, setTrackDistanceNm] = useState('')
@@ -127,6 +142,8 @@ export default function LogEntryEditor({
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null) const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => { const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(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 // Auto-calculate Freshwater Consumption
useEffect(() => { useEffect(() => {
const morning = parseFloat(fwMorning) || 0 const morning = parseFloat(fwMorning) || 0
@@ -197,6 +350,8 @@ export default function LogEntryEditor({
async function loadEntry() { async function loadEntry() {
setLoading(true) setLoading(true)
setError(null) setError(null)
lockedContentHashRef.current = null
contentReadyRef.current = false
try { try {
if (readOnly && preloadedEntry) { if (readOnly && preloadedEntry) {
setDate(preloadedEntry.date || '') setDate(preloadedEntry.date || '')
@@ -208,15 +363,17 @@ export default function LogEntryEditor({
setFwMorning(String(preloadedEntry.freshwater.morning || 0)) setFwMorning(String(preloadedEntry.freshwater.morning || 0))
setFwRefilled(String(preloadedEntry.freshwater.refilled || 0)) setFwRefilled(String(preloadedEntry.freshwater.refilled || 0))
setFwEvening(String(preloadedEntry.freshwater.evening || 0)) setFwEvening(String(preloadedEntry.freshwater.evening || 0))
setFwConsumption(String(preloadedEntry.freshwater.consumption ?? 0))
} }
if (preloadedEntry.fuel) { if (preloadedEntry.fuel) {
setFuelMorning(String(preloadedEntry.fuel.morning || 0)) setFuelMorning(String(preloadedEntry.fuel.morning || 0))
setFuelRefilled(String(preloadedEntry.fuel.refilled || 0)) setFuelRefilled(String(preloadedEntry.fuel.refilled || 0))
setFuelEvening(String(preloadedEntry.fuel.evening || 0)) setFuelEvening(String(preloadedEntry.fuel.evening || 0))
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
} }
setSignSkipper(preloadedEntry.signSkipper || '') setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(preloadedEntry.signCrew || '') setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry) loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || []) setEvents(preloadedEntry.events || [])
return return
@@ -238,15 +395,17 @@ export default function LogEntryEditor({
setFwMorning(String(decrypted.freshwater.morning || 0)) setFwMorning(String(decrypted.freshwater.morning || 0))
setFwRefilled(String(decrypted.freshwater.refilled || 0)) setFwRefilled(String(decrypted.freshwater.refilled || 0))
setFwEvening(String(decrypted.freshwater.evening || 0)) setFwEvening(String(decrypted.freshwater.evening || 0))
setFwConsumption(String(decrypted.freshwater.consumption ?? 0))
} }
if (decrypted.fuel) { if (decrypted.fuel) {
setFuelMorning(String(decrypted.fuel.morning || 0)) setFuelMorning(String(decrypted.fuel.morning || 0))
setFuelRefilled(String(decrypted.fuel.refilled || 0)) setFuelRefilled(String(decrypted.fuel.refilled || 0))
setFuelEvening(String(decrypted.fuel.evening || 0)) setFuelEvening(String(decrypted.fuel.evening || 0))
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
} }
setSignSkipper(decrypted.signSkipper || '') setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(decrypted.signCrew || '') setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted) loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || []) setEvents(decrypted.events || [])
} }
@@ -591,29 +750,11 @@ export default function LogEntryEditor({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.') if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryPayload = buildPayloadForSigning()
const entryData = { const entryData = {
date, ...entryPayload,
dayOfTravel: dayOfTravel.trim(), signSkipper: serializeSignature(signSkipper),
departure: departure.trim(), signCrew: serializeSignature(signCrew)
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
} }
// E2E encrypt // E2E encrypt
@@ -1309,32 +1450,22 @@ export default function LogEntryEditor({
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} /> <PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
{/* Section 4: Sign-Off Signatures */} <SignatureSection
<div className="form-card"> readOnly={readOnly}
<div className="form-header"> disabled={saving}
<Check size={20} className="form-icon" /> isOnline={isOnline}
<h3>{t('logs.signatures')}</h3> canSignSkipper={canSignSkipper}
</div> hasWriteCollaborators={hasWriteCollaborators}
<div className="form-grid signature-grid"> signSkipper={signSkipper}
<SignaturePad signCrew={signCrew}
id="sign-skipper" skipperSignatureValid={skipperSignatureValid}
label={t('logs.sign_skipper')} crewSignatureValid={crewSignatureValid}
value={signSkipper} onSignSkipperChange={setSignSkipper}
onChange={setSignSkipper} onSignCrewChange={setSignCrew}
disabled={saving} onPasskeySignSkipper={handlePasskeySignSkipper}
readOnly={readOnly} onPasskeySignCrew={handlePasskeySignCrew}
/> onBeforeSign={confirmSignWarning}
/>
<SignaturePad
id="sign-crew"
label={t('logs.sign_crew')}
value={signCrew}
onChange={setSignCrew}
disabled={saving}
readOnly={readOnly}
/>
</div>
</div>
{/* Save Controls */} {/* Save Controls */}
{!readOnly && ( {!readOnly && (
+6 -1
View File
@@ -219,7 +219,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div> </div>
</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} /> <Trash2 size={18} />
</button> </button>
</div> </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>
)
}
+10 -2
View File
@@ -10,6 +10,7 @@ interface SignaturePadProps {
onChange: (value: string) => void onChange: (value: string) => void
disabled?: boolean disabled?: boolean
readOnly?: boolean readOnly?: boolean
onBeforeSign?: () => Promise<boolean> | boolean
} }
const STROKE_COLOR = '#0f172a' const STROKE_COLOR = '#0f172a'
@@ -21,7 +22,8 @@ export default function SignaturePad({
value, value,
onChange, onChange,
disabled = false, disabled = false,
readOnly = false readOnly = false,
onBeforeSign
}: SignaturePadProps) { }: SignaturePadProps) {
const { t } = useTranslation() const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@@ -138,9 +140,15 @@ export default function SignaturePad({
onChange(canvas.toDataURL('image/png')) onChange(canvas.toDataURL('image/png'))
} }
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => { const handlePointerDown = async (event: React.PointerEvent<HTMLCanvasElement>) => {
if (readOnly || disabled) return if (readOnly || disabled) return
event.preventDefault() event.preventDefault()
if (!value && !hasInk.current && onBeforeSign) {
const allowed = await onBeforeSign()
if (!allowed) return
}
const point = getPoint(event) const point = getPoint(event)
if (!point) return 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>
)
}
+24 -1
View File
@@ -117,6 +117,28 @@
"sign_crew": "Crew-Unterschrift", "sign_crew": "Crew-Unterschrift",
"sign_hint": "Mit Finger, Stift oder Maus unterschreiben", "sign_hint": "Mit Finger, Stift oder Maus unterschreiben",
"sign_clear": "Löschen", "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!", "no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
"back_to_list": "Zurück zur Journal-Liste", "back_to_list": "Zurück zur Journal-Liste",
"save": "Logbuchseite speichern", "save": "Logbuchseite speichern",
@@ -200,7 +222,8 @@
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!", "no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...", "loading": "Logbücher werden geladen...",
"status_synced": "Synchronisiert", "status_synced": "Synchronisiert",
"status_local": "Nur lokaler Cache" "status_local": "Nur lokaler Cache",
"delete_btn": "Logbuch löschen"
}, },
"crew": { "crew": {
"title": "Skipper- & Crew-Profile", "title": "Skipper- & Crew-Profile",
+24 -1
View File
@@ -117,6 +117,28 @@
"sign_crew": "Crew signature", "sign_crew": "Crew signature",
"sign_hint": "Sign with finger, stylus, or mouse", "sign_hint": "Sign with finger, stylus, or mouse",
"sign_clear": "Clear", "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!", "no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!",
"back_to_list": "Back to Journal List", "back_to_list": "Back to Journal List",
"save": "Save Logbook Page", "save": "Save Logbook Page",
@@ -200,7 +222,8 @@
"no_logbooks": "No logbooks found. Create your first logbook to begin!", "no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...", "loading": "Loading logbooks...",
"status_synced": "Synced", "status_synced": "Synced",
"status_local": "Local Cache Only" "status_local": "Local Cache Only",
"delete_btn": "Delete logbook"
}, },
"crew": { "crew": {
"title": "Skipper & Crew Profiles", "title": "Skipper & Crew Profiles",
+11 -3
View File
@@ -2,7 +2,8 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js' import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.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 { function escapeCsvValue(val: string | number | undefined | null): string {
if (val === null || val === undefined) return ''; if (val === null || val === undefined) return '';
@@ -89,14 +90,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
]; ];
const rows: string[][] = [headers]; 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) { for (const entry of decryptedEntries) {
const dateVal = entry.date || ''; const dateVal = entry.date || '';
const travelDay = entry.dayOfTravel || ''; const travelDay = entry.dayOfTravel || '';
const dep = entry.departure || ''; const dep = entry.departure || '';
const dest = entry.destination || ''; const dest = entry.destination || '';
const signS = formatSignatureForExport(entry.signSkipper); const signS = formatSignatureForExport(normalizeSignature(entry.signSkipper), exportLabels);
const signC = formatSignatureForExport(entry.signCrew); const signC = formatSignatureForExport(normalizeSignature(entry.signCrew), exportLabels);
const trackDist = entry.trackDistanceNm ?? ''; const trackDist = entry.trackDistanceNm ?? '';
const trackMax = entry.trackSpeedMaxKn ?? ''; const trackMax = entry.trackSpeedMaxKn ?? '';
const trackAvg = entry.trackSpeedAvgKn ?? ''; const trackAvg = entry.trackSpeedAvgKn ?? '';
+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
}
}
+23 -6
View File
@@ -10,6 +10,7 @@ export interface DecryptedLogbook {
title: string title: string
updatedAt: string updatedAt: string
isSynced: boolean isSynced: boolean
isShared: boolean
} }
// Helper to decrypt a logbook's title using the active logbook key or master key // Helper to decrypt a logbook's title using the active logbook key or master key
@@ -42,6 +43,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
throw new Error('Master key not found. User must log in.') throw new Error('Master key not found. User must log in.')
} }
const sharedLogbookIds = new Set<string>()
if (navigator.onLine) { if (navigator.onLine) {
try { try {
const response = await fetch(API_BASE, { const response = await fetch(API_BASE, {
@@ -57,9 +60,18 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
// Decrypt and save logbook keys locally if they exist // Decrypt and save logbook keys locally if they exist
for (const lb of serverLogbooks) { for (const lb of serverLogbooks) {
const encryptedKeyStr = lb.encryptedKey || (lb.collaborators && lb.collaborators[0]?.encryptedLogbookKey) const isShared = lb.userId !== userId
const ivStr = lb.iv || (lb.collaborators && lb.collaborators[0]?.iv) if (isShared) sharedLogbookIds.add(lb.id)
const tagStr = lb.tag || (lb.collaborators && lb.collaborators[0]?.tag)
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) { if (encryptedKeyStr && ivStr && tagStr) {
try { try {
@@ -75,6 +87,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
} catch (err) { } catch (err) {
console.error(`Failed to decrypt and save logbook key for logbook ${lb.id}:`, 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 // Clear local cache for any logbooks that are no longer on the server
@@ -113,7 +127,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
id: lb.id, id: lb.id,
title, title,
updatedAt: lb.updatedAt, updatedAt: lb.updatedAt,
isSynced: lb.isSynced === 1 isSynced: lb.isSynced === 1,
isShared: sharedLogbookIds.has(lb.id)
}) })
} }
@@ -187,7 +202,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: serverLb.id, id: serverLb.id,
title, title,
updatedAt: serverLb.updatedAt, updatedAt: serverLb.updatedAt,
isSynced: true isSynced: true,
isShared: false
} }
} }
} catch (error) { } catch (error) {
@@ -216,7 +232,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: localId, id: localId,
title, title,
updatedAt: now, 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 { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.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> { export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = ''; 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.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
doc.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2); 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) doc.addImage(entry.signSkipper, 'PNG', sigX + 2, sigY + 6, 72, 14)
} else { } else {
doc.setFont('Helvetica', 'normal'); doc.setFont('Helvetica', 'normal');
@@ -239,7 +250,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.setFont('Helvetica', 'bold'); doc.setFont('Helvetica', 'bold');
doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2); 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) doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
} else { } else {
doc.setFont('Helvetica', 'normal'); doc.setFont('Helvetica', 'normal');
+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 { export function isSignatureImage(value: string | undefined | null): boolean {
return typeof value === 'string' && value.startsWith('data:image/') 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 (!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 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
}
+2
View File
@@ -5,6 +5,7 @@ import authRouter from './routes/auth.js'
import logbooksRouter from './routes/logbooks.js' import logbooksRouter from './routes/logbooks.js'
import syncRouter from './routes/sync.js' import syncRouter from './routes/sync.js'
import collaborationRouter from './routes/collaboration.js' import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js'
import { prisma } from './db.js' import { prisma } from './db.js'
dotenv.config() dotenv.config()
@@ -20,6 +21,7 @@ app.use('/api/auth', authRouter)
app.use('/api/logbooks', logbooksRouter) app.use('/api/logbooks', logbooksRouter)
app.use('/api/sync', syncRouter) app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter) app.use('/api/collaboration', collaborationRouter)
app.use('/api/sign', signRouter)
// Health check endpoint // Health check endpoint
app.get('/api/health', async (req, res) => { 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) => { router.delete('/:id', async (req: any, res) => {
try { try {
const { id } = req.params const { id } = req.params
+281
View File
@@ -0,0 +1,281 @@
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 }) {
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') {
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