feat: Hybride Passkey-Freigabe für Skipper und Crew

Skipper (nur Owner) und Crew (WRITE-Collaborators) können Logbuchseiten
optional per WebAuthn freigeben; klassische Unterschrift bleibt als Fallback.
Signatur ist an den Eintrags-Hash gebunden, Export in CSV/PDF angepasst.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:28:52 +02:00
co-authored by Cursor
parent 5706d1762d
commit ce47fe5fdc
18 changed files with 1471 additions and 68 deletions
+127 -57
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
@@ -8,10 +8,20 @@ import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignaturePad from './SignaturePad.tsx'
import SignatureSection from './SignatureSection.tsx'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import { isSignatureImage } from '../utils/signatures.js'
import {
normalizeSignature,
serializeSignature,
isPasskeySignature,
isSignatureValidForEntry
} 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,
@@ -85,8 +95,12 @@ export default function LogEntryEditor({
const [fuelConsumption, setFuelConsumption] = useState('0')
// Signatures
const [signSkipper, setSignSkipper] = useState('')
const [signCrew, setSignCrew] = useState('')
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
const [isOwner, setIsOwner] = 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('')
@@ -149,6 +163,91 @@ export default function LogEntryEditor({
}
}
const buildPayloadForSigning = useCallback(() => {
return buildLogEntryPayload({
date,
dayOfTravel,
departure,
destination,
freshwater: {
morning: parseFloat(fwMorning) || 0,
refilled: parseFloat(fwRefilled) || 0,
evening: parseFloat(fwEvening) || 0,
consumption: parseFloat(fwConsumption) || 0
},
fuel: {
morning: parseFloat(fuelMorning) || 0,
refilled: parseFloat(fuelRefilled) || 0,
evening: parseFloat(fuelEvening) || 0,
consumption: parseFloat(fuelConsumption) || 0
},
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events
})
}, [
date, dayOfTravel, departure, destination,
fwMorning, fwRefilled, fwEvening, fwConsumption,
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn,
events
])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
useEffect(() => {
getLogbookAccess(logbookId).then((access) => {
if (!access) return
setIsOwner(access.isOwner)
setHasWriteCollaborators(access.writeCollaboratorCount > 0)
})
}, [logbookId])
useEffect(() => {
let cancelled = false
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
if (!cancelled) setEntryHash(hash)
})
return () => { cancelled = true }
}, [buildPayloadForSigning])
const skipperSignatureValid = !isPasskeySignature(signSkipper) || isSignatureValidForEntry(signSkipper, entryHash)
const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash)
const handlePasskeySignSkipper = async () => {
const hash = await hashEntryForSigning(buildPayloadForSigning())
const signature = await signLogEntry({
logbookId,
entryId,
entryHash: hash,
role: 'skipper'
})
setSignSkipper(signature)
setEntryHash(hash)
}
const handlePasskeySignCrew = async () => {
const hash = await hashEntryForSigning(buildPayloadForSigning())
const signature = await signLogEntry({
logbookId,
entryId,
entryHash: hash,
role: 'crew'
})
setSignCrew(signature)
setEntryHash(hash)
}
// Auto-calculate Freshwater Consumption
useEffect(() => {
const morning = parseFloat(fwMorning) || 0
@@ -215,8 +314,8 @@ export default function LogEntryEditor({
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
}
setSignSkipper(preloadedEntry.signSkipper || '')
setSignCrew(preloadedEntry.signCrew || '')
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || [])
return
@@ -245,8 +344,8 @@ export default function LogEntryEditor({
setFuelEvening(String(decrypted.fuel.evening || 0))
}
setSignSkipper(decrypted.signSkipper || '')
setSignCrew(decrypted.signCrew || '')
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || [])
}
@@ -591,29 +690,11 @@ export default function LogEntryEditor({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryPayload = buildPayloadForSigning()
const entryData = {
date,
dayOfTravel: dayOfTravel.trim(),
departure: departure.trim(),
destination: destination.trim(),
freshwater: {
morning: parseFloat(fwMorning) || 0,
refilled: parseFloat(fwRefilled) || 0,
evening: parseFloat(fwEvening) || 0,
consumption: parseFloat(fwConsumption) || 0
},
fuel: {
morning: parseFloat(fuelMorning) || 0,
refilled: parseFloat(fuelRefilled) || 0,
evening: parseFloat(fuelEvening) || 0,
consumption: parseFloat(fuelConsumption) || 0
},
signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(),
signCrew: isSignatureImage(signCrew) ? signCrew : signCrew.trim(),
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events
...entryPayload,
signSkipper: serializeSignature(signSkipper),
signCrew: serializeSignature(signCrew)
}
// E2E encrypt
@@ -1309,32 +1390,21 @@ export default function LogEntryEditor({
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
{/* Section 4: Sign-Off Signatures */}
<div className="form-card">
<div className="form-header">
<Check size={20} className="form-icon" />
<h3>{t('logs.signatures')}</h3>
</div>
<div className="form-grid signature-grid">
<SignaturePad
id="sign-skipper"
label={t('logs.sign_skipper')}
value={signSkipper}
onChange={setSignSkipper}
disabled={saving}
readOnly={readOnly}
/>
<SignaturePad
id="sign-crew"
label={t('logs.sign_crew')}
value={signCrew}
onChange={setSignCrew}
disabled={saving}
readOnly={readOnly}
/>
</div>
</div>
<SignatureSection
readOnly={readOnly}
disabled={saving}
isOnline={isOnline}
isOwner={isOwner}
hasWriteCollaborators={hasWriteCollaborators}
signSkipper={signSkipper}
signCrew={signCrew}
skipperSignatureValid={skipperSignatureValid}
crewSignatureValid={crewSignatureValid}
onSignSkipperChange={setSignSkipper}
onSignCrewChange={setSignCrew}
onPasskeySignSkipper={handlePasskeySignSkipper}
onPasskeySignCrew={handlePasskeySignCrew}
/>
{/* Save Controls */}
{!readOnly && (
@@ -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>
)
}
+124
View File
@@ -0,0 +1,124 @@
import { useTranslation } from 'react-i18next'
import { Check } from 'lucide-react'
import SignaturePad from './SignaturePad.tsx'
import PasskeySignButton from './PasskeySignButton.tsx'
import type { SignatureValue } from '../types/signatures.js'
import { isPasskeySignature } from '../utils/signatures.js'
interface SignatureSectionProps {
readOnly?: boolean
disabled?: boolean
isOnline: boolean
isOwner: 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>
}
function padValue(value: SignatureValue | ''): string {
if (!value || isPasskeySignature(value)) return ''
return value
}
export default function SignatureSection({
readOnly = false,
disabled = false,
isOnline,
isOwner,
hasWriteCollaborators,
signSkipper,
signCrew,
skipperSignatureValid,
crewSignatureValid,
onSignSkipperChange,
onSignCrewChange,
onPasskeySignSkipper,
onPasskeySignCrew
}: SignatureSectionProps) {
const { t } = useTranslation()
const skipperPasskey = isPasskeySignature(signSkipper) ? signSkipper : undefined
const crewPasskey = isPasskeySignature(signCrew) ? signCrew : undefined
const showSkipperPasskey = isOwner && isOnline
const showCrewPasskey = hasWriteCollaborators && isOnline
return (
<div className="form-card">
<div className="form-header">
<Check size={20} className="form-icon" />
<h3>{t('logs.signatures')}</h3>
</div>
<div className="form-grid signature-grid">
<div className="signature-role-block">
{showSkipperPasskey && (
<PasskeySignButton
label={t('logs.sign_skipper')}
signature={skipperPasskey}
signatureValid={skipperSignatureValid}
disabled={disabled}
canSign={!readOnly}
onSign={onPasskeySignSkipper}
onClear={skipperPasskey ? () => onSignSkipperChange('') : undefined}
/>
)}
{!skipperPasskey && (
<SignaturePad
id="sign-skipper"
label={t('logs.sign_skipper')}
value={padValue(signSkipper)}
onChange={onSignSkipperChange}
disabled={disabled}
readOnly={readOnly}
/>
)}
{showSkipperPasskey && !skipperPasskey && !readOnly && (
<p className="signature-hint">{t('logs.sign_classic_or_passkey')}</p>
)}
{!isOnline && isOwner && !readOnly && (
<p className="signature-hint">{t('logs.sign_offline_hint')}</p>
)}
</div>
<div className="signature-role-block">
{showCrewPasskey && (
<PasskeySignButton
label={t('logs.sign_crew')}
signature={crewPasskey}
signatureValid={crewSignatureValid}
disabled={disabled}
canSign={!readOnly}
onSign={onPasskeySignCrew}
onClear={crewPasskey ? () => onSignCrewChange('') : undefined}
/>
)}
{!crewPasskey && (
<SignaturePad
id="sign-crew"
label={t('logs.sign_crew')}
value={padValue(signCrew)}
onChange={onSignCrewChange}
disabled={disabled}
readOnly={readOnly}
/>
)}
{showCrewPasskey && !crewPasskey && !readOnly && (
<p className="signature-hint">{t('logs.sign_crew_passkey_hint')}</p>
)}
</div>
</div>
</div>
)
}