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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user