Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f0385ee1b | |||
| 1710007efe | |||
| 241b2fdf63 | |||
| f87f5e382d | |||
| 81da01e786 | |||
| 878a18e9f7 | |||
| ce47fe5fdc | |||
| 5706d1762d | |||
| 7d28b5745a | |||
| affe745250 | |||
| cb96343d8c | |||
| 56af7a3c60 | |||
| 95856800de | |||
| b1b0c798b3 | |||
| cffe934d5e | |||
| 3c7aec1573 |
@@ -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) | ~2–3 Tage |
|
||||
| Phase 2 (Crew Passkey) | ~1 Tag |
|
||||
| Phase 3 (Audit + Tests) | ~1–2 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. 611–612, 1312–1336) |
|
||||
| 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
@@ -7,7 +7,7 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Daagbox" />
|
||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||
<meta name="theme-color" content="#1e293b" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<title>Kapteins Daagbok</title>
|
||||
|
||||
+375
-16
@@ -1,15 +1,30 @@
|
||||
/* Kapteins Daagbox App styling */
|
||||
/* Kapteins Daagbok App styling */
|
||||
|
||||
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);
|
||||
@@ -55,7 +70,8 @@ body {
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin: 0 0 8px 0;
|
||||
margin: 0 0 14px 0;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
@@ -63,7 +79,7 @@ body {
|
||||
font-size: 14.5px;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
line-height: 140%;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
@@ -449,6 +465,51 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-account-section {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
margin: 40px auto 48px;
|
||||
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.account-danger-zone {
|
||||
border-top: 1px solid rgba(239, 68, 68, 0.2);
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.account-danger-zone__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.account-danger-zone__icon {
|
||||
color: #ef4444;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.account-danger-zone__title {
|
||||
margin: 0;
|
||||
color: #ef4444;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.account-danger-zone__desc {
|
||||
font-size: 13.5px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.45;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.account-danger-zone__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.account-danger-zone__actions .btn {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.create-section {
|
||||
background: rgba(11, 12, 16, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
@@ -1518,6 +1579,7 @@ body:has(.theme-cupertino) {
|
||||
color: #e2e8f0;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 24px 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.custom-dialog-actions {
|
||||
@@ -1845,6 +1907,41 @@ body:has(.theme-cupertino) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.consumption-grid .input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.consumption-grid .input-group label {
|
||||
flex: 1 1 auto;
|
||||
min-height: 2.75em;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.consumption-grid .input-group .input-text {
|
||||
flex-shrink: 0;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.consumption-grid .input-text::-webkit-outer-spin-button,
|
||||
.consumption-grid .input-text::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.consumption-grid .consumption-value {
|
||||
color: #4ade80;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -1894,16 +1991,6 @@ body:has(.theme-cupertino) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#openseamap-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.track-info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1944,6 +2031,278 @@ body:has(.theme-cupertino) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.track-stats-grid {
|
||||
margin-top: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.track-map-wrapper {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
#openseamap-container,
|
||||
.track-map-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.track-map-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.track-map-legend-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(to right, hsl(120, 72%, 42%), hsl(60, 72%, 42%), hsl(0, 72%, 42%));
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.track-map-container.leaflet-container {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.track-map-container .leaflet-tile,
|
||||
.track-map-container img.leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.track-map-container .leaflet-control-attribution {
|
||||
font-size: 10px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.signature-grid {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.signature-pad-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signature-pad-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.signature-pad-header label {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signature-pad-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.signature-pad-clear:hover {
|
||||
color: #e2e8f0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.signature-pad {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 132px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.45);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.signature-pad.disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.signature-pad-canvas,
|
||||
.signature-pad-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.signature-pad-canvas {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.signature-pad-hint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.signature-legacy-text {
|
||||
min-height: 132px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #e2e8f0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.08em;
|
||||
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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Trash2, AlertTriangle } from 'lucide-react'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { deleteAccount } from '../services/auth.js'
|
||||
|
||||
interface AccountDangerZoneProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function AccountDangerZone({ className = '' }: AccountDangerZoneProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm, showAlert } = useDialog()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('settings.delete_account_confirm_desc'),
|
||||
t('settings.delete_account_confirm_title'),
|
||||
t('settings.delete_account_confirm_yes'),
|
||||
t('settings.delete_account_confirm_no')
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
setDeleting(true)
|
||||
try {
|
||||
const success = await deleteAccount()
|
||||
if (success) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
showAlert(t('settings.delete_account_failed'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
showAlert(err.message || t('settings.delete_account_failed'))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`account-danger-zone member-editor-card glass ${className}`.trim()}>
|
||||
<div className="account-danger-zone__header">
|
||||
<AlertTriangle size={20} className="account-danger-zone__icon" />
|
||||
<h3 className="account-danger-zone__title">{t('settings.danger_zone_title')}</h3>
|
||||
</div>
|
||||
|
||||
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
|
||||
|
||||
<div className="form-actions account-danger-zone__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{deleting ? t('settings.deleting_account') : t('settings.delete_account_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -382,7 +382,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
return (
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-brand">
|
||||
<img src="/logo.png" alt="Kapteins Daagbox" className="auth-logo-img" />
|
||||
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
|
||||
<h1>{t('app.name')}</h1>
|
||||
<p className="tagline">{t('auth.tagline')}</p>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,6 +10,15 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
||||
import {
|
||||
carryOverTankLevelsFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
emptyTankLevels,
|
||||
formatTankLiters,
|
||||
getNextTravelDayNumber,
|
||||
type LogEntryTankSource,
|
||||
type TravelDaySortable
|
||||
} from '../utils/logEntryTankLevels.js'
|
||||
|
||||
interface LogEntriesListProps {
|
||||
logbookId: string
|
||||
@@ -179,26 +188,52 @@ export default function LogEntriesList({
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (readOnly) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||
|
||||
for (const entry of localEntries) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
||||
}
|
||||
|
||||
decryptedEntries.sort(compareTravelDaysChronological)
|
||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||
let { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||
|
||||
if (previousEntry && (freshwater.morning > 0 || fuel.morning > 0)) {
|
||||
const confirmed = await showConfirm(
|
||||
t('logs.carry_over_tanks_confirm', {
|
||||
fw: formatTankLiters(freshwater.morning),
|
||||
fuel: formatTankLiters(fuel.morning)
|
||||
}),
|
||||
t('logs.carry_over_tanks_title'),
|
||||
t('logs.carry_over_tanks_yes'),
|
||||
t('logs.carry_over_tanks_no')
|
||||
)
|
||||
if (!confirmed) {
|
||||
freshwater = emptyTankLevels()
|
||||
fuel = emptyTankLevels()
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const localId = window.crypto.randomUUID()
|
||||
const nowStr = new Date().toISOString()
|
||||
const todayStr = nowStr.substring(0, 10)
|
||||
|
||||
// Calculate next travel day number
|
||||
const nextDayNum = String(entries.length + 1)
|
||||
|
||||
const initialPayload = {
|
||||
date: todayStr,
|
||||
dayOfTravel: nextDayNum,
|
||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||
departure: '',
|
||||
destination: '',
|
||||
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||
freshwater,
|
||||
fuel,
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: []
|
||||
@@ -275,7 +310,7 @@ export default function LogEntriesList({
|
||||
readOnly={readOnly}
|
||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||
preloadedPhotos={preloadedPhotos}
|
||||
preloadedGpsTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
||||
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -6,20 +6,32 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Navigation } 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 SignatureSection from './SignatureSection.tsx'
|
||||
import TrackMap from './TrackMap.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import {
|
||||
getDecryptedGpsTrack,
|
||||
saveUploadedGpsTrack,
|
||||
deleteGpsTrack,
|
||||
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,
|
||||
deleteTrack,
|
||||
downloadTrackFile,
|
||||
parseTrackFile,
|
||||
type GpsWaypoint,
|
||||
type SavedGpsTrack
|
||||
} from '../services/gpsTracker.js'
|
||||
type SavedTrack
|
||||
} from '../services/trackUpload.js'
|
||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||
|
||||
interface LogEntryEditorProps {
|
||||
entryId: string
|
||||
@@ -28,7 +40,7 @@ interface LogEntryEditorProps {
|
||||
readOnly?: boolean
|
||||
preloadedEntry?: any
|
||||
preloadedPhotos?: any[]
|
||||
preloadedGpsTrack?: any
|
||||
preloadedTrack?: any
|
||||
preloadedYacht?: any
|
||||
}
|
||||
|
||||
@@ -58,11 +70,11 @@ export default function LogEntryEditor({
|
||||
readOnly = false,
|
||||
preloadedEntry,
|
||||
preloadedPhotos,
|
||||
preloadedGpsTrack,
|
||||
preloadedTrack,
|
||||
preloadedYacht
|
||||
}: LogEntryEditorProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showAlert } = useDialog()
|
||||
const { showAlert, showConfirm } = useDialog()
|
||||
|
||||
// General details state
|
||||
const [date, setDate] = useState('')
|
||||
@@ -84,8 +96,17 @@ 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('')
|
||||
const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('')
|
||||
const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('')
|
||||
|
||||
// Events list state
|
||||
const [events, setEvents] = useState<LogEvent[]>([])
|
||||
@@ -116,14 +137,170 @@ export default function LogEntryEditor({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [weatherLoading, setWeatherLoading] = useState(false)
|
||||
|
||||
// GPS Tracking States
|
||||
const [savedTrack, setSavedTrack] = useState<SavedGpsTrack | null>(null)
|
||||
// Track file upload
|
||||
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
|
||||
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 mapContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const mapInstanceRef = useRef<L.Map | null>(null)
|
||||
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
||||
const stats = computeTrackStats(waypoints)
|
||||
if (!stats) return
|
||||
const formatted = formatTrackStats(stats)
|
||||
setTrackDistanceNm(formatted.distanceNm)
|
||||
setTrackSpeedMaxKn(formatted.speedMaxKn)
|
||||
setTrackSpeedAvgKn(formatted.speedAvgKn)
|
||||
}
|
||||
|
||||
const loadTrackStatsFromEntry = (entry: any) => {
|
||||
if (entry?.trackDistanceNm != null && entry.trackDistanceNm !== '') {
|
||||
setTrackDistanceNm(String(entry.trackDistanceNm))
|
||||
}
|
||||
if (entry?.trackSpeedMaxKn != null && entry.trackSpeedMaxKn !== '') {
|
||||
setTrackSpeedMaxKn(String(entry.trackSpeedMaxKn))
|
||||
}
|
||||
if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') {
|
||||
setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn))
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {
|
||||
@@ -173,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 || '')
|
||||
@@ -184,15 +363,18 @@ 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
|
||||
}
|
||||
@@ -213,15 +395,18 @@ 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 || [])
|
||||
}
|
||||
}
|
||||
@@ -236,91 +421,30 @@ export default function LogEntryEditor({
|
||||
loadEntry()
|
||||
}, [entryId, preloadedEntry])
|
||||
|
||||
// GPS Track Loader
|
||||
const loadGpsTrack = async () => {
|
||||
if (readOnly && preloadedGpsTrack) {
|
||||
setSavedTrack(preloadedGpsTrack)
|
||||
const loadTrack = async () => {
|
||||
if (readOnly && preloadedTrack) {
|
||||
setSavedTrack(preloadedTrack)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const track = await getDecryptedGpsTrack(entryId)
|
||||
const track = await getDecryptedTrack(entryId)
|
||||
setSavedTrack(track)
|
||||
} catch (e) {
|
||||
console.warn('Failed to load GPS track:', e)
|
||||
console.warn('Failed to load track file:', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadGpsTrack()
|
||||
}, [entryId, preloadedGpsTrack])
|
||||
loadTrack()
|
||||
}, [entryId, preloadedTrack])
|
||||
|
||||
// Leaflet Map Initialization and Rendering
|
||||
useEffect(() => {
|
||||
if (!savedTrack || !savedTrack.waypoints || savedTrack.waypoints.length === 0 || !mapContainerRef.current) {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove()
|
||||
mapInstanceRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!savedTrack || savedTrack.waypoints.length < 2) return
|
||||
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
|
||||
applyTrackStats(savedTrack.waypoints)
|
||||
}, [savedTrack, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn])
|
||||
|
||||
const startWp = savedTrack.waypoints[0]
|
||||
const map = L.map(mapContainerRef.current).setView([startWp.lat, startWp.lng], 13)
|
||||
mapInstanceRef.current = map
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'
|
||||
}).addTo(map)
|
||||
|
||||
L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
attribution: 'Map data © <a href="http://openseamap.org">OpenSeaMap</a> contributors'
|
||||
}).addTo(map)
|
||||
|
||||
const latLngs = savedTrack.waypoints.map((wp) => [wp.lat, wp.lng] as [number, number])
|
||||
|
||||
const polyline = L.polyline(latLngs, {
|
||||
color: '#fbbf24',
|
||||
weight: 4,
|
||||
opacity: 0.85
|
||||
}).addTo(map)
|
||||
|
||||
map.fitBounds(polyline.getBounds(), { padding: [20, 20] })
|
||||
|
||||
if (savedTrack.waypoints.length > 0) {
|
||||
L.circleMarker(latLngs[0], {
|
||||
radius: 8,
|
||||
fillColor: '#10b981',
|
||||
fillOpacity: 0.9,
|
||||
color: '#ffffff',
|
||||
weight: 2
|
||||
}).addTo(map).bindPopup('Start Position')
|
||||
|
||||
if (savedTrack.waypoints.length > 1) {
|
||||
L.circleMarker(latLngs[latLngs.length - 1], {
|
||||
radius: 8,
|
||||
fillColor: '#ef4444',
|
||||
fillOpacity: 0.9,
|
||||
color: '#ffffff',
|
||||
weight: 2
|
||||
}).addTo(map).bindPopup('End Position')
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
map.invalidateSize()
|
||||
}, 100)
|
||||
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove()
|
||||
mapInstanceRef.current = null
|
||||
}
|
||||
}
|
||||
}, [savedTrack])
|
||||
|
||||
// GPX/KML/GeoJSON Upload Handlers
|
||||
// Track file upload handlers
|
||||
const handleFileUpload = async (file: File) => {
|
||||
if (readOnly) return
|
||||
setUploadError(null)
|
||||
@@ -338,8 +462,9 @@ export default function LogEntryEditor({
|
||||
throw new Error('No coordinates found in file. Supported formats: GPX, KML, GeoJSON.')
|
||||
}
|
||||
|
||||
await saveUploadedGpsTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
|
||||
await loadGpsTrack()
|
||||
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
|
||||
applyTrackStats(parsedWps)
|
||||
await loadTrack()
|
||||
} catch (err: any) {
|
||||
console.error('File parsing failed:', err)
|
||||
setUploadError(err.message || 'Failed to parse track file.')
|
||||
@@ -380,38 +505,17 @@ export default function LogEntryEditor({
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteGpsTrack(logbookId, entryId)
|
||||
await deleteTrack(logbookId, entryId)
|
||||
setSavedTrack(null)
|
||||
setTrackDistanceNm('')
|
||||
setTrackSpeedMaxKn('')
|
||||
setTrackSpeedAvgKn('')
|
||||
setUploadError(null)
|
||||
} catch (err: any) {
|
||||
showAlert(err.message || 'Failed to delete track')
|
||||
}
|
||||
}
|
||||
|
||||
const calculateTrackDistance = (wps: GpsWaypoint[]) => {
|
||||
if (wps.length < 2) return 0
|
||||
let totalMeters = 0
|
||||
for (let i = 1; i < wps.length; i++) {
|
||||
const lat1 = wps[i - 1].lat
|
||||
const lon1 = wps[i - 1].lng
|
||||
const lat2 = wps[i].lat
|
||||
const lon2 = wps[i].lng
|
||||
|
||||
const R = 6371e3
|
||||
const phi1 = (lat1 * Math.PI) / 180
|
||||
const phi2 = (lat2 * Math.PI) / 180
|
||||
const deltaPhi = ((lat2 - lat1) * Math.PI) / 180
|
||||
const deltaLambda = ((lon2 - lon1) * Math.PI) / 180
|
||||
|
||||
const a =
|
||||
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
|
||||
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2)
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
totalMeters += R * c
|
||||
}
|
||||
return Number((totalMeters / 1852).toFixed(2))
|
||||
}
|
||||
|
||||
const handleGetGps = () => {
|
||||
if (readOnly) return
|
||||
const lookupFallback = async () => {
|
||||
@@ -646,26 +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: signSkipper.trim(),
|
||||
signCrew: signCrew.trim(),
|
||||
events
|
||||
...entryPayload,
|
||||
signSkipper: serializeSignature(signSkipper),
|
||||
signCrew: serializeSignature(signCrew)
|
||||
}
|
||||
|
||||
// E2E encrypt
|
||||
@@ -816,7 +905,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
<div className="consumption-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('logs.freshwater')} ({t('logs.morning')})</label>
|
||||
<label>{t('logs.morning')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
@@ -827,7 +916,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.freshwater')} ({t('logs.refilled')})</label>
|
||||
<label>{t('logs.refilled')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
@@ -838,7 +927,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.freshwater')} ({t('logs.evening')})</label>
|
||||
<label>{t('logs.evening')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
@@ -851,11 +940,12 @@ export default function LogEntryEditor({
|
||||
<div className="input-group">
|
||||
<label>{t('logs.consumption')} (L)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text cell-input text-green"
|
||||
style={{ color: '#4ade80', fontWeight: 'bold' }}
|
||||
type="number"
|
||||
className="input-text consumption-value"
|
||||
value={fwConsumption}
|
||||
disabled
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -869,7 +959,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
<div className="consumption-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('logs.fuel')} ({t('logs.morning')})</label>
|
||||
<label>{t('logs.morning')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
@@ -880,7 +970,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.fuel')} ({t('logs.refilled')})</label>
|
||||
<label>{t('logs.refilled')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
@@ -891,7 +981,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.fuel')} ({t('logs.evening')})</label>
|
||||
<label>{t('logs.evening')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
@@ -904,11 +994,12 @@ export default function LogEntryEditor({
|
||||
<div className="input-group">
|
||||
<label>{t('logs.consumption')} (L)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text cell-input text-green"
|
||||
style={{ color: '#4ade80', fontWeight: 'bold' }}
|
||||
type="number"
|
||||
className="input-text consumption-value"
|
||||
value={fuelConsumption}
|
||||
disabled
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1234,17 +1325,16 @@ export default function LogEntryEditor({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* GPS Track Upload & Map Visualization */}
|
||||
{/* Track file upload */}
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Navigation size={20} className="form-icon" />
|
||||
<h3>{t('logs.gps_tracking_title')}</h3>
|
||||
<Upload size={20} className="form-icon" />
|
||||
<h3>{t('logs.track_upload_title')}</h3>
|
||||
</div>
|
||||
|
||||
{uploadError && <div className="track-error-msg">{uploadError}</div>}
|
||||
|
||||
{!savedTrack ? (
|
||||
/* Upload Zone when no track is loaded */
|
||||
<div
|
||||
className={`track-upload-zone ${dragOver ? 'dragover' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -1260,27 +1350,33 @@ export default function LogEntryEditor({
|
||||
onChange={handleFileChange}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Download size={36} className="track-upload-icon" />
|
||||
<Upload size={36} className="track-upload-icon" />
|
||||
<div className="track-upload-text">{t('logs.gps_track_upload_btn')}</div>
|
||||
<div className="track-upload-subtext">{t('logs.gps_track_upload_help')}</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Map and Details when track is loaded */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<>
|
||||
<div className="track-info-header">
|
||||
<div className="track-info-left">
|
||||
<Navigation size={16} style={{ color: '#fbbf24' }} />
|
||||
<Upload size={16} style={{ color: '#fbbf24' }} />
|
||||
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
|
||||
</div>
|
||||
<div className="track-info-stats">
|
||||
<span style={{ marginRight: '12px' }}>
|
||||
{t('logs.gps_tracking_stat_distance')}: <strong>{calculateTrackDistance(savedTrack.waypoints)} sm</strong>
|
||||
</span>
|
||||
<span>
|
||||
{t('logs.gps_tracking_stat_waypoints')}: <strong>{savedTrack.waypoints.length}</strong>
|
||||
<span className="track-info-stats">
|
||||
{savedTrack.fileType.toUpperCase()}
|
||||
{savedTrack.waypoints.length > 0 && (
|
||||
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
|
||||
)}
|
||||
{trackDistanceNm && (
|
||||
<> · {trackDistanceNm} sm</>
|
||||
)}
|
||||
{trackSpeedMaxKn && (
|
||||
<> · max {trackSpeedMaxKn} kn</>
|
||||
)}
|
||||
{trackSpeedAvgKn && (
|
||||
<> · Ø {trackSpeedAvgKn} kn</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
@@ -1304,46 +1400,72 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaflet Map Div */}
|
||||
<div id="openseamap-container" ref={mapContainerRef} />
|
||||
{savedTrack.waypoints.length > 0 && (
|
||||
<TrackMap waypoints={savedTrack.waypoints} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
|
||||
<div className="form-grid track-stats-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('logs.track_distance')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="e.g. 5.0"
|
||||
className="input-text"
|
||||
value={trackDistanceNm}
|
||||
onChange={(e) => setTrackDistanceNm(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.track_speed_max')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="e.g. 7.8"
|
||||
className="input-text"
|
||||
value={trackSpeedMaxKn}
|
||||
onChange={(e) => setTrackSpeedMaxKn(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.track_speed_avg')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="e.g. 4.6"
|
||||
className="input-text"
|
||||
value={trackSpeedAvgKn}
|
||||
onChange={(e) => setTrackSpeedAvgKn(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div className="input-group">
|
||||
<label>{t('logs.sign_skipper')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. MARKUS SKIPPER"
|
||||
className="input-text"
|
||||
value={signSkipper}
|
||||
onChange={(e) => setSignSkipper(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.sign_crew')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. JAN MATE"
|
||||
className="input-text"
|
||||
value={signCrew}
|
||||
onChange={(e) => setSignCrew(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
</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 && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { db } from '../services/db.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import { logoutUser } from '../services/auth.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
||||
|
||||
interface LogbookDashboardProps {
|
||||
@@ -218,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>
|
||||
@@ -227,6 +233,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
|
||||
<AccountDangerZone />
|
||||
</section>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, AlertTriangle } from 'lucide-react'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
import { deleteAccount } from '../services/auth.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
|
||||
interface SettingsFormProps {
|
||||
logbookId?: string | null
|
||||
@@ -48,31 +48,6 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
const [shareCopied, setShareCopied] = useState(false)
|
||||
const [loadingShareLink, setLoadingShareLink] = useState(false)
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('settings.delete_account_confirm_desc'),
|
||||
t('settings.delete_account_confirm_title'),
|
||||
t('settings.delete_account_confirm_yes'),
|
||||
t('settings.delete_account_confirm_no')
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
setSaving(true)
|
||||
try {
|
||||
const success = await deleteAccount()
|
||||
if (success) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
showAlert(t('settings.delete_account_failed'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
showAlert(err.message || t('settings.delete_account_failed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (logbookId) {
|
||||
loadCollaborators()
|
||||
@@ -514,30 +489,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
</div>
|
||||
)}
|
||||
{/* Danger Zone / Account Deletion */}
|
||||
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(239,68,68,0.2)', paddingTop: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<AlertTriangle size={20} style={{ color: '#ef4444' }} />
|
||||
<h3 style={{ margin: 0, color: '#ef4444', fontSize: '16px' }}>
|
||||
{t('settings.danger_zone_title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.danger_zone_desc')}
|
||||
</p>
|
||||
|
||||
<div className="form-actions" style={{ justifyContent: 'flex-start' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={handleDeleteAccount}
|
||||
style={{ width: 'auto' }}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t('settings.delete_account_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AccountDangerZone className="mt-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Eraser } from 'lucide-react'
|
||||
import { isSignatureImage } from '../utils/signatures.js'
|
||||
|
||||
interface SignaturePadProps {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
onBeforeSign?: () => Promise<boolean> | boolean
|
||||
}
|
||||
|
||||
const STROKE_COLOR = '#0f172a'
|
||||
const STROKE_WIDTH = 2.2
|
||||
|
||||
export default function SignaturePad({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
onBeforeSign
|
||||
}: SignaturePadProps) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const isDrawing = useRef(false)
|
||||
const lastPoint = useRef<{ x: number; y: number } | null>(null)
|
||||
const skipExternalRedraw = useRef(false)
|
||||
const hasInk = useRef(false)
|
||||
const [showHint, setShowHint] = useState(() => !value)
|
||||
|
||||
const getContext = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return null
|
||||
return canvas.getContext('2d')
|
||||
}, [])
|
||||
|
||||
const clearCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = getContext()
|
||||
if (!canvas || !ctx) return
|
||||
ctx.save()
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.restore()
|
||||
hasInk.current = false
|
||||
}, [getContext])
|
||||
|
||||
const drawImageValue = useCallback((dataUrl: string) => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = getContext()
|
||||
if (!canvas || !ctx) return
|
||||
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
clearCanvas()
|
||||
const width = canvas.clientWidth
|
||||
const height = canvas.clientHeight
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
hasInk.current = true
|
||||
}
|
||||
img.src = dataUrl
|
||||
}, [clearCanvas, getContext])
|
||||
|
||||
const setupCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container) return
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
const width = Math.max(rect.width, 1)
|
||||
const height = Math.max(rect.height, 1)
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
canvas.width = Math.floor(width * dpr)
|
||||
canvas.height = Math.floor(height * dpr)
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineWidth = STROKE_WIDTH
|
||||
ctx.strokeStyle = STROKE_COLOR
|
||||
|
||||
if (value && isSignatureImage(value)) {
|
||||
drawImageValue(value)
|
||||
} else {
|
||||
clearCanvas()
|
||||
}
|
||||
}, [clearCanvas, drawImageValue, value])
|
||||
|
||||
useEffect(() => {
|
||||
setupCanvas()
|
||||
window.addEventListener('resize', setupCanvas)
|
||||
return () => window.removeEventListener('resize', setupCanvas)
|
||||
}, [setupCanvas])
|
||||
|
||||
useEffect(() => {
|
||||
if (skipExternalRedraw.current) {
|
||||
skipExternalRedraw.current = false
|
||||
return
|
||||
}
|
||||
if (value && isSignatureImage(value)) {
|
||||
drawImageValue(value)
|
||||
setShowHint(false)
|
||||
} else if (!value) {
|
||||
clearCanvas()
|
||||
setShowHint(true)
|
||||
}
|
||||
}, [value, clearCanvas, drawImageValue])
|
||||
|
||||
const getPoint = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return null
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
return {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top
|
||||
}
|
||||
}
|
||||
|
||||
const commitCanvas = () => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
if (!hasInk.current) {
|
||||
skipExternalRedraw.current = true
|
||||
onChange('')
|
||||
return
|
||||
}
|
||||
skipExternalRedraw.current = true
|
||||
onChange(canvas.toDataURL('image/png'))
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
isDrawing.current = true
|
||||
lastPoint.current = point
|
||||
setShowHint(false)
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing.current || readOnly || disabled) return
|
||||
event.preventDefault()
|
||||
|
||||
const point = getPoint(event)
|
||||
const ctx = getContext()
|
||||
const prev = lastPoint.current
|
||||
if (!point || !ctx || !prev) return
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(prev.x, prev.y)
|
||||
ctx.lineTo(point.x, point.y)
|
||||
ctx.stroke()
|
||||
|
||||
lastPoint.current = point
|
||||
hasInk.current = true
|
||||
}
|
||||
|
||||
const finishStroke = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing.current) return
|
||||
isDrawing.current = false
|
||||
lastPoint.current = null
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
commitCanvas()
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (readOnly || disabled) return
|
||||
clearCanvas()
|
||||
skipExternalRedraw.current = true
|
||||
setShowHint(true)
|
||||
onChange('')
|
||||
}
|
||||
|
||||
const interactive = !readOnly && !disabled
|
||||
|
||||
if (readOnly && value && !isSignatureImage(value)) {
|
||||
return (
|
||||
<div className="input-group signature-pad-group">
|
||||
<label htmlFor={id}>{label}</label>
|
||||
<div className="signature-legacy-text">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="input-group signature-pad-group">
|
||||
<div className="signature-pad-header">
|
||||
<label htmlFor={id}>{label}</label>
|
||||
{interactive && (
|
||||
<button type="button" className="signature-pad-clear" onClick={handleClear}>
|
||||
<Eraser size={14} />
|
||||
{t('logs.sign_clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`signature-pad ${readOnly ? 'readonly' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
>
|
||||
{readOnly && value && isSignatureImage(value) ? (
|
||||
<img src={value} alt={label} className="signature-pad-image" />
|
||||
) : (
|
||||
<canvas
|
||||
id={id}
|
||||
ref={canvasRef}
|
||||
className="signature-pad-canvas"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={finishStroke}
|
||||
onPointerLeave={finishStroke}
|
||||
onPointerCancel={finishStroke}
|
||||
/>
|
||||
)}
|
||||
{interactive && showHint && (
|
||||
<span className="signature-pad-hint">{t('logs.sign_hint')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Component, useEffect, useMemo, useRef } from 'react'
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import L from 'leaflet'
|
||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||
import {
|
||||
getSegmentSpeedsKn,
|
||||
getTrackLineColor,
|
||||
hasSpeedGradientData,
|
||||
speedToTrackColor
|
||||
} from '../utils/trackMapColors.js'
|
||||
|
||||
interface TrackMapProps {
|
||||
waypoints: TrackWaypoint[]
|
||||
}
|
||||
|
||||
const LINE_WEIGHT = 5
|
||||
const LINE_OPACITY = 0.92
|
||||
|
||||
function isValidWaypoint(wp: TrackWaypoint): boolean {
|
||||
return Number.isFinite(Number(wp.lat)) && Number.isFinite(Number(wp.lng))
|
||||
}
|
||||
|
||||
function toLatLngs(waypoints: TrackWaypoint[]): [number, number][] {
|
||||
return waypoints
|
||||
.filter(isValidWaypoint)
|
||||
.map((wp) => [Number(wp.lat), Number(wp.lng)] as [number, number])
|
||||
}
|
||||
|
||||
function getTrackCenter(latLngs: [number, number][]): [number, number] {
|
||||
const avgLat = latLngs.reduce((sum, point) => sum + point[0], 0) / latLngs.length
|
||||
const avgLng = latLngs.reduce((sum, point) => sum + point[1], 0) / latLngs.length
|
||||
return [avgLat, avgLng]
|
||||
}
|
||||
|
||||
function scheduleFitMap(
|
||||
map: L.Map,
|
||||
latLngs: [number, number][],
|
||||
isCancelled: () => boolean,
|
||||
frameIds: number[]
|
||||
) {
|
||||
if (latLngs.length === 0) return
|
||||
|
||||
const fallbackCenter = latLngs.length === 1 ? latLngs[0] : getTrackCenter(latLngs)
|
||||
const fallbackZoom = latLngs.length === 1 ? 14 : 11
|
||||
|
||||
frameIds.push(
|
||||
requestAnimationFrame(() => {
|
||||
if (isCancelled()) return
|
||||
map.invalidateSize({ animate: false })
|
||||
frameIds.push(
|
||||
requestAnimationFrame(() => {
|
||||
if (isCancelled()) return
|
||||
try {
|
||||
if (latLngs.length === 1) {
|
||||
map.setView(L.latLng(latLngs[0]), 14, { animate: false })
|
||||
return
|
||||
}
|
||||
|
||||
const bounds = L.latLngBounds(latLngs.map(([lat, lng]) => L.latLng(lat, lng)))
|
||||
if (!bounds.isValid()) {
|
||||
map.setView(fallbackCenter, fallbackZoom, { animate: false })
|
||||
return
|
||||
}
|
||||
|
||||
map.fitBounds(bounds, { padding: [20, 20], maxZoom: 14, animate: false })
|
||||
} catch {
|
||||
map.setView(fallbackCenter, fallbackZoom, { animate: false })
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function TrackMapInner({ waypoints }: TrackMapProps) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const validWaypoints = useMemo(() => waypoints.filter(isValidWaypoint), [waypoints])
|
||||
const segmentSpeeds = useMemo(() => getSegmentSpeedsKn(validWaypoints), [validWaypoints])
|
||||
const useGradient = hasSpeedGradientData(segmentSpeeds)
|
||||
|
||||
const speedRange = useMemo(() => {
|
||||
const valid = segmentSpeeds.filter((speed) => speed > 0)
|
||||
if (valid.length === 0) return { min: 0, max: 0 }
|
||||
return { min: Math.min(...valid), max: Math.max(...valid) }
|
||||
}, [segmentSpeeds])
|
||||
|
||||
const trackKey = useMemo(
|
||||
() =>
|
||||
validWaypoints
|
||||
.map((wp, index) => {
|
||||
const speed = index > 0 ? segmentSpeeds[index - 1] : 0
|
||||
return `${wp.lat},${wp.lng},${speed.toFixed(1)}`
|
||||
})
|
||||
.join('|'),
|
||||
[validWaypoints, segmentSpeeds]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || validWaypoints.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
const pendingFrames: number[] = []
|
||||
const isCancelled = () => cancelled
|
||||
|
||||
const map = L.map(container, {
|
||||
zoomControl: true,
|
||||
attributionControl: true
|
||||
})
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'
|
||||
}).addTo(map)
|
||||
|
||||
L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
attribution: 'Map data © <a href="http://openseamap.org">OpenSeaMap</a> contributors'
|
||||
}).addTo(map)
|
||||
|
||||
const trackGroup = L.layerGroup().addTo(map)
|
||||
const latLngs = toLatLngs(validWaypoints)
|
||||
|
||||
if (useGradient && latLngs.length >= 2) {
|
||||
for (let i = 1; i < latLngs.length; i++) {
|
||||
const speedKn = segmentSpeeds[i - 1] ?? 0
|
||||
const color = speedToTrackColor(speedKn, speedRange.min, speedRange.max)
|
||||
L.polyline([latLngs[i - 1], latLngs[i]], {
|
||||
color,
|
||||
weight: LINE_WEIGHT,
|
||||
opacity: LINE_OPACITY,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round'
|
||||
}).addTo(trackGroup)
|
||||
}
|
||||
} else if (latLngs.length >= 2) {
|
||||
L.polyline(latLngs, {
|
||||
color: getTrackLineColor(segmentSpeeds),
|
||||
weight: LINE_WEIGHT,
|
||||
opacity: LINE_OPACITY,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round'
|
||||
}).addTo(trackGroup)
|
||||
}
|
||||
|
||||
if (latLngs.length > 0) {
|
||||
L.circleMarker(latLngs[0], {
|
||||
radius: 8,
|
||||
fillColor: '#10b981',
|
||||
fillOpacity: 0.9,
|
||||
color: '#ffffff',
|
||||
weight: 2
|
||||
})
|
||||
.addTo(trackGroup)
|
||||
.bindPopup(t('logs.track_map_start'))
|
||||
}
|
||||
|
||||
if (latLngs.length > 1) {
|
||||
L.circleMarker(latLngs[latLngs.length - 1], {
|
||||
radius: 8,
|
||||
fillColor: '#ef4444',
|
||||
fillOpacity: 0.9,
|
||||
color: '#ffffff',
|
||||
weight: 2
|
||||
})
|
||||
.addTo(trackGroup)
|
||||
.bindPopup(t('logs.track_map_end'))
|
||||
}
|
||||
|
||||
scheduleFitMap(map, latLngs, isCancelled, pendingFrames)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
pendingFrames.forEach((id) => cancelAnimationFrame(id))
|
||||
map.remove()
|
||||
}
|
||||
}, [trackKey, validWaypoints, segmentSpeeds, speedRange.min, speedRange.max, useGradient, t])
|
||||
|
||||
if (validWaypoints.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="track-map-wrapper">
|
||||
<div
|
||||
className="track-map-container"
|
||||
ref={containerRef}
|
||||
aria-label={t('logs.track_map_title')}
|
||||
/>
|
||||
{useGradient && (
|
||||
<div className="track-map-legend" aria-hidden="true">
|
||||
<span>{t('logs.track_map_speed_slow')}</span>
|
||||
<div className="track-map-legend-bar" />
|
||||
<span>{t('logs.track_map_speed_fast')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
class TrackMapErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false }
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('TrackMap render failed:', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) return this.props.fallback
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default function TrackMap(props: TrackMapProps) {
|
||||
const { t } = useTranslation()
|
||||
const remountKey = props.waypoints.filter(isValidWaypoint).length
|
||||
|
||||
return (
|
||||
<TrackMapErrorBoundary
|
||||
key={remountKey}
|
||||
fallback={<div className="track-error-msg">{t('logs.track_map_error')}</div>}
|
||||
>
|
||||
<TrackMapInner {...props} />
|
||||
</TrackMapErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"translation": {
|
||||
"app": {
|
||||
"name": "Kapteins Daagbox",
|
||||
"name": "Kapteins Daagbok",
|
||||
"tagline": "Privates Yacht-Logbuch"
|
||||
},
|
||||
"nav": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Willkommen bei Kapteins Daagbox",
|
||||
"welcome": "Willkommen bei Kapteins Daagbok",
|
||||
"tagline": "Sicheres, E2E-verschlüsseltes maritimes Logbuch.",
|
||||
"register": "Mit Passkey registrieren",
|
||||
"login": "Mit Passkey anmelden",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"pwa": {
|
||||
"title": "App installieren",
|
||||
"generic_benefit": "Installieren Sie Kapteins Daagbox auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
|
||||
"generic_benefit": "Installieren Sie Kapteins Daagbok auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
|
||||
"ios_instructions": "Auf dem iPad/iPhone: Fügen Sie die App zum Home-Bildschirm hinzu, damit Ihre Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
|
||||
"ios_step_share": "Teilen-Symbol in der Safari-Leiste antippen",
|
||||
"ios_step_add": "„Zum Home-Bildschirm“ wählen",
|
||||
@@ -113,8 +113,32 @@
|
||||
"evening": "Stand abends",
|
||||
"consumption": "Tagesverbrauch",
|
||||
"signatures": "Unterschriften / Freigabe",
|
||||
"sign_skipper": "Skipper (Blockschrift)",
|
||||
"sign_crew": "Crew-Mitglied (Blockschrift)",
|
||||
"sign_skipper": "Skipper-Unterschrift",
|
||||
"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",
|
||||
@@ -123,6 +147,10 @@
|
||||
"loading": "Journal wird geladen...",
|
||||
"delete_entry": "Tag löschen",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
|
||||
"carry_over_tanks_title": "Tankstände übernehmen?",
|
||||
"carry_over_tanks_confirm": "Morgenstände vom letzten Reisetag als Startwerte übernehmen?\n\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
||||
"carry_over_tanks_yes": "Übernehmen",
|
||||
"carry_over_tanks_no": "Mit 0 starten",
|
||||
"event_title": "Chronologisches Ereignisprotokoll",
|
||||
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
||||
"event_time": "Uhrzeit",
|
||||
@@ -157,14 +185,22 @@
|
||||
"photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?",
|
||||
"confirm_yes": "Ja",
|
||||
"confirm_no": "Nein",
|
||||
"gps_tracking_title": "GPS-Route (OpenSeaMap)",
|
||||
"track_upload_title": "GPS-Track (Datei)",
|
||||
"track_upload_points": "Punkte",
|
||||
"gps_tracking_btn_gpx": "Track-Datei herunterladen",
|
||||
"gps_tracking_stat_distance": "Track-Distanz",
|
||||
"gps_tracking_stat_waypoints": "Wegpunkte",
|
||||
"gps_track_upload_help": "Ziehen Sie eine GPX-, KML- oder GeoJSON-Datei hierher oder klicken Sie zum Auswählen",
|
||||
"gps_track_upload_btn": "GPS-Track hochladen",
|
||||
"gps_track_delete": "Track-Datei löschen",
|
||||
"gps_track_delete_confirm": "Sind Sie sicher, dass Sie diese Track-Datei dauerhaft löschen möchten?",
|
||||
"track_distance": "GPS-Strecke (sm)",
|
||||
"track_speed_max": "Max. Geschwindigkeit (kn)",
|
||||
"track_speed_avg": "Ø Geschwindigkeit (kn)",
|
||||
"track_map_title": "GPS-Track auf OpenSeaMap",
|
||||
"track_map_start": "Start",
|
||||
"track_map_end": "Ziel",
|
||||
"track_map_speed_slow": "langsam",
|
||||
"track_map_speed_fast": "schnell",
|
||||
"track_map_error": "Karte konnte nicht geladen werden.",
|
||||
"exporting": "Exportiere...",
|
||||
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
||||
"invite_crew": "Crew einladen",
|
||||
@@ -186,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",
|
||||
@@ -253,7 +290,8 @@
|
||||
"delete_account_confirm_desc": "Sind Sie absolut sicher, dass Sie Ihr Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchten?",
|
||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||
"delete_account_confirm_no": "Abbrechen",
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut."
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
||||
"deleting_account": "Konto wird gelöscht…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"translation": {
|
||||
"app": {
|
||||
"name": "Kapteins Daagbox",
|
||||
"name": "Kapteins Daagbok",
|
||||
"tagline": "Private Yacht Logbook"
|
||||
},
|
||||
"nav": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"settings": "Settings"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Welcome to Kapteins Daagbox",
|
||||
"welcome": "Welcome to Kapteins Daagbok",
|
||||
"tagline": "Secure, E2E encrypted maritime logbook.",
|
||||
"register": "Register with Passkey",
|
||||
"login": "Login with Passkey",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Install app",
|
||||
"generic_benefit": "Install Kapteins Daagbox on your device for faster access, offline use, and persistent data storage.",
|
||||
"generic_benefit": "Install Kapteins Daagbok on your device for faster access, offline use, and persistent data storage.",
|
||||
"ios_instructions": "On iPad/iPhone: Add the app to your Home Screen so your logbook data stays protected and the app launches like a native app.",
|
||||
"ios_step_share": "Tap the Share button in the Safari toolbar",
|
||||
"ios_step_add": "Choose “Add to Home Screen”",
|
||||
@@ -113,8 +113,32 @@
|
||||
"evening": "Evening Level",
|
||||
"consumption": "Consumption",
|
||||
"signatures": "Signatures / Sign-Off",
|
||||
"sign_skipper": "Skipper Signature",
|
||||
"sign_crew": "Crew Signature",
|
||||
"sign_skipper": "Skipper signature",
|
||||
"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",
|
||||
@@ -123,6 +147,10 @@
|
||||
"loading": "Loading journal...",
|
||||
"delete_entry": "Delete Day",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||
"carry_over_tanks_title": "Carry over tank levels?",
|
||||
"carry_over_tanks_confirm": "Use the previous travel day's closing levels as morning levels?\n\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
||||
"carry_over_tanks_yes": "Carry over",
|
||||
"carry_over_tanks_no": "Start at 0",
|
||||
"event_title": "Chronological Event Logbook",
|
||||
"no_events": "No events logged for this travel day yet.",
|
||||
"event_time": "Time",
|
||||
@@ -157,14 +185,22 @@
|
||||
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
|
||||
"confirm_yes": "Yes",
|
||||
"confirm_no": "No",
|
||||
"gps_tracking_title": "GPS Route (OpenSeaMap)",
|
||||
"gps_tracking_btn_gpx": "Download Track File",
|
||||
"gps_tracking_stat_distance": "Track Distance",
|
||||
"gps_tracking_stat_waypoints": "Points",
|
||||
"track_upload_title": "GPS track file",
|
||||
"track_upload_points": "points",
|
||||
"gps_tracking_btn_gpx": "Download track file",
|
||||
"gps_track_upload_help": "Drag & drop a GPX, KML, or GeoJSON file here, or click to select",
|
||||
"gps_track_upload_btn": "Upload GPS Track File",
|
||||
"gps_track_delete": "Delete Track File",
|
||||
"gps_track_delete_confirm": "Are you sure you want to permanently delete this track file?",
|
||||
"track_distance": "GPS distance (nm)",
|
||||
"track_speed_max": "Max speed (kn)",
|
||||
"track_speed_avg": "Avg speed (kn)",
|
||||
"track_map_title": "GPS track on OpenSeaMap",
|
||||
"track_map_start": "Start",
|
||||
"track_map_end": "End",
|
||||
"track_map_speed_slow": "slow",
|
||||
"track_map_speed_fast": "fast",
|
||||
"track_map_error": "Could not load map.",
|
||||
"exporting": "Exporting...",
|
||||
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
|
||||
"invite_crew": "Invite Crew",
|
||||
@@ -186,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",
|
||||
@@ -253,7 +290,8 @@
|
||||
"delete_account_confirm_desc": "Are you absolutely sure you want to permanently delete your account and all associated logbooks and E2E-encrypted data?",
|
||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||
"delete_account_confirm_no": "Cancel",
|
||||
"delete_account_failed": "Failed to delete account. Please try again."
|
||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||
"deleting_account": "Deleting account…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import './i18n'
|
||||
|
||||
@@ -2,6 +2,8 @@ import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.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 '';
|
||||
@@ -77,6 +79,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const headers = [
|
||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
||||
'Skipper Signature', 'Crew Signature',
|
||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)',
|
||||
'Event Time', 'MgK Course', 'RwK Course',
|
||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
|
||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||
@@ -87,14 +90,24 @@ 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 = entry.signSkipper || '';
|
||||
const signC = 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 ?? '';
|
||||
const fwM = entry.freshwater?.morning ?? '';
|
||||
const fwR = entry.freshwater?.refilled ?? '';
|
||||
const fwE = entry.freshwater?.evening ?? '';
|
||||
@@ -110,6 +123,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg,
|
||||
'', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '', '',
|
||||
@@ -125,6 +139,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg,
|
||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||
@@ -166,7 +181,7 @@ export async function shareCsv(logbookId: string, title: string, preloadedData?:
|
||||
try {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: `Kapteins Daagbox - ${title}`,
|
||||
title: `Kapteins Daagbok - ${title}`,
|
||||
text: `Logbook export for yacht ${title}`
|
||||
});
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { isSignatureImage, isPasskeySignature } from '../utils/signatures.js'
|
||||
import 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 = '';
|
||||
@@ -77,10 +84,19 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.text(`ATIS: ${atis || '—'}`, 210, 21);
|
||||
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21);
|
||||
|
||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 26);
|
||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 26);
|
||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 26);
|
||||
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 26);
|
||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 23);
|
||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23);
|
||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23);
|
||||
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23);
|
||||
|
||||
if (entry.trackDistanceNm) {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(
|
||||
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
|
||||
10,
|
||||
27
|
||||
);
|
||||
}
|
||||
|
||||
// Divider line
|
||||
doc.setLineWidth(0.3);
|
||||
@@ -219,14 +235,32 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.line(sigX, sigY + rowHeight * 1.5, sigX + 157, sigY + rowHeight * 1.5);
|
||||
doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
|
||||
|
||||
doc.text('Skipper Unterschrift (in Blockschrift):', sigX + 2, sigY + 4.2);
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2);
|
||||
doc.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2);
|
||||
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');
|
||||
doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2);
|
||||
}
|
||||
|
||||
doc.setFont('Helvetica', 'bold');
|
||||
doc.text('Crew Unterschrift (in Blockschrift):', sigX + 80.5, sigY + 4.2);
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
|
||||
doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2);
|
||||
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');
|
||||
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
|
||||
export interface GpsWaypoint {
|
||||
export interface TrackWaypoint {
|
||||
timestamp: number
|
||||
lat: number
|
||||
lng: number
|
||||
@@ -12,15 +12,14 @@ export interface GpsWaypoint {
|
||||
heading?: number
|
||||
}
|
||||
|
||||
export interface SavedGpsTrack {
|
||||
waypoints: GpsWaypoint[]
|
||||
gpxContent: string // Holds the raw text file content (GPX, KML or GeoJSON)
|
||||
export interface SavedTrack {
|
||||
waypoints: TrackWaypoint[]
|
||||
gpxContent: string
|
||||
filename: string
|
||||
fileType: string // 'gpx' | 'kml' | 'geojson'
|
||||
fileType: string
|
||||
}
|
||||
|
||||
// Get the decrypted track data for a journal entry (with legacy array format compatibility)
|
||||
export async function getDecryptedGpsTrack(entryId: string): Promise<SavedGpsTrack | null> {
|
||||
export async function getDecryptedTrack(entryId: string): Promise<SavedTrack | null> {
|
||||
const record = await db.gpsTracks.get(entryId)
|
||||
if (!record) return null
|
||||
|
||||
@@ -33,45 +32,41 @@ export async function getDecryptedGpsTrack(entryId: string): Promise<SavedGpsTra
|
||||
try {
|
||||
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
|
||||
if (Array.isArray(decrypted)) {
|
||||
// Legacy format (just coordinate array)
|
||||
return {
|
||||
waypoints: decrypted,
|
||||
gpxContent: generateLegacyGpxString(decrypted, 'legacy'),
|
||||
gpxContent: buildLegacyGpx(decrypted, 'legacy'),
|
||||
filename: 'track_legacy.gpx',
|
||||
fileType: 'gpx'
|
||||
}
|
||||
}
|
||||
return decrypted
|
||||
return decrypted as SavedTrack
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt GPS track:', err)
|
||||
console.error('Failed to decrypt track file:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt and save uploaded GPS track to local Dexie and remote sync
|
||||
export async function saveUploadedGpsTrack(
|
||||
export async function saveUploadedTrack(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
gpxContent: string,
|
||||
waypoints: GpsWaypoint[],
|
||||
fileContent: string,
|
||||
waypoints: TrackWaypoint[],
|
||||
filename: string,
|
||||
fileType: string
|
||||
): Promise<void> {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const trackData: SavedGpsTrack = {
|
||||
const trackData: SavedTrack = {
|
||||
waypoints,
|
||||
gpxContent,
|
||||
gpxContent: fileContent,
|
||||
filename,
|
||||
fileType
|
||||
}
|
||||
|
||||
// Encrypt JSON
|
||||
const encrypted = await encryptJson(trackData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Save to Dexie
|
||||
await db.gpsTracks.put({
|
||||
entryId,
|
||||
logbookId,
|
||||
@@ -81,7 +76,6 @@ export async function saveUploadedGpsTrack(
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Add to Sync queue (payloadId is entryId)
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'gpsTrack',
|
||||
@@ -91,18 +85,14 @@ export async function saveUploadedGpsTrack(
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Trigger sync
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
// Delete GPS track from local DB and sync queue
|
||||
export async function deleteGpsTrack(logbookId: string, entryId: string): Promise<void> {
|
||||
export async function deleteTrack(logbookId: string, entryId: string): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Delete from Dexie
|
||||
await db.gpsTracks.delete(entryId)
|
||||
|
||||
// Add to Sync queue
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'gpsTrack',
|
||||
@@ -112,12 +102,10 @@ export async function deleteGpsTrack(logbookId: string, entryId: string): Promis
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Trigger sync
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
// Download the track file exactly as uploaded
|
||||
export function downloadTrackFile(track: SavedGpsTrack): void {
|
||||
export function downloadTrackFile(track: SavedTrack): void {
|
||||
const blob = new Blob([track.gpxContent], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
@@ -130,24 +118,22 @@ export function downloadTrackFile(track: SavedGpsTrack): void {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Main parser entry point
|
||||
export function parseTrackFile(text: string, filename: string): { waypoints: GpsWaypoint[]; type: string } {
|
||||
export function parseTrackFile(text: string, filename: string): { waypoints: TrackWaypoint[]; type: string } {
|
||||
const lowerName = filename.toLowerCase()
|
||||
if (lowerName.endsWith('.kml') || text.includes('<kml')) {
|
||||
return { waypoints: parseKmlFile(text), type: 'kml' }
|
||||
} else if (lowerName.endsWith('.json') || lowerName.endsWith('.geojson') || text.trim().startsWith('{')) {
|
||||
return { waypoints: parseGeoJsonFile(text), type: 'geojson' }
|
||||
} else {
|
||||
return { waypoints: parseGpxFile(text), type: 'gpx' }
|
||||
}
|
||||
if (lowerName.endsWith('.json') || lowerName.endsWith('.geojson') || text.trim().startsWith('{')) {
|
||||
return { waypoints: parseGeoJsonFile(text), type: 'geojson' }
|
||||
}
|
||||
return { waypoints: parseGpxFile(text), type: 'gpx' }
|
||||
}
|
||||
|
||||
// 1. GPX Parser
|
||||
export function parseGpxFile(gpxText: string): GpsWaypoint[] {
|
||||
function parseGpxFile(gpxText: string): TrackWaypoint[] {
|
||||
const parser = new DOMParser()
|
||||
const xmlDoc = parser.parseFromString(gpxText, 'text/xml')
|
||||
const trackPoints = xmlDoc.getElementsByTagName('trkpt')
|
||||
const waypoints: GpsWaypoint[] = []
|
||||
const waypoints: TrackWaypoint[] = []
|
||||
|
||||
for (let i = 0; i < trackPoints.length; i++) {
|
||||
const el = trackPoints[i]
|
||||
@@ -156,13 +142,13 @@ export function parseGpxFile(gpxText: string): GpsWaypoint[] {
|
||||
if (isNaN(lat) || isNaN(lon)) continue
|
||||
|
||||
const timeEl = el.getElementsByTagName('time')[0]
|
||||
const timestamp = timeEl && timeEl.textContent ? new Date(timeEl.textContent).getTime() : Date.now()
|
||||
const timestamp = timeEl?.textContent ? new Date(timeEl.textContent).getTime() : Date.now()
|
||||
|
||||
const speedEl = el.getElementsByTagName('speed')[0]
|
||||
const speedKnots = speedEl && speedEl.textContent ? parseFloat(speedEl.textContent) * 1.94384 : undefined
|
||||
const speedKnots = speedEl?.textContent ? parseFloat(speedEl.textContent) * 1.94384 : undefined
|
||||
|
||||
const courseEl = el.getElementsByTagName('course')[0] || el.getElementsByTagName('heading')[0]
|
||||
const heading = courseEl && courseEl.textContent ? parseFloat(courseEl.textContent) : undefined
|
||||
const heading = courseEl?.textContent ? parseFloat(courseEl.textContent) : undefined
|
||||
|
||||
waypoints.push({
|
||||
timestamp,
|
||||
@@ -175,18 +161,15 @@ export function parseGpxFile(gpxText: string): GpsWaypoint[] {
|
||||
return waypoints
|
||||
}
|
||||
|
||||
// 2. KML Parser
|
||||
export function parseKmlFile(kmlText: string): GpsWaypoint[] {
|
||||
function parseKmlFile(kmlText: string): TrackWaypoint[] {
|
||||
const parser = new DOMParser()
|
||||
const xmlDoc = parser.parseFromString(kmlText, 'text/xml')
|
||||
const waypoints: GpsWaypoint[] = []
|
||||
const waypoints: TrackWaypoint[] = []
|
||||
|
||||
// Check for standard KML <coordinates> tags
|
||||
const coordsTags = xmlDoc.getElementsByTagName('coordinates')
|
||||
for (let i = 0; i < coordsTags.length; i++) {
|
||||
const text = coordsTags[i].textContent || ''
|
||||
const coordStrings = text.trim().split(/\s+/)
|
||||
for (const str of coordStrings) {
|
||||
for (const str of text.trim().split(/\s+/)) {
|
||||
const parts = str.split(',')
|
||||
if (parts.length >= 2) {
|
||||
const lon = parseFloat(parts[0])
|
||||
@@ -202,22 +185,18 @@ export function parseKmlFile(kmlText: string): GpsWaypoint[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for gx:coord extensions (commonly used in Google Earth tracks)
|
||||
const gxCoords = xmlDoc.getElementsByTagName('gx:coord')
|
||||
if (gxCoords.length > 0) {
|
||||
for (let i = 0; i < gxCoords.length; i++) {
|
||||
const text = gxCoords[i].textContent || ''
|
||||
const parts = text.trim().split(/\s+/)
|
||||
if (parts.length >= 2) {
|
||||
const lon = parseFloat(parts[0])
|
||||
const lat = parseFloat(parts[1])
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
waypoints.push({
|
||||
timestamp: Date.now(),
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lng: Number(lon.toFixed(6))
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < gxCoords.length; i++) {
|
||||
const parts = (gxCoords[i].textContent || '').trim().split(/\s+/)
|
||||
if (parts.length >= 2) {
|
||||
const lon = parseFloat(parts[0])
|
||||
const lat = parseFloat(parts[1])
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
waypoints.push({
|
||||
timestamp: Date.now(),
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lng: Number(lon.toFixed(6))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,9 +204,8 @@ export function parseKmlFile(kmlText: string): GpsWaypoint[] {
|
||||
return waypoints
|
||||
}
|
||||
|
||||
// 3. GeoJSON Parser
|
||||
export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
|
||||
const waypoints: GpsWaypoint[] = []
|
||||
function parseGeoJsonFile(geoJsonText: string): TrackWaypoint[] {
|
||||
const waypoints: TrackWaypoint[] = []
|
||||
try {
|
||||
const data = JSON.parse(geoJsonText)
|
||||
|
||||
@@ -235,40 +213,22 @@ export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
|
||||
if (!geom) return
|
||||
if (geom.type === 'LineString' && Array.isArray(geom.coordinates)) {
|
||||
for (const coord of geom.coordinates) {
|
||||
const lon = coord[0]
|
||||
const lat = coord[1]
|
||||
if (typeof lat === 'number' && typeof lon === 'number') {
|
||||
waypoints.push({
|
||||
timestamp: Date.now(),
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lng: Number(lon.toFixed(6))
|
||||
})
|
||||
}
|
||||
pushCoord(waypoints, coord)
|
||||
}
|
||||
} else if (geom.type === 'MultiLineString' && Array.isArray(geom.coordinates)) {
|
||||
for (const line of geom.coordinates) {
|
||||
if (Array.isArray(line)) {
|
||||
for (const coord of line) {
|
||||
const lon = coord[0]
|
||||
const lat = coord[1]
|
||||
if (typeof lat === 'number' && typeof lon === 'number') {
|
||||
waypoints.push({
|
||||
timestamp: Date.now(),
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lng: Number(lon.toFixed(6))
|
||||
})
|
||||
}
|
||||
pushCoord(waypoints, coord)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (data.type === 'FeatureCollection' && Array.isArray(data.features)) {
|
||||
for (const feature of data.features) {
|
||||
if (feature && feature.geometry) {
|
||||
processGeometry(feature.geometry)
|
||||
}
|
||||
if (feature?.geometry) processGeometry(feature.geometry)
|
||||
}
|
||||
} else if (data.type === 'Feature' && data.geometry) {
|
||||
processGeometry(data.geometry)
|
||||
@@ -282,8 +242,19 @@ export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
|
||||
return waypoints
|
||||
}
|
||||
|
||||
// Generate legacy fallback GPX string
|
||||
function generateLegacyGpxString(waypoints: GpsWaypoint[], dateStr: string): string {
|
||||
function pushCoord(waypoints: TrackWaypoint[], coord: number[]) {
|
||||
const lon = coord[0]
|
||||
const lat = coord[1]
|
||||
if (typeof lat === 'number' && typeof lon === 'number') {
|
||||
waypoints.push({
|
||||
timestamp: Date.now(),
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lng: Number(lon.toFixed(6))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function buildLegacyGpx(waypoints: TrackWaypoint[], dateStr: string): string {
|
||||
const trkpts = waypoints
|
||||
.map((wp) => {
|
||||
const timeISO = new Date(wp.timestamp).toISOString()
|
||||
@@ -294,7 +265,7 @@ function generateLegacyGpxString(waypoints: GpsWaypoint[], dateStr: string): str
|
||||
.join('\n')
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Kapteins Daagbox" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<gpx version="1.1" creator="Kapteins Daagbok" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata>
|
||||
<time>${new Date().toISOString()}</time>
|
||||
</metadata>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export interface TankLevels {
|
||||
morning: number
|
||||
refilled: number
|
||||
evening: number
|
||||
consumption: number
|
||||
}
|
||||
|
||||
export interface TravelDaySortable {
|
||||
date?: string
|
||||
dayOfTravel?: string | number
|
||||
}
|
||||
|
||||
/** Chronological order: date ascending, then day of travel ascending. */
|
||||
export function compareTravelDaysChronological(a: TravelDaySortable, b: TravelDaySortable): number {
|
||||
const dateCompare = new Date(a.date || 0).getTime() - new Date(b.date || 0).getTime()
|
||||
if (dateCompare !== 0) return dateCompare
|
||||
return Number(a.dayOfTravel || 0) - Number(b.dayOfTravel || 0)
|
||||
}
|
||||
|
||||
export function getNextTravelDayNumber(entries: TravelDaySortable[]): string {
|
||||
const maxDay = entries.reduce((max, entry) => Math.max(max, Number(entry.dayOfTravel) || 0), 0)
|
||||
return String(maxDay + 1)
|
||||
}
|
||||
|
||||
/** Closing level at end of travel day: evening stand, else calculated balance, else morning. */
|
||||
export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
|
||||
if (!tank) return 0
|
||||
|
||||
const evening = Number(tank.evening) || 0
|
||||
if (evening > 0) return evening
|
||||
|
||||
const morning = Number(tank.morning) || 0
|
||||
const refilled = Number(tank.refilled) || 0
|
||||
const consumption = Number(tank.consumption) || 0
|
||||
const fromBalance = morning + refilled - consumption
|
||||
if (fromBalance > 0) return fromBalance
|
||||
|
||||
return morning
|
||||
}
|
||||
|
||||
export interface LogEntryTankSource {
|
||||
freshwater?: Partial<TankLevels>
|
||||
fuel?: Partial<TankLevels>
|
||||
}
|
||||
|
||||
export function emptyTankLevels(morning = 0): TankLevels {
|
||||
return { morning, refilled: 0, evening: 0, consumption: 0 }
|
||||
}
|
||||
|
||||
export function formatTankLiters(liters: number): string {
|
||||
if (!Number.isFinite(liters) || liters <= 0) return '0'
|
||||
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
||||
}
|
||||
|
||||
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
|
||||
if (!previousEntry) {
|
||||
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
|
||||
}
|
||||
|
||||
return {
|
||||
freshwater: emptyTankLevels(getClosingTankLevel(previousEntry.freshwater)),
|
||||
fuel: emptyTankLevels(getClosingTankLevel(previousEntry.fuel))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 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 (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
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||
|
||||
const NM_IN_METERS = 1852
|
||||
const MAX_PLAUSIBLE_KNOTS = 50
|
||||
const FALLBACK_GREEN = '#16a34a'
|
||||
|
||||
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000
|
||||
const p1 = (lat1 * Math.PI) / 180
|
||||
const p2 = (lat2 * Math.PI) / 180
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(a))
|
||||
}
|
||||
|
||||
function hasMeaningfulTimestamps(waypoints: TrackWaypoint[]): boolean {
|
||||
if (waypoints.length < 2) return false
|
||||
const first = waypoints[0].timestamp
|
||||
const last = waypoints[waypoints.length - 1].timestamp
|
||||
return last > first + 60_000
|
||||
}
|
||||
|
||||
export function getSegmentSpeedsKn(waypoints: TrackWaypoint[]): number[] {
|
||||
if (waypoints.length < 2) return []
|
||||
|
||||
const timed = hasMeaningfulTimestamps(waypoints)
|
||||
const speeds: number[] = []
|
||||
|
||||
for (let i = 1; i < waypoints.length; i++) {
|
||||
const prev = waypoints[i - 1]
|
||||
const curr = waypoints[i]
|
||||
let speedKn = 0
|
||||
|
||||
const tagged = [prev.speedKnots, curr.speedKnots].filter(
|
||||
(value): value is number => value != null && value > 0
|
||||
)
|
||||
if (tagged.length > 0) {
|
||||
speedKn = tagged.reduce((sum, value) => sum + value, 0) / tagged.length
|
||||
} else if (timed) {
|
||||
const dtMs = curr.timestamp - prev.timestamp
|
||||
const segmentM = haversineMeters(prev.lat, prev.lng, curr.lat, curr.lng)
|
||||
if (dtMs > 0 && segmentM > 0) {
|
||||
speedKn = (segmentM / NM_IN_METERS) / (dtMs / 3_600_000)
|
||||
}
|
||||
}
|
||||
|
||||
if (speedKn > MAX_PLAUSIBLE_KNOTS) speedKn = 0
|
||||
speeds.push(speedKn)
|
||||
}
|
||||
|
||||
return speeds
|
||||
}
|
||||
|
||||
export function hasSpeedGradientData(speeds: number[]): boolean {
|
||||
const valid = speeds.filter((speed) => speed > 0)
|
||||
if (valid.length < 2) return false
|
||||
const min = Math.min(...valid)
|
||||
const max = Math.max(...valid)
|
||||
return max - min >= 0.3
|
||||
}
|
||||
|
||||
/** Green (slow) → yellow → red (fast) */
|
||||
export function speedToTrackColor(speedKn: number, minKn: number, maxKn: number): string {
|
||||
if (speedKn <= 0 || maxKn <= minKn) return FALLBACK_GREEN
|
||||
const t = Math.max(0, Math.min(1, (speedKn - minKn) / (maxKn - minKn)))
|
||||
const hue = 120 - t * 120
|
||||
return `hsl(${hue}, 72%, 42%)`
|
||||
}
|
||||
|
||||
export function getTrackLineColor(speeds: number[]): string {
|
||||
return hasSpeedGradientData(speeds) ? '' : FALLBACK_GREEN
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||
|
||||
const NM_IN_METERS = 1852
|
||||
const MAX_PLAUSIBLE_KNOTS = 50
|
||||
|
||||
export interface TrackStats {
|
||||
distanceNm: number
|
||||
speedMaxKn: number
|
||||
speedAvgKn: number
|
||||
durationMinutes: number
|
||||
}
|
||||
|
||||
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000
|
||||
const p1 = (lat1 * Math.PI) / 180
|
||||
const p2 = (lat2 * Math.PI) / 180
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(a))
|
||||
}
|
||||
|
||||
function hasMeaningfulTimestamps(waypoints: TrackWaypoint[]): boolean {
|
||||
if (waypoints.length < 2) return false
|
||||
const first = waypoints[0].timestamp
|
||||
const last = waypoints[waypoints.length - 1].timestamp
|
||||
return last > first + 60_000
|
||||
}
|
||||
|
||||
export function computeTrackStats(waypoints: TrackWaypoint[]): TrackStats | null {
|
||||
if (waypoints.length < 2) return null
|
||||
|
||||
let totalMeters = 0
|
||||
let maxSegmentKn = 0
|
||||
let maxTaggedKn = 0
|
||||
let hasTaggedSpeed = false
|
||||
|
||||
const timed = hasMeaningfulTimestamps(waypoints)
|
||||
const firstTs = waypoints[0].timestamp
|
||||
const lastTs = waypoints[waypoints.length - 1].timestamp
|
||||
|
||||
for (let i = 1; i < waypoints.length; i++) {
|
||||
const prev = waypoints[i - 1]
|
||||
const curr = waypoints[i]
|
||||
const segmentM = haversineMeters(prev.lat, prev.lng, curr.lat, curr.lng)
|
||||
totalMeters += segmentM
|
||||
|
||||
if (timed) {
|
||||
const dtMs = curr.timestamp - prev.timestamp
|
||||
if (dtMs > 0 && segmentM > 0) {
|
||||
const segmentKn = (segmentM / NM_IN_METERS) / (dtMs / 3_600_000)
|
||||
if (segmentKn <= MAX_PLAUSIBLE_KNOTS) {
|
||||
maxSegmentKn = Math.max(maxSegmentKn, segmentKn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (curr.speedKnots != null && curr.speedKnots > 0) {
|
||||
hasTaggedSpeed = true
|
||||
maxTaggedKn = Math.max(maxTaggedKn, curr.speedKnots)
|
||||
}
|
||||
}
|
||||
|
||||
const distanceNm = totalMeters / NM_IN_METERS
|
||||
if (distanceNm <= 0) return null
|
||||
|
||||
let speedMaxKn = 0
|
||||
let speedAvgKn = 0
|
||||
let durationMinutes = 0
|
||||
|
||||
if (timed) {
|
||||
const durationHours = (lastTs - firstTs) / 3_600_000
|
||||
durationMinutes = Math.round((lastTs - firstTs) / 60_000)
|
||||
speedAvgKn = durationHours > 0 ? distanceNm / durationHours : 0
|
||||
speedMaxKn = Math.max(maxSegmentKn, hasTaggedSpeed ? maxTaggedKn : 0)
|
||||
} else if (hasTaggedSpeed) {
|
||||
const taggedSpeeds = waypoints
|
||||
.map((wp) => wp.speedKnots)
|
||||
.filter((speed): speed is number => speed != null && speed > 0)
|
||||
speedMaxKn = maxTaggedKn
|
||||
speedAvgKn =
|
||||
taggedSpeeds.length > 0
|
||||
? taggedSpeeds.reduce((sum, speed) => sum + speed, 0) / taggedSpeeds.length
|
||||
: 0
|
||||
}
|
||||
|
||||
return {
|
||||
distanceNm: Number(distanceNm.toFixed(2)),
|
||||
speedMaxKn: Number(speedMaxKn.toFixed(1)),
|
||||
speedAvgKn: Number(speedAvgKn.toFixed(1)),
|
||||
durationMinutes
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTrackStats(stats: TrackStats): {
|
||||
distanceNm: string
|
||||
speedMaxKn: string
|
||||
speedAvgKn: string
|
||||
} {
|
||||
return {
|
||||
distanceNm: stats.distanceNm.toFixed(2),
|
||||
speedMaxKn: stats.speedMaxKn > 0 ? stats.speedMaxKn.toFixed(1) : '',
|
||||
speedAvgKn: stats.speedAvgKn > 0 ? stats.speedAvgKn.toFixed(1) : ''
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(readAppVersion())
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['leaflet']
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
@@ -38,8 +41,8 @@ export default defineConfig({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'logo.png'],
|
||||
manifest: {
|
||||
name: 'Kapteins Daagbox',
|
||||
short_name: 'Daagbox',
|
||||
name: 'Kapteins Daagbok',
|
||||
short_name: 'Daagbok',
|
||||
description: 'Digital maritime ship logbook with E2E encryption and Passkeys',
|
||||
theme_color: '#1e293b',
|
||||
background_color: '#0f172a',
|
||||
|
||||
@@ -5,7 +5,7 @@ COMPOSE_FILE="docker-compose.yml"
|
||||
BACKEND_CONTAINER="daagbox-prod-backend"
|
||||
|
||||
echo "=================================================="
|
||||
echo " Kapteins Daagbox Docker Environment Manager "
|
||||
echo " Kapteins Daagbok Docker Environment Manager "
|
||||
echo "=================================================="
|
||||
echo "Stopping any existing container stack..."
|
||||
docker compose -f $COMPOSE_FILE down
|
||||
|
||||
@@ -5,7 +5,7 @@ SERVER_PORT=5000
|
||||
CLIENT_PORT=5173
|
||||
|
||||
echo "========================================"
|
||||
echo " Kapteins Daagbox Dev Environment "
|
||||
echo " Kapteins Daagbok Dev Environment "
|
||||
echo "========================================"
|
||||
echo "Preparing to (re)start services..."
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ VERSION_FILE="$REPO_ROOT/VERSION"
|
||||
DEFAULT_VERSION="0.1.0.0"
|
||||
|
||||
echo "=================================================="
|
||||
echo " Kapteins Daagbox Prod Environment Update "
|
||||
echo " Kapteins Daagbok Prod Environment Update "
|
||||
echo "=================================================="
|
||||
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||
echo "=================================================="
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for Kapteins Daagbox",
|
||||
"description": "Backend API for Kapteins Daagbok",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+4
-2
@@ -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) => {
|
||||
@@ -29,7 +31,7 @@ app.get('/api/health', async (req, res) => {
|
||||
status: 'ok',
|
||||
database: 'connected',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'Kapteins Daagbox Backend'
|
||||
service: 'Kapteins Daagbok Backend'
|
||||
})
|
||||
} catch (err: any) {
|
||||
res.status(500).json({
|
||||
@@ -37,7 +39,7 @@ app.get('/api/health', async (req, res) => {
|
||||
database: 'disconnected',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'Kapteins Daagbox Backend'
|
||||
service: 'Kapteins Daagbok Backend'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import { prisma } from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const rpName = 'Kapteins Daagbox'
|
||||
const rpName = 'Kapteins Daagbok'
|
||||
const rpID = process.env.RP_ID || 'localhost'
|
||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+1274
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user