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%);
min-height: 100vh;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
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 */
.auth-card {
background: rgba(11, 12, 16, 0.75);
@@ -2162,6 +2177,132 @@ body:has(.theme-cupertino) {
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-banner {
position: fixed;
+2 -2
View File
@@ -166,7 +166,7 @@ function App() {
if (isAcceptingInvite) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div className={`theme-${appliedTheme} auth-screen`}>
<InvitationAcceptance
onAccepted={(logbookId, title) => {
setIsAuthenticated(true)
@@ -186,7 +186,7 @@ function App() {
if (!isAuthenticated) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<div className={`theme-${appliedTheme} auth-screen`}>
<AuthOnboarding onAuthenticated={handleAuthenticated} />
</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 { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight } from 'lucide-react'
import { getActiveMasterKey, registerUser, loginUser } from '../services/auth.js'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
import {
getActiveMasterKey,
registerUser,
loginUser,
completeLoginWithRecovery,
getKnownUsernames
} from '../services/auth.js'
import { decryptJson, encryptBuffer } from '../services/crypto.js'
import { saveLogbookKey } from '../services/logbookKeys.js'
import { syncLogbook } from '../services/sync.js'
import { db } from '../services/db.js'
import { useDialog } from './ModalDialog.tsx'
interface InvitationAcceptanceProps {
onAccepted: (logbookId: string, title: string) => void
onCancel: () => void
}
// Convert Hex String back to ArrayBuffer
const hexToBuffer = (hex: string): ArrayBuffer => {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) {
@@ -22,65 +27,73 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
}
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
const { i18n } = useTranslation()
const { showAlert } = useDialog()
const { t, i18n } = useTranslation()
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Link parameters
const [token, setToken] = useState('')
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
// Details loaded from server
const [ownerUsername, setOwnerUsername] = useState('')
const [decryptedTitle, setDecryptedTitle] = useState('')
const [logbookId, setLogbookId] = useState('')
const [rawEncryptedTitle, setRawEncryptedTitle] = useState('')
// Authentication states
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [username, setUsername] = useState('')
const [loginMode, setLoginMode] = useState<'options' | 'login' | 'register'>('options')
const [loginMode, setLoginMode] = useState<'options' | 'register'>('options')
const [regUsername, setRegUsername] = useState('')
const [authError, setAuthError] = useState<string | null>(null)
// Check login state on mount
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
const [showRecoveryFallback, setShowRecoveryFallback] = useState(false)
const [recoveryInput, setRecoveryInput] = useState('')
const [encryptedPayloads, setEncryptedPayloads] = useState<any>(null)
const autoAcceptStarted = useRef(false)
const isDe = i18n.language.startsWith('de')
const sessionReady = (): boolean => {
return !!(getActiveMasterKey() && localStorage.getItem('active_userid'))
}
useEffect(() => {
const key = getActiveMasterKey()
const savedUser = localStorage.getItem('active_username')
if (key && savedUser) {
const savedUserId = localStorage.getItem('active_userid')
if (key && savedUser && savedUserId) {
setIsLoggedIn(true)
setUsername(savedUser)
}
// Extract parameters from URL
const params = new URLSearchParams(window.location.search)
const tokenVal = params.get('token') || ''
setToken(tokenVal)
// Hash anchor (#key=xxx)
const hash = window.location.hash
if (hash.startsWith('#key=')) {
const hexKey = hash.substring(5)
try {
const keyBuffer = hexToBuffer(hexKey)
setLogbookKey(keyBuffer)
setLogbookKey(hexToBuffer(hexKey))
} catch (err) {
console.error('Invalid key in URL fragment:', err)
setError('The invitation link is cryptographically invalid or corrupted (missing key).')
setError(isDe
? 'Der Einladungslink ist kryptografisch ungültig (Schlüssel fehlerhaft).'
: 'The invitation link is cryptographically invalid (corrupted key).')
}
} else {
setError('The invitation link is missing the necessary decryption key fragment (#key=...).')
setError(isDe
? 'Der Einladungslink enthält keinen Entschlüsselungsschlüssel (#key=...). Bitte den vollständigen Link vom Eigner verwenden.'
: 'The invitation link is missing the decryption key (#key=...). Please use the complete link from the owner.')
}
// Suggest a random guest skipper username
const rand = Math.floor(1000 + Math.random() * 9000)
setRegUsername(`CrewSkipper_${rand}`)
}, [])
}, [isDe])
// Load invitation details once parameters are ready
useEffect(() => {
if (token && logbookKey) {
loadDetails()
@@ -92,44 +105,50 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setError(null)
try {
const res = await fetch(`/api/collaboration/invite-details?token=${token}`)
if (res.status === 410) {
setError('This invitation link has expired (valid for 48 hours only).')
setError(isDe
? 'Diese Einladung ist abgelaufen (48 Stunden gültig).'
: 'This invitation link has expired (valid for 48 hours only).')
return
}
if (!res.ok) {
throw new Error('Failed to verify invitation token.')
throw new Error(isDe ? 'Einladungstoken ungültig.' : 'Failed to verify invitation token.')
}
const details = await res.json()
setOwnerUsername(details.ownerUsername)
setLogbookId(details.logbookId)
setRawEncryptedTitle(details.encryptedTitle)
// Decrypt title client-side using URL key
const parsed = JSON.parse(details.encryptedTitle)
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey!)
setDecryptedTitle(title)
} catch (err: any) {
console.error('Failed to load invitation details:', err)
setError(err.message || 'Invitation details could not be retrieved from the server.')
setError(err.message || (isDe ? 'Einladungsdetails konnten nicht geladen werden.' : 'Invitation details could not be retrieved.'))
} finally {
setLoading(false)
}
}
const handleAccept = async () => {
const handleAccept = useCallback(async () => {
const masterKey = getActiveMasterKey()
const activeUserId = localStorage.getItem('active_userid')
if (!masterKey || !activeUserId || !logbookKey || !logbookId) return
if (!masterKey || !activeUserId) {
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)
setError(null)
try {
// 1. Encrypt logbook key with user's master key
const aesMasterKey = await window.crypto.subtle.importKey(
'raw',
masterKey,
@@ -139,7 +158,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
)
const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
// 2. Register collaboration on server
const res = await fetch('/api/collaboration/accept', {
method: 'POST',
headers: {
@@ -155,14 +173,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
})
if (!res.ok) {
const serverError = await res.json()
throw new Error(serverError.error || 'Failed to join logbook on the server.')
const serverError = await res.json().catch(() => ({}))
throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.'))
}
// 3. Save key locally in Dexie
await saveLogbookKey(logbookId, logbookKey)
// 3b. Save logbook index locally in Dexie so sync is triggered immediately
if (rawEncryptedTitle) {
await db.logbooks.put({
id: logbookId,
@@ -172,32 +188,72 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
})
}
// 4. Redirect to workspace
await syncLogbook(logbookId)
onAccepted(logbookId, decryptedTitle)
} catch (err: any) {
console.error('Accepting invitation failed:', err)
setError(err.message || 'Acceptance failed.')
setError(err.message || (isDe ? 'Beitritt fehlgeschlagen.' : 'Acceptance failed.'))
autoAcceptStarted.current = false
} finally {
setAccepting(false)
}
}
}, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted, isDe])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
useEffect(() => {
if (loading || accepting || autoAcceptStarted.current) return
if (!isLoggedIn || !logbookId || !logbookKey || !token) return
if (!sessionReady()) return
autoAcceptStarted.current = true
void handleAccept()
}, [isLoggedIn, logbookId, logbookKey, token, loading, accepting, handleAccept])
const handleLogin = async () => {
setAuthError(null)
setLoading(true)
try {
const result = await loginUser()
if (result.verified && result.prfSuccess) {
const remembered = getKnownUsernames()
const target = remembered.length === 1 ? remembered[0] : undefined
const result = await loginUser(target)
if (!result.verified) return
if (result.prfSuccess) {
setIsLoggedIn(true)
setUsername(result.username || 'Skipper')
} else if (result.verified) {
// Biometrics succeeded but fallback phrase is needed
setAuthError('Device doesn\'t support PRF key derivation. Traditional login is not supported in the invitation screen. Please log in normally on the main page first.')
return
}
setEncryptedPayloads(result.encryptedPayloads)
const resolvedUser = result.username || result.encryptedPayloads?.username || ''
if (resolvedUser) setUsername(resolvedUser)
setShowRecoveryFallback(true)
} catch (err: any) {
setAuthError(err.message || (isDe ? 'Passkey-Anmeldung fehlgeschlagen.' : 'Passkey authentication failed.'))
} finally {
setLoading(false)
}
}
const handleRecoverySubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!recoveryInput.trim() || !encryptedPayloads) return
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) {
setAuthError(err.message || 'Passkey authentication failed.')
setAuthError(err.message || t('auth.error_decryption_failed'))
} finally {
setLoading(false)
}
@@ -213,31 +269,92 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
try {
const result = await registerUser(regUsername.trim())
if (result.verified) {
setIsLoggedIn(true)
setUsername(regUsername.trim())
showAlert(`Account created successfully! Your 12-word recovery phrase is: ${result.recoveryPhrase}. Write it down securely!`)
setRecoveryPhrase(result.recoveryPhrase)
}
} catch (err: any) {
setAuthError(err.message || 'Registration failed.')
setAuthError(err.message || (isDe ? 'Registrierung fehlgeschlagen.' : 'Registration failed.'))
} finally {
setLoading(false)
}
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
const handleConfirmRecovery = () => {
setRecoveryPhrase(null)
setIsLoggedIn(true)
}
if (loading && !accepting) {
const toggleLanguage = () => {
i18n.changeLanguage(i18n.language.startsWith('de') ? 'en' : 'de')
}
if (recoveryPhrase) {
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.recovery_title')}</h2>
</div>
<p className="recovery-warning">{t('auth.recovery_warning')}</p>
<div className="recovery-phrase-grid">
{recoveryPhrase.split(' ').map((word, idx) => (
<div key={idx} className="recovery-word">
<span className="word-index">{idx + 1}</span>
{word}
</div>
))}
</div>
<div className="auth-actions mt-6">
<button className="btn primary" onClick={handleConfirmRecovery} style={{ width: '100%' }}>
{t('auth.confirm_recovery')}
</button>
</div>
</div>
)
}
if (showRecoveryFallback) {
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.enter_recovery')}</h2>
</div>
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
<form onSubmit={handleRecoverySubmit}>
<textarea
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
rows={3}
required
/>
<div className="auth-actions mt-4">
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
{isDe ? 'Zurück' : 'Back'}
</button>
<button type="submit" className="btn primary" disabled={loading}>
{t('auth.decrypt_logbook')}
</button>
</div>
</form>
{authError && <div className="auth-error mt-4">{authError}</div>}
</div>
)
}
if ((loading || accepting) && !error) {
return (
<div className="auth-card glass">
<div className="auth-header">
<Ship className="auth-icon accent spin" size={48} />
<h2>{i18n.language.startsWith('de') ? 'Einladung wird geprüft...' : 'Checking Invitation...'}</h2>
<h2>{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Einladung wird geprüft...' : 'Checking Invitation...')}</h2>
</div>
<p className="recovery-warning">
{i18n.language.startsWith('de') ? 'Lade Verschlüsselungsschlüssel und Verifizierungstoken...' : 'Retrieving credentials and secure key components...'}
{accepting
? (isDe ? 'Logbuch wird freigeschaltet und synchronisiert...' : 'Unlocking logbook and syncing data...')
: (isDe ? 'Lade Verschlüsselungsschlüssel...' : 'Retrieving encryption key...')}
</p>
</div>
)
@@ -248,13 +365,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-card glass">
<div className="auth-header">
<AlertTriangle className="auth-icon warn" size={48} />
<h2>{i18n.language.startsWith('de') ? 'Einladungsfehler' : 'Invitation Error'}</h2>
<h2>{isDe ? 'Einladungsfehler' : 'Invitation Error'}</h2>
</div>
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p>
<div className="auth-actions mt-6">
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
{i18n.language.startsWith('de') ? 'Zurück zum Start' : 'Back to Dashboard'}
{isDe ? 'Zurück zum Start' : 'Back to Dashboard'}
</button>
</div>
</div>
@@ -265,18 +381,18 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-card glass">
<div className="auth-header">
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} />
<h2>{i18n.language.startsWith('de') ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2>
<h2>{isDe ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2>
</div>
<div style={{ textAlign: 'center', margin: '20px 0', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}>
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
{i18n.language.startsWith('de') ? 'Einladung von' : 'INVITED BY'}
{isDe ? 'Einladung von' : 'INVITED BY'}
</p>
<p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}>
Skipper {ownerUsername}
</p>
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
{i18n.language.startsWith('de') ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
{isDe ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
</p>
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
{decryptedTitle}
@@ -284,53 +400,43 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
</div>
{isLoggedIn ? (
/* If logged in: Accept and Join immediately */
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{i18n.language.startsWith('de')
? `Sie sind angemeldet als ${username}. Möchten Sie diesem Logbuch als Crewmitglied beitreten?`
: `You are logged in as ${username}. Would you like to join this logbook with write permissions?`
}
{isDe
? `Angemeldet als ${username}. Beitritt wird vorbereitet...`
: `Signed in as ${username}. Preparing to join...`}
</p>
<div className="auth-actions mt-4" style={{ display: 'flex', gap: '12px' }}>
<button className="btn secondary" onClick={onCancel} disabled={accepting} style={{ flex: 1 }}>
{i18n.language.startsWith('de') ? 'Abbrechen' : 'Cancel'}
</button>
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ flex: 2 }}>
{accepting ? (i18n.language.startsWith('de') ? 'Beitritt...' : 'Joining...') : (i18n.language.startsWith('de') ? 'Beitreten' : 'Accept & Join')}
<ArrowRight size={16} />
</button>
</div>
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ width: '100%' }}>
{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Erneut beitreten' : 'Join again')}
<ArrowRight size={16} />
</button>
</div>
) : (
/* If not logged in: Ask to authenticate or register */
<div style={{ width: '100%' }}>
{loginMode === 'options' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{i18n.language.startsWith('de')
? 'Sie müssen ein Passkey-Konto besitzen oder erstellen, um E2E-verschlüsselte Einträge zu schreiben.'
: 'You must authenticate or register an E2E-secured crew account to write entries.'
}
{isDe
? 'Melden Sie sich an oder registrieren Sie ein Konto, um dem Logbuch beizutreten.'
: 'Sign in or register an account to join this logbook.'}
</p>
<button className="btn primary" onClick={handleLogin} style={{ width: '100%', padding: '14px' }}>
<button className="btn primary" onClick={handleLogin} disabled={loading} style={{ width: '100%', padding: '14px' }}>
<LogIn size={16} />
{i18n.language.startsWith('de') ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
{isDe ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
</button>
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
{i18n.language.startsWith('de') ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
{isDe ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
</span>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
</div>
<button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}>
<UserPlus size={16} />
{i18n.language.startsWith('de') ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
{isDe ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
</button>
</div>
)}
@@ -339,41 +445,35 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="input-group">
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>
{i18n.language.startsWith('de') ? 'Skipper- / Benutzername' : 'Skipper / User Name'}
{isDe ? 'Benutzername' : 'Username'}
</label>
<input
type="text"
className="input-text"
placeholder="e.g. Max Mustermann"
value={regUsername}
onChange={(e) => setRegUsername(e.target.value)}
required
/>
</div>
<div className="auth-actions">
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
{i18n.language.startsWith('de') ? 'Zurück' : 'Back'}
{isDe ? 'Zurück' : 'Back'}
</button>
<button type="submit" className="btn primary" disabled={!regUsername.trim()}>
{i18n.language.startsWith('de') ? 'Passkey erstellen & beitreten' : 'Create Passkey & Join'}
<button type="submit" className="btn primary" disabled={!regUsername.trim() || loading}>
{isDe ? 'Passkey erstellen' : 'Create Passkey'}
</button>
</div>
</form>
)}
{authError && (
<div className="auth-error mt-4" style={{ fontSize: '13px' }}>
{authError}
</div>
)}
{authError && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authError}</div>}
</div>
)}
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
<button className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
{isDe ? 'English' : 'Deutsch'}
</button>
</div>
</div>
+189 -58
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
@@ -8,10 +8,21 @@ import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignaturePad from './SignaturePad.tsx'
import SignatureSection from './SignatureSection.tsx'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import { isSignatureImage } from '../utils/signatures.js'
import {
normalizeSignature,
serializeSignature,
isPasskeySignature,
isSignatureValidForEntry,
hasAnySignature
} from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
import {
getDecryptedTrack,
saveUploadedTrack,
@@ -63,7 +74,7 @@ export default function LogEntryEditor({
preloadedYacht
}: LogEntryEditorProps) {
const { t, i18n } = useTranslation()
const { showAlert } = useDialog()
const { showAlert, showConfirm } = useDialog()
// General details state
const [date, setDate] = useState('')
@@ -85,8 +96,12 @@ export default function LogEntryEditor({
const [fuelConsumption, setFuelConsumption] = useState('0')
// Signatures
const [signSkipper, setSignSkipper] = useState('')
const [signCrew, setSignCrew] = useState('')
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
const [canSignSkipper, setCanSignSkipper] = useState(false)
const [hasWriteCollaborators, setHasWriteCollaborators] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [entryHash, setEntryHash] = useState('')
// GPS track stats (from uploaded track)
const [trackDistanceNm, setTrackDistanceNm] = useState('')
@@ -127,6 +142,8 @@ export default function LogEntryEditor({
const [dragOver, setDragOver] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(waypoints)
@@ -149,6 +166,142 @@ export default function LogEntryEditor({
}
}
const buildPayloadForSigning = useCallback(() => {
return buildLogEntryPayload({
date,
dayOfTravel,
departure,
destination,
freshwater: {
morning: parseFloat(fwMorning) || 0,
refilled: parseFloat(fwRefilled) || 0,
evening: parseFloat(fwEvening) || 0,
consumption: parseFloat(fwConsumption) || 0
},
fuel: {
morning: parseFloat(fuelMorning) || 0,
refilled: parseFloat(fuelRefilled) || 0,
evening: parseFloat(fuelEvening) || 0,
consumption: parseFloat(fuelConsumption) || 0
},
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events
})
}, [
date, dayOfTravel, departure, destination,
fwMorning, fwRefilled, fwEvening, fwConsumption,
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn,
events
])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
useEffect(() => {
getLogbookAccess(logbookId).then((access) => {
if (!access) return
setCanSignSkipper(access.isOwner || access.role === 'WRITE')
setHasWriteCollaborators(access.writeCollaboratorCount > 0)
})
}, [logbookId])
useEffect(() => {
let cancelled = false
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
if (!cancelled) setEntryHash(hash)
})
return () => { cancelled = true }
}, [buildPayloadForSigning])
useEffect(() => {
contentReadyRef.current = false
if (loading) return
const timer = window.setTimeout(() => {
contentReadyRef.current = true
}, 0)
return () => window.clearTimeout(timer)
}, [loading])
useEffect(() => {
if (!entryHash || !contentReadyRef.current || readOnly) return
const hasSig = hasAnySignature(signSkipper, signCrew)
if (!hasSig) {
lockedContentHashRef.current = null
return
}
if (!lockedContentHashRef.current) {
lockedContentHashRef.current = entryHash
return
}
if (entryHash !== lockedContentHashRef.current) {
lockedContentHashRef.current = null
setSignSkipper('')
setSignCrew('')
void showAlert(
t('logs.sign_cleared_re_sign'),
t('logs.sign_cleared_re_sign_title')
)
}
}, [entryHash, signSkipper, signCrew, readOnly, showAlert, t])
const confirmSignWarning = useCallback(async (): Promise<boolean> => {
return showConfirm(
t('logs.sign_lock_warning'),
t('logs.sign_lock_warning_title'),
t('logs.sign_proceed'),
t('logs.sign_cancel')
)
}, [showConfirm, t])
const skipperSignatureValid = !isPasskeySignature(signSkipper) || isSignatureValidForEntry(signSkipper, entryHash)
const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash)
const handlePasskeySignSkipper = async () => {
const confirmed = await confirmSignWarning()
if (!confirmed) return
const hash = await hashEntryForSigning(buildPayloadForSigning())
const signature = await signLogEntry({
logbookId,
entryId,
entryHash: hash,
role: 'skipper'
})
setSignSkipper(signature)
setEntryHash(hash)
lockedContentHashRef.current = hash
}
const handlePasskeySignCrew = async () => {
const confirmed = await confirmSignWarning()
if (!confirmed) return
const hash = await hashEntryForSigning(buildPayloadForSigning())
const signature = await signLogEntry({
logbookId,
entryId,
entryHash: hash,
role: 'crew'
})
setSignCrew(signature)
setEntryHash(hash)
lockedContentHashRef.current = hash
}
// Auto-calculate Freshwater Consumption
useEffect(() => {
const morning = parseFloat(fwMorning) || 0
@@ -197,6 +350,8 @@ export default function LogEntryEditor({
async function loadEntry() {
setLoading(true)
setError(null)
lockedContentHashRef.current = null
contentReadyRef.current = false
try {
if (readOnly && preloadedEntry) {
setDate(preloadedEntry.date || '')
@@ -208,15 +363,17 @@ export default function LogEntryEditor({
setFwMorning(String(preloadedEntry.freshwater.morning || 0))
setFwRefilled(String(preloadedEntry.freshwater.refilled || 0))
setFwEvening(String(preloadedEntry.freshwater.evening || 0))
setFwConsumption(String(preloadedEntry.freshwater.consumption ?? 0))
}
if (preloadedEntry.fuel) {
setFuelMorning(String(preloadedEntry.fuel.morning || 0))
setFuelRefilled(String(preloadedEntry.fuel.refilled || 0))
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
}
setSignSkipper(preloadedEntry.signSkipper || '')
setSignCrew(preloadedEntry.signCrew || '')
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || [])
return
@@ -238,15 +395,17 @@ export default function LogEntryEditor({
setFwMorning(String(decrypted.freshwater.morning || 0))
setFwRefilled(String(decrypted.freshwater.refilled || 0))
setFwEvening(String(decrypted.freshwater.evening || 0))
setFwConsumption(String(decrypted.freshwater.consumption ?? 0))
}
if (decrypted.fuel) {
setFuelMorning(String(decrypted.fuel.morning || 0))
setFuelRefilled(String(decrypted.fuel.refilled || 0))
setFuelEvening(String(decrypted.fuel.evening || 0))
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
}
setSignSkipper(decrypted.signSkipper || '')
setSignCrew(decrypted.signCrew || '')
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || [])
}
@@ -591,29 +750,11 @@ export default function LogEntryEditor({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryPayload = buildPayloadForSigning()
const entryData = {
date,
dayOfTravel: dayOfTravel.trim(),
departure: departure.trim(),
destination: destination.trim(),
freshwater: {
morning: parseFloat(fwMorning) || 0,
refilled: parseFloat(fwRefilled) || 0,
evening: parseFloat(fwEvening) || 0,
consumption: parseFloat(fwConsumption) || 0
},
fuel: {
morning: parseFloat(fuelMorning) || 0,
refilled: parseFloat(fuelRefilled) || 0,
evening: parseFloat(fuelEvening) || 0,
consumption: parseFloat(fuelConsumption) || 0
},
signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(),
signCrew: isSignatureImage(signCrew) ? signCrew : signCrew.trim(),
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events
...entryPayload,
signSkipper: serializeSignature(signSkipper),
signCrew: serializeSignature(signCrew)
}
// E2E encrypt
@@ -1309,32 +1450,22 @@ export default function LogEntryEditor({
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
{/* Section 4: Sign-Off Signatures */}
<div className="form-card">
<div className="form-header">
<Check size={20} className="form-icon" />
<h3>{t('logs.signatures')}</h3>
</div>
<div className="form-grid signature-grid">
<SignaturePad
id="sign-skipper"
label={t('logs.sign_skipper')}
value={signSkipper}
onChange={setSignSkipper}
disabled={saving}
readOnly={readOnly}
/>
<SignaturePad
id="sign-crew"
label={t('logs.sign_crew')}
value={signCrew}
onChange={setSignCrew}
disabled={saving}
readOnly={readOnly}
/>
</div>
</div>
<SignatureSection
readOnly={readOnly}
disabled={saving}
isOnline={isOnline}
canSignSkipper={canSignSkipper}
hasWriteCollaborators={hasWriteCollaborators}
signSkipper={signSkipper}
signCrew={signCrew}
skipperSignatureValid={skipperSignatureValid}
crewSignatureValid={crewSignatureValid}
onSignSkipperChange={setSignSkipper}
onSignCrewChange={setSignCrew}
onPasskeySignSkipper={handlePasskeySignSkipper}
onPasskeySignCrew={handlePasskeySignCrew}
onBeforeSign={confirmSignWarning}
/>
{/* Save Controls */}
{!readOnly && (
+6 -1
View File
@@ -219,7 +219,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div>
</div>
<button className="btn-delete" onClick={(e) => handleDelete(lb.id, e)} title="Delete Logbook">
<button
className="btn-delete"
onClick={(e) => handleDelete(lb.id, e)}
title={t('dashboard.delete_btn')}
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
>
<Trash2 size={18} />
</button>
</div>
@@ -0,0 +1,90 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Fingerprint, Loader2, AlertTriangle } from 'lucide-react'
import type { PasskeySignature } from '../types/signatures.js'
interface PasskeySignButtonProps {
label: string
signature?: PasskeySignature
signatureValid?: boolean
disabled?: boolean
canSign: boolean
onSign: () => Promise<void>
onClear?: () => void
}
export default function PasskeySignButton({
label,
signature,
signatureValid = true,
disabled = false,
canSign,
onSign,
onClear
}: PasskeySignButtonProps) {
const { t, i18n } = useTranslation()
const [signing, setSigning] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSign = async () => {
setSigning(true)
setError(null)
try {
await onSign()
} catch (err: any) {
if (err?.name === 'NotAllowedError') {
setError(t('logs.sign_passkey_cancelled'))
} else {
setError(err?.message || t('logs.sign_passkey_failed'))
}
} finally {
setSigning(false)
}
}
const formattedDate = signature
? new Date(signature.signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
: ''
return (
<div className="passkey-sign-block">
<div className="passkey-sign-label">{label}</div>
{signature ? (
<div className={`passkey-sign-badge ${signatureValid ? 'valid' : 'invalid'}`}>
<Fingerprint size={16} />
<div className="passkey-sign-badge-text">
<span>{t('logs.sign_passkey_signed', { username: signature.username })}</span>
<span className="passkey-sign-date">{formattedDate}</span>
</div>
{!signatureValid && (
<span className="passkey-sign-invalid-hint">
<AlertTriangle size={14} />
{t('logs.sign_invalid')}
</span>
)}
</div>
) : null}
{canSign && !disabled && (
<button
type="button"
className="btn secondary passkey-sign-btn"
onClick={handleSign}
disabled={signing}
>
{signing ? <Loader2 size={16} className="spin" /> : <Fingerprint size={16} />}
{signing ? t('logs.sign_passkey_signing') : t('logs.sign_with_passkey')}
</button>
)}
{signature && onClear && !disabled && (
<button type="button" className="btn text-btn passkey-sign-clear" onClick={onClear}>
{t('logs.sign_passkey_clear')}
</button>
)}
{error && <p className="passkey-sign-error">{error}</p>}
</div>
)
}
+10 -2
View File
@@ -10,6 +10,7 @@ interface SignaturePadProps {
onChange: (value: string) => void
disabled?: boolean
readOnly?: boolean
onBeforeSign?: () => Promise<boolean> | boolean
}
const STROKE_COLOR = '#0f172a'
@@ -21,7 +22,8 @@ export default function SignaturePad({
value,
onChange,
disabled = false,
readOnly = false
readOnly = false,
onBeforeSign
}: SignaturePadProps) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
@@ -138,9 +140,15 @@ export default function SignaturePad({
onChange(canvas.toDataURL('image/png'))
}
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
const handlePointerDown = async (event: React.PointerEvent<HTMLCanvasElement>) => {
if (readOnly || disabled) return
event.preventDefault()
if (!value && !hasInk.current && onBeforeSign) {
const allowed = await onBeforeSign()
if (!allowed) return
}
const point = getPoint(event)
if (!point) return
+258
View File
@@ -0,0 +1,258 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check } from 'lucide-react'
import SignaturePad from './SignaturePad.tsx'
import PasskeySignButton from './PasskeySignButton.tsx'
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
import { isPasskeySignature } from '../utils/signatures.js'
type SignatureMode = 'passkey' | 'classic'
interface SignatureSectionProps {
readOnly?: boolean
disabled?: boolean
isOnline: boolean
canSignSkipper: boolean
hasWriteCollaborators: boolean
signSkipper: SignatureValue | ''
signCrew: SignatureValue | ''
skipperSignatureValid: boolean
crewSignatureValid: boolean
onSignSkipperChange: (value: SignatureValue | '') => void
onSignCrewChange: (value: SignatureValue | '') => void
onPasskeySignSkipper: () => Promise<void>
onPasskeySignCrew: () => Promise<void>
onBeforeSign?: () => Promise<boolean>
}
function padValue(value: SignatureValue | ''): string {
if (!value || isPasskeySignature(value)) return ''
return value
}
function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode {
if (isPasskeySignature(value)) return 'passkey'
if (value) return 'classic'
return passkeyAvailable ? 'passkey' : 'classic'
}
interface RoleSignatureBlockProps {
roleLabel: string
passkeyLabel: string
padId: string
value: SignatureValue | ''
passkeySignature?: PasskeySignature
signatureValid: boolean
showPasskey: boolean
readOnly: boolean
disabled: boolean
classicHint?: string
offlineHint?: string
onChange: (value: SignatureValue | '') => void
onPasskeySign: () => Promise<void>
onBeforeSign?: () => Promise<boolean>
}
function RoleSignatureBlock({
roleLabel,
passkeyLabel,
padId,
value,
passkeySignature,
signatureValid,
showPasskey,
readOnly,
disabled,
classicHint,
offlineHint,
onChange,
onPasskeySign,
onBeforeSign
}: RoleSignatureBlockProps) {
const { t } = useTranslation()
const [mode, setMode] = useState<SignatureMode>(() => modeFromValue(value, showPasskey))
useEffect(() => {
setMode(modeFromValue(value, showPasskey))
}, [value, showPasskey])
const switchToClassic = () => {
setMode('classic')
if (isPasskeySignature(value)) onChange('')
}
const switchToPasskey = () => {
setMode('passkey')
if (value && !isPasskeySignature(value)) onChange('')
}
const handlePadChange = (next: string) => {
setMode('classic')
onChange(next)
}
if (readOnly) {
if (isPasskeySignature(value)) {
return (
<div className="signature-role-block">
<PasskeySignButton
label={passkeyLabel}
signature={value}
signatureValid={signatureValid}
disabled={disabled}
canSign={false}
onSign={onPasskeySign}
/>
</div>
)
}
return (
<div className="signature-role-block">
<SignaturePad
id={padId}
label={roleLabel}
value={padValue(value)}
onChange={() => {}}
disabled={disabled}
readOnly
/>
</div>
)
}
const showPasskeyPanel = showPasskey && mode === 'passkey'
const showClassicPanel = !showPasskey || mode === 'classic'
return (
<div className="signature-role-block">
{showPasskey && (
<div className="signature-mode-toggle" role="tablist" aria-label={passkeyLabel}>
<button
type="button"
role="tab"
aria-selected={mode === 'passkey'}
className={`signature-mode-btn ${mode === 'passkey' ? 'active' : ''}`}
onClick={switchToPasskey}
>
{t('logs.sign_mode_passkey')}
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'classic'}
className={`signature-mode-btn ${mode === 'classic' ? 'active' : ''}`}
onClick={switchToClassic}
>
{t('logs.sign_mode_classic')}
</button>
</div>
)}
{showPasskeyPanel && (
<PasskeySignButton
label={passkeyLabel}
signature={passkeySignature}
signatureValid={signatureValid}
disabled={disabled}
canSign
onSign={onPasskeySign}
onClear={passkeySignature ? switchToClassic : undefined}
/>
)}
{showClassicPanel && (
<>
<SignaturePad
id={padId}
label={roleLabel}
value={padValue(value)}
onChange={handlePadChange}
disabled={disabled}
readOnly={false}
onBeforeSign={onBeforeSign}
/>
{classicHint && !passkeySignature && (
<p className="signature-hint">{classicHint}</p>
)}
</>
)}
{offlineHint && !showPasskey && (
<p className="signature-hint">{offlineHint}</p>
)}
</div>
)
}
export default function SignatureSection({
readOnly = false,
disabled = false,
isOnline,
canSignSkipper,
hasWriteCollaborators,
signSkipper,
signCrew,
skipperSignatureValid,
crewSignatureValid,
onSignSkipperChange,
onSignCrewChange,
onPasskeySignSkipper,
onPasskeySignCrew,
onBeforeSign
}: SignatureSectionProps) {
const { t } = useTranslation()
const showSkipperPasskey = canSignSkipper && isOnline
const showCrewPasskey = hasWriteCollaborators && isOnline
const hasSignature = !!(signSkipper || signCrew)
return (
<div className="form-card">
<div className="form-header">
<Check size={20} className="form-icon" />
<h3>{t('logs.signatures')}</h3>
</div>
{!readOnly && (
<p className={`signature-lock-notice ${hasSignature ? 'locked' : ''}`}>
{hasSignature ? t('logs.sign_lock_active') : t('logs.sign_lock_notice')}
</p>
)}
<div className="form-grid signature-grid">
<RoleSignatureBlock
roleLabel={t('logs.sign_skipper')}
passkeyLabel={t('logs.sign_skipper')}
padId="sign-skipper"
value={signSkipper}
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
signatureValid={skipperSignatureValid}
showPasskey={showSkipperPasskey}
readOnly={readOnly}
disabled={disabled}
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined}
onChange={onSignSkipperChange}
onPasskeySign={onPasskeySignSkipper}
onBeforeSign={onBeforeSign}
/>
<RoleSignatureBlock
roleLabel={t('logs.sign_crew')}
passkeyLabel={t('logs.sign_crew')}
padId="sign-crew"
value={signCrew}
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
signatureValid={crewSignatureValid}
showPasskey={showCrewPasskey}
readOnly={readOnly}
disabled={disabled}
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
onChange={onSignCrewChange}
onPasskeySign={onPasskeySignCrew}
onBeforeSign={onBeforeSign}
/>
</div>
</div>
)
}
+24 -1
View File
@@ -117,6 +117,28 @@
"sign_crew": "Crew-Unterschrift",
"sign_hint": "Mit Finger, Stift oder Maus unterschreiben",
"sign_clear": "Löschen",
"sign_export_image": "[Unterschrift]",
"sign_with_passkey": "Mit Passkey freigeben",
"sign_passkey_signing": "Passkey wird angefordert…",
"sign_passkey_signed": "Freigegeben von {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_passkey_clear": "Passkey-Freigabe entfernen",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Klassisch",
"sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen",
"sign_passkey_cancelled": "Passkey-Freigabe abgebrochen",
"sign_invalid": "Signatur ungültig — Inhalt wurde geändert",
"sign_classic_or_passkey": "Optional: klassisch unterschreiben oder Passkey-Freigabe oben",
"sign_crew_passkey_hint": "Crew-Mitglieder mit Schreibzugriff können per Passkey freigeben",
"sign_offline_hint": "Passkey-Freigabe erfordert Internet — klassische Unterschrift offline möglich",
"sign_lock_notice": "Nach der Unterschrift sind Änderungen am Logbucheintrag (außer Fotos) nicht möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.",
"sign_lock_active": "Dieser Eintrag ist unterschrieben. Änderungen am Logbuch (außer Fotos) entfernen Skipper- und Crew-Unterschrift automatisch.",
"sign_lock_warning_title": "Unterschrift bestätigen",
"sign_lock_warning": "Nach dem Unterschreiben sind Änderungen am Logbucheintrag (außer Fotos) nicht mehr möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.\n\nMöchten Sie fortfahren?",
"sign_proceed": "Unterschreiben",
"sign_cancel": "Abbrechen",
"sign_cleared_re_sign_title": "Unterschriften entfernt",
"sign_cleared_re_sign": "Der Logbucheintrag wurde geändert. Skipper- und Crew-Unterschrift wurden entfernt. Bitte erneut unterschreiben.",
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
"back_to_list": "Zurück zur Journal-Liste",
"save": "Logbuchseite speichern",
@@ -200,7 +222,8 @@
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...",
"status_synced": "Synchronisiert",
"status_local": "Nur lokaler Cache"
"status_local": "Nur lokaler Cache",
"delete_btn": "Logbuch löschen"
},
"crew": {
"title": "Skipper- & Crew-Profile",
+24 -1
View File
@@ -117,6 +117,28 @@
"sign_crew": "Crew signature",
"sign_hint": "Sign with finger, stylus, or mouse",
"sign_clear": "Clear",
"sign_export_image": "[Signature]",
"sign_with_passkey": "Sign with Passkey",
"sign_passkey_signing": "Requesting Passkey…",
"sign_passkey_signed": "Signed by {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_passkey_clear": "Remove Passkey signature",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Classic",
"sign_passkey_failed": "Passkey signing failed",
"sign_passkey_cancelled": "Passkey signing cancelled",
"sign_invalid": "Signature invalid — entry content changed",
"sign_classic_or_passkey": "Optional: sign classically below or use Passkey above",
"sign_crew_passkey_hint": "Write collaborators can sign with their Passkey",
"sign_offline_hint": "Passkey signing requires internet — classic signature works offline",
"sign_lock_notice": "After signing, log entry changes (except photos) require Skipper and Crew to sign again.",
"sign_lock_active": "This entry is signed. Changes to the log (except photos) will automatically remove Skipper and Crew signatures.",
"sign_lock_warning_title": "Confirm signature",
"sign_lock_warning": "After signing, changes to the log entry (except photos) are not possible without Skipper and Crew signing again.\n\nDo you want to proceed?",
"sign_proceed": "Sign",
"sign_cancel": "Cancel",
"sign_cleared_re_sign_title": "Signatures removed",
"sign_cleared_re_sign": "The log entry was changed. Skipper and Crew signatures were removed. Please sign again.",
"no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!",
"back_to_list": "Back to Journal List",
"save": "Save Logbook Page",
@@ -200,7 +222,8 @@
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...",
"status_synced": "Synced",
"status_local": "Local Cache Only"
"status_local": "Local Cache Only",
"delete_btn": "Delete logbook"
},
"crew": {
"title": "Skipper & Crew Profiles",
+11 -3
View File
@@ -2,7 +2,8 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { formatSignatureForExport } from '../utils/signatures.js'
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
import i18n from '../i18n/index.js'
function escapeCsvValue(val: string | number | undefined | null): string {
if (val === null || val === undefined) return '';
@@ -89,14 +90,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
];
const rows: string[][] = [headers];
const exportLabels = {
imagePlaceholder: i18n.t('logs.sign_export_image'),
passkeyLabel: (username: string, signedAt: string) => {
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
return i18n.t('logs.sign_passkey_export', { username, date })
}
};
for (const entry of decryptedEntries) {
const dateVal = entry.date || '';
const travelDay = entry.dayOfTravel || '';
const dep = entry.departure || '';
const dest = entry.destination || '';
const signS = formatSignatureForExport(entry.signSkipper);
const signC = formatSignatureForExport(entry.signCrew);
const signS = formatSignatureForExport(normalizeSignature(entry.signSkipper), exportLabels);
const signC = formatSignatureForExport(normalizeSignature(entry.signCrew), exportLabels);
const trackDist = entry.trackDistanceNm ?? '';
const trackMax = entry.trackSpeedMaxKn ?? '';
const trackAvg = entry.trackSpeedAvgKn ?? '';
+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
updatedAt: string
isSynced: boolean
isShared: boolean
}
// 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.')
}
const sharedLogbookIds = new Set<string>()
if (navigator.onLine) {
try {
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
for (const lb of serverLogbooks) {
const encryptedKeyStr = lb.encryptedKey || (lb.collaborators && lb.collaborators[0]?.encryptedLogbookKey)
const ivStr = lb.iv || (lb.collaborators && lb.collaborators[0]?.iv)
const tagStr = lb.tag || (lb.collaborators && lb.collaborators[0]?.tag)
const isShared = lb.userId !== userId
if (isShared) sharedLogbookIds.add(lb.id)
const encryptedKeyStr = isShared
? lb.collaborators?.[0]?.encryptedLogbookKey
: (lb.encryptedKey || lb.collaborators?.[0]?.encryptedLogbookKey)
const ivStr = isShared
? lb.collaborators?.[0]?.iv
: (lb.iv || lb.collaborators?.[0]?.iv)
const tagStr = isShared
? lb.collaborators?.[0]?.tag
: (lb.tag || lb.collaborators?.[0]?.tag)
if (encryptedKeyStr && ivStr && tagStr) {
try {
@@ -75,6 +87,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
} catch (err) {
console.error(`Failed to decrypt and save logbook key for logbook ${lb.id}:`, err)
}
} else if (isShared) {
console.warn(`Shared logbook ${lb.id} is missing collaboration key on server`)
}
}
// Clear local cache for any logbooks that are no longer on the server
@@ -113,7 +127,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
id: lb.id,
title,
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,
title,
updatedAt: serverLb.updatedAt,
isSynced: true
isSynced: true,
isShared: false
}
}
} catch (error) {
@@ -216,7 +232,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
id: localId,
title,
updatedAt: now,
isSynced: false
isSynced: false,
isShared: false
}
}
+20
View File
@@ -0,0 +1,20 @@
export interface LogbookAccess {
isOwner: boolean
role: 'OWNER' | 'READ' | 'WRITE'
writeCollaboratorCount: number
}
export async function getLogbookAccess(logbookId: string): Promise<LogbookAccess | null> {
const userId = localStorage.getItem('active_userid')
if (!userId || !navigator.onLine) return null
try {
const res = await fetch(`/api/logbooks/${logbookId}/access`, {
headers: { 'X-User-Id': userId }
})
if (!res.ok) return null
return res.json()
} catch {
return null
}
}
+19 -3
View File
@@ -3,7 +3,13 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { isSignatureImage } from '../utils/signatures.js'
import { isSignatureImage, isPasskeySignature } from '../utils/signatures.js'
import i18n from '../i18n/index.js'
function formatPasskeySignDate(signedAt: string): string {
const locale = i18n.language === 'de' ? 'de-DE' : 'en-GB'
return new Date(signedAt).toLocaleString(locale)
}
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
@@ -230,7 +236,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
doc.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2);
if (isSignatureImage(entry.signSkipper)) {
if (isPasskeySignature(entry.signSkipper)) {
doc.setFont('Helvetica', 'normal');
const skipperDate = formatPasskeySignDate(entry.signSkipper.signedAt);
doc.text(`Passkey: ${entry.signSkipper.username}`, sigX + 2, sigY + 9);
doc.text(skipperDate, sigX + 2, sigY + 13.5);
} else if (isSignatureImage(entry.signSkipper)) {
doc.addImage(entry.signSkipper, 'PNG', sigX + 2, sigY + 6, 72, 14)
} else {
doc.setFont('Helvetica', 'normal');
@@ -239,7 +250,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.setFont('Helvetica', 'bold');
doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2);
if (isSignatureImage(entry.signCrew)) {
if (isPasskeySignature(entry.signCrew)) {
doc.setFont('Helvetica', 'normal');
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9);
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
} else if (isSignatureImage(entry.signCrew)) {
doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
} else {
doc.setFont('Helvetica', 'normal');
+15
View File
@@ -0,0 +1,15 @@
/** Passkey-Freigabe — gespeichert im E2E-verschlüsselten Eintrag */
export interface PasskeySignature {
kind: 'passkey'
version: 1
role: 'skipper' | 'crew'
userId: string
username: string
credentialId: string
signedAt: string
entryHash: string
clientVerified: boolean
}
/** Legacy: PNG data URL oder getippter Name */
export type SignatureValue = string | PasskeySignature
+46
View File
@@ -0,0 +1,46 @@
const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew'])
function sortEventsByTime(items: unknown[]): unknown[] {
return [...items]
.sort((a, b) => {
const timeA =
typeof a === 'object' && a !== null && 'time' in a
? String((a as Record<string, unknown>).time)
: ''
const timeB =
typeof b === 'object' && b !== null && 'time' in b
? String((b as Record<string, unknown>).time)
: ''
return timeA.localeCompare(timeB)
})
.map((item) => sortValue(item))
}
function sortValue(value: unknown, parentKey?: string): unknown {
if (value === null || typeof value !== 'object') return value
if (Array.isArray(value)) {
if (parentKey === 'events') return sortEventsByTime(value)
return value.map((item) => sortValue(item))
}
const obj = value as Record<string, unknown>
const sorted: Record<string, unknown> = {}
for (const key of Object.keys(obj).sort()) {
if (SIGNATURE_KEYS.has(key)) continue
sorted[key] = sortValue(obj[key], key)
}
return sorted
}
function bufferToBase64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (const b of bytes) binary += String.fromCharCode(b)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/** Stabil sortiertes JSON → SHA-256 → base64url */
export async function hashEntryForSigning(entry: Record<string, unknown>): Promise<string> {
const canonical = JSON.stringify(sortValue(entry))
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical))
return bufferToBase64url(digest)
}
+49
View File
@@ -0,0 +1,49 @@
export interface LogEventPayload {
time: string
mgk: string
rwk: string
windPressure: string
windDirection: string
windStrength: string
seaState: string
weatherIcon: string
current: string
heel: string
sailsOrMotor: string
logReading: string
distance: string
gpsLat: string
gpsLng: string
remarks: string
}
export interface LogEntryPayloadInput {
date: string
dayOfTravel: string
departure: string
destination: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
trackDistanceNm?: number
trackSpeedMaxKn?: number
trackSpeedAvgKn?: number
events: LogEventPayload[]
}
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
const payload: Record<string, unknown> = {
date: input.date,
dayOfTravel: input.dayOfTravel.trim(),
departure: input.departure.trim(),
destination: input.destination.trim(),
freshwater: { ...input.freshwater },
fuel: { ...input.fuel },
events: input.events.map((e) => ({ ...e }))
}
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
return payload
}
+50 -2
View File
@@ -1,9 +1,57 @@
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
export function isSignatureImage(value: string | undefined | null): boolean {
return typeof value === 'string' && value.startsWith('data:image/')
}
export function formatSignatureForExport(value: string | undefined | null): string {
export function isPasskeySignature(value: unknown): value is PasskeySignature {
return (
typeof value === 'object' &&
value !== null &&
(value as PasskeySignature).kind === 'passkey' &&
(value as PasskeySignature).version === 1
)
}
export function normalizeSignature(value: unknown): SignatureValue | undefined {
if (value === null || value === undefined || value === '') return undefined
if (isPasskeySignature(value)) return value
if (typeof value === 'string') return value
return undefined
}
export function hasAnySignature(
skipper: SignatureValue | '' | undefined,
crew: SignatureValue | '' | undefined
): boolean {
return !!(skipper || crew)
}
export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: string): boolean {
return sig.entryHash === entryHash
}
export interface SignatureExportLabels {
imagePlaceholder: string
passkeyLabel: (username: string, signedAt: string) => string
}
export function formatSignatureForExport(
value: SignatureValue | undefined | null,
labels: SignatureExportLabels
): string {
if (!value) return ''
if (isSignatureImage(value)) return '[Unterschrift]'
if (isPasskeySignature(value)) {
return labels.passkeyLabel(value.username, value.signedAt)
}
if (isSignatureImage(value)) return labels.imagePlaceholder
return value
}
export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined {
if (!value) return undefined
if (isPasskeySignature(value)) return value
if (isSignatureImage(value)) return value
const trimmed = value.trim()
return trimmed || undefined
}
+2
View File
@@ -5,6 +5,7 @@ import authRouter from './routes/auth.js'
import logbooksRouter from './routes/logbooks.js'
import syncRouter from './routes/sync.js'
import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js'
import { prisma } from './db.js'
dotenv.config()
@@ -20,6 +21,7 @@ app.use('/api/auth', authRouter)
app.use('/api/logbooks', logbooksRouter)
app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter)
app.use('/api/sign', signRouter)
// Health check endpoint
app.get('/api/health', async (req, res) => {
+44 -1
View File
@@ -69,7 +69,50 @@ router.post('/', async (req: any, res) => {
}
})
// 3. Delete a logbook
// 3. Access metadata for a logbook (owner / collaborator)
router.get('/:id/access', async (req: any, res) => {
try {
const { id } = req.params
const logbook = await prisma.logbook.findUnique({
where: { id },
include: {
collaborators: {
where: { userId: req.userId }
},
_count: {
select: {
collaborators: {
where: { role: 'WRITE' }
}
}
}
}
})
if (!logbook) {
return res.status(404).json({ error: 'Logbook not found' })
}
const isOwner = logbook.userId === req.userId
const collaboration = logbook.collaborators[0]
if (!isOwner && !collaboration) {
return res.status(403).json({ error: 'Forbidden: Access denied' })
}
return res.json({
isOwner,
role: isOwner ? 'OWNER' : collaboration!.role,
writeCollaboratorCount: logbook._count.collaborators
})
} catch (error: any) {
console.error('Error fetching logbook access:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
// 4. Delete a logbook
router.delete('/:id', async (req: any, res) => {
try {
const { id } = req.params
+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