feat: Unterschriften bei Logbuchänderungen invalidieren

Änderungen am Eintrag (außer Fotos) entfernen Skipper- und Crew-Signaturen
automatisch. Vor dem Unterschreiben erscheinen Hinweis-Banner und Bestätigung.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:45:02 +02:00
parent 878a18e9f7
commit 81da01e786
7 changed files with 129 additions and 6 deletions
+17
View File
@@ -2220,6 +2220,23 @@ body:has(.theme-cupertino) {
background: rgba(255, 255, 255, 0.06); 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 { .passkey-sign-block {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+63 -2
View File
@@ -15,7 +15,8 @@ import {
normalizeSignature, normalizeSignature,
serializeSignature, serializeSignature,
isPasskeySignature, isPasskeySignature,
isSignatureValidForEntry isSignatureValidForEntry,
hasAnySignature
} from '../utils/signatures.js' } from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js' import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload } from '../utils/logEntryPayload.js' import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
@@ -73,7 +74,7 @@ export default function LogEntryEditor({
preloadedYacht preloadedYacht
}: LogEntryEditorProps) { }: LogEntryEditorProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { showAlert } = useDialog() const { showAlert, showConfirm } = useDialog()
// General details state // General details state
const [date, setDate] = useState('') const [date, setDate] = useState('')
@@ -141,6 +142,8 @@ export default function LogEntryEditor({
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null) const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => { const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(waypoints) const stats = computeTrackStats(waypoints)
@@ -221,10 +224,56 @@ export default function LogEntryEditor({
return () => { cancelled = true } return () => { cancelled = true }
}, [buildPayloadForSigning]) }, [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 skipperSignatureValid = !isPasskeySignature(signSkipper) || isSignatureValidForEntry(signSkipper, entryHash)
const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash) const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash)
const handlePasskeySignSkipper = async () => { const handlePasskeySignSkipper = async () => {
const confirmed = await confirmSignWarning()
if (!confirmed) return
const hash = await hashEntryForSigning(buildPayloadForSigning()) const hash = await hashEntryForSigning(buildPayloadForSigning())
const signature = await signLogEntry({ const signature = await signLogEntry({
logbookId, logbookId,
@@ -234,9 +283,13 @@ export default function LogEntryEditor({
}) })
setSignSkipper(signature) setSignSkipper(signature)
setEntryHash(hash) setEntryHash(hash)
lockedContentHashRef.current = hash
} }
const handlePasskeySignCrew = async () => { const handlePasskeySignCrew = async () => {
const confirmed = await confirmSignWarning()
if (!confirmed) return
const hash = await hashEntryForSigning(buildPayloadForSigning()) const hash = await hashEntryForSigning(buildPayloadForSigning())
const signature = await signLogEntry({ const signature = await signLogEntry({
logbookId, logbookId,
@@ -246,6 +299,7 @@ export default function LogEntryEditor({
}) })
setSignCrew(signature) setSignCrew(signature)
setEntryHash(hash) setEntryHash(hash)
lockedContentHashRef.current = hash
} }
// Auto-calculate Freshwater Consumption // Auto-calculate Freshwater Consumption
@@ -296,6 +350,8 @@ export default function LogEntryEditor({
async function loadEntry() { async function loadEntry() {
setLoading(true) setLoading(true)
setError(null) setError(null)
lockedContentHashRef.current = null
contentReadyRef.current = false
try { try {
if (readOnly && preloadedEntry) { if (readOnly && preloadedEntry) {
setDate(preloadedEntry.date || '') setDate(preloadedEntry.date || '')
@@ -307,11 +363,13 @@ export default function LogEntryEditor({
setFwMorning(String(preloadedEntry.freshwater.morning || 0)) setFwMorning(String(preloadedEntry.freshwater.morning || 0))
setFwRefilled(String(preloadedEntry.freshwater.refilled || 0)) setFwRefilled(String(preloadedEntry.freshwater.refilled || 0))
setFwEvening(String(preloadedEntry.freshwater.evening || 0)) setFwEvening(String(preloadedEntry.freshwater.evening || 0))
setFwConsumption(String(preloadedEntry.freshwater.consumption ?? 0))
} }
if (preloadedEntry.fuel) { if (preloadedEntry.fuel) {
setFuelMorning(String(preloadedEntry.fuel.morning || 0)) setFuelMorning(String(preloadedEntry.fuel.morning || 0))
setFuelRefilled(String(preloadedEntry.fuel.refilled || 0)) setFuelRefilled(String(preloadedEntry.fuel.refilled || 0))
setFuelEvening(String(preloadedEntry.fuel.evening || 0)) setFuelEvening(String(preloadedEntry.fuel.evening || 0))
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
} }
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '') setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
@@ -337,11 +395,13 @@ export default function LogEntryEditor({
setFwMorning(String(decrypted.freshwater.morning || 0)) setFwMorning(String(decrypted.freshwater.morning || 0))
setFwRefilled(String(decrypted.freshwater.refilled || 0)) setFwRefilled(String(decrypted.freshwater.refilled || 0))
setFwEvening(String(decrypted.freshwater.evening || 0)) setFwEvening(String(decrypted.freshwater.evening || 0))
setFwConsumption(String(decrypted.freshwater.consumption ?? 0))
} }
if (decrypted.fuel) { if (decrypted.fuel) {
setFuelMorning(String(decrypted.fuel.morning || 0)) setFuelMorning(String(decrypted.fuel.morning || 0))
setFuelRefilled(String(decrypted.fuel.refilled || 0)) setFuelRefilled(String(decrypted.fuel.refilled || 0))
setFuelEvening(String(decrypted.fuel.evening || 0)) setFuelEvening(String(decrypted.fuel.evening || 0))
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
} }
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '') setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
@@ -1404,6 +1464,7 @@ export default function LogEntryEditor({
onSignCrewChange={setSignCrew} onSignCrewChange={setSignCrew}
onPasskeySignSkipper={handlePasskeySignSkipper} onPasskeySignSkipper={handlePasskeySignSkipper}
onPasskeySignCrew={handlePasskeySignCrew} onPasskeySignCrew={handlePasskeySignCrew}
onBeforeSign={confirmSignWarning}
/> />
{/* Save Controls */} {/* Save Controls */}
+10 -2
View File
@@ -10,6 +10,7 @@ interface SignaturePadProps {
onChange: (value: string) => void onChange: (value: string) => void
disabled?: boolean disabled?: boolean
readOnly?: boolean readOnly?: boolean
onBeforeSign?: () => Promise<boolean> | boolean
} }
const STROKE_COLOR = '#0f172a' const STROKE_COLOR = '#0f172a'
@@ -21,7 +22,8 @@ export default function SignaturePad({
value, value,
onChange, onChange,
disabled = false, disabled = false,
readOnly = false readOnly = false,
onBeforeSign
}: SignaturePadProps) { }: SignaturePadProps) {
const { t } = useTranslation() const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@@ -138,9 +140,15 @@ export default function SignaturePad({
onChange(canvas.toDataURL('image/png')) onChange(canvas.toDataURL('image/png'))
} }
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => { const handlePointerDown = async (event: React.PointerEvent<HTMLCanvasElement>) => {
if (readOnly || disabled) return if (readOnly || disabled) return
event.preventDefault() event.preventDefault()
if (!value && !hasInk.current && onBeforeSign) {
const allowed = await onBeforeSign()
if (!allowed) return
}
const point = getPoint(event) const point = getPoint(event)
if (!point) return if (!point) return
+16 -2
View File
@@ -22,6 +22,7 @@ interface SignatureSectionProps {
onSignCrewChange: (value: SignatureValue | '') => void onSignCrewChange: (value: SignatureValue | '') => void
onPasskeySignSkipper: () => Promise<void> onPasskeySignSkipper: () => Promise<void>
onPasskeySignCrew: () => Promise<void> onPasskeySignCrew: () => Promise<void>
onBeforeSign?: () => Promise<boolean>
} }
function padValue(value: SignatureValue | ''): string { function padValue(value: SignatureValue | ''): string {
@@ -49,6 +50,7 @@ interface RoleSignatureBlockProps {
offlineHint?: string offlineHint?: string
onChange: (value: SignatureValue | '') => void onChange: (value: SignatureValue | '') => void
onPasskeySign: () => Promise<void> onPasskeySign: () => Promise<void>
onBeforeSign?: () => Promise<boolean>
} }
function RoleSignatureBlock({ function RoleSignatureBlock({
@@ -64,7 +66,8 @@ function RoleSignatureBlock({
classicHint, classicHint,
offlineHint, offlineHint,
onChange, onChange,
onPasskeySign onPasskeySign,
onBeforeSign
}: RoleSignatureBlockProps) { }: RoleSignatureBlockProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [mode, setMode] = useState<SignatureMode>(() => modeFromValue(value, showPasskey)) const [mode, setMode] = useState<SignatureMode>(() => modeFromValue(value, showPasskey))
@@ -166,6 +169,7 @@ function RoleSignatureBlock({
onChange={handlePadChange} onChange={handlePadChange}
disabled={disabled} disabled={disabled}
readOnly={false} readOnly={false}
onBeforeSign={onBeforeSign}
/> />
{classicHint && !passkeySignature && ( {classicHint && !passkeySignature && (
<p className="signature-hint">{classicHint}</p> <p className="signature-hint">{classicHint}</p>
@@ -193,12 +197,14 @@ export default function SignatureSection({
onSignSkipperChange, onSignSkipperChange,
onSignCrewChange, onSignCrewChange,
onPasskeySignSkipper, onPasskeySignSkipper,
onPasskeySignCrew onPasskeySignCrew,
onBeforeSign
}: SignatureSectionProps) { }: SignatureSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const showSkipperPasskey = isOwner && isOnline const showSkipperPasskey = isOwner && isOnline
const showCrewPasskey = hasWriteCollaborators && isOnline const showCrewPasskey = hasWriteCollaborators && isOnline
const hasSignature = !!(signSkipper || signCrew)
return ( return (
<div className="form-card"> <div className="form-card">
@@ -207,6 +213,12 @@ export default function SignatureSection({
<h3>{t('logs.signatures')}</h3> <h3>{t('logs.signatures')}</h3>
</div> </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"> <div className="form-grid signature-grid">
<RoleSignatureBlock <RoleSignatureBlock
roleLabel={t('logs.sign_skipper')} roleLabel={t('logs.sign_skipper')}
@@ -222,6 +234,7 @@ export default function SignatureSection({
offlineHint={!isOnline && isOwner ? t('logs.sign_offline_hint') : undefined} offlineHint={!isOnline && isOwner ? t('logs.sign_offline_hint') : undefined}
onChange={onSignSkipperChange} onChange={onSignSkipperChange}
onPasskeySign={onPasskeySignSkipper} onPasskeySign={onPasskeySignSkipper}
onBeforeSign={onBeforeSign}
/> />
<RoleSignatureBlock <RoleSignatureBlock
@@ -237,6 +250,7 @@ export default function SignatureSection({
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined} classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
onChange={onSignCrewChange} onChange={onSignCrewChange}
onPasskeySign={onPasskeySignCrew} onPasskeySign={onPasskeySignCrew}
onBeforeSign={onBeforeSign}
/> />
</div> </div>
</div> </div>
+8
View File
@@ -131,6 +131,14 @@
"sign_classic_or_passkey": "Optional: klassisch unterschreiben oder Passkey-Freigabe oben", "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_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_offline_hint": "Passkey-Freigabe erfordert Internet — klassische Unterschrift offline möglich",
"sign_lock_notice": "Nach der Unterschrift sind Änderungen am Logbucheintrag (außer Fotos) nicht möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.",
"sign_lock_active": "Dieser Eintrag ist unterschrieben. Änderungen am Logbuch (außer Fotos) entfernen Skipper- und Crew-Unterschrift automatisch.",
"sign_lock_warning_title": "Unterschrift bestätigen",
"sign_lock_warning": "Nach dem Unterschreiben sind Änderungen am Logbucheintrag (außer Fotos) nicht mehr möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.\n\nMöchten Sie fortfahren?",
"sign_proceed": "Unterschreiben",
"sign_cancel": "Abbrechen",
"sign_cleared_re_sign_title": "Unterschriften entfernt",
"sign_cleared_re_sign": "Der Logbucheintrag wurde geändert. Skipper- und Crew-Unterschrift wurden entfernt. Bitte erneut unterschreiben.",
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!", "no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
"back_to_list": "Zurück zur Journal-Liste", "back_to_list": "Zurück zur Journal-Liste",
"save": "Logbuchseite speichern", "save": "Logbuchseite speichern",
+8
View File
@@ -131,6 +131,14 @@
"sign_classic_or_passkey": "Optional: sign classically below or use Passkey above", "sign_classic_or_passkey": "Optional: sign classically below or use Passkey above",
"sign_crew_passkey_hint": "Write collaborators can sign with their Passkey", "sign_crew_passkey_hint": "Write collaborators can sign with their Passkey",
"sign_offline_hint": "Passkey signing requires internet — classic signature works offline", "sign_offline_hint": "Passkey signing requires internet — classic signature works offline",
"sign_lock_notice": "After signing, log entry changes (except photos) require Skipper and Crew to sign again.",
"sign_lock_active": "This entry is signed. Changes to the log (except photos) will automatically remove Skipper and Crew signatures.",
"sign_lock_warning_title": "Confirm signature",
"sign_lock_warning": "After signing, changes to the log entry (except photos) are not possible without Skipper and Crew signing again.\n\nDo you want to proceed?",
"sign_proceed": "Sign",
"sign_cancel": "Cancel",
"sign_cleared_re_sign_title": "Signatures removed",
"sign_cleared_re_sign": "The log entry was changed. Skipper and Crew signatures were removed. Please sign again.",
"no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!", "no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!",
"back_to_list": "Back to Journal List", "back_to_list": "Back to Journal List",
"save": "Save Logbook Page", "save": "Save Logbook Page",
+7
View File
@@ -20,6 +20,13 @@ export function normalizeSignature(value: unknown): SignatureValue | undefined {
return undefined return undefined
} }
export function hasAnySignature(
skipper: SignatureValue | '' | undefined,
crew: SignatureValue | '' | undefined
): boolean {
return !!(skipper || crew)
}
export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: string): boolean { export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: string): boolean {
return sig.entryHash === entryHash return sig.entryHash === entryHash
} }