diff --git a/client/src/App.css b/client/src/App.css index 03cc54b..c5a26f5 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -2220,6 +2220,23 @@ body:has(.theme-cupertino) { 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; diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 3fb4048..e099428 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -15,7 +15,8 @@ import { normalizeSignature, serializeSignature, isPasskeySignature, - isSignatureValidForEntry + isSignatureValidForEntry, + hasAnySignature } from '../utils/signatures.js' import type { SignatureValue } from '../types/signatures.js' import { buildLogEntryPayload } from '../utils/logEntryPayload.js' @@ -73,7 +74,7 @@ export default function LogEntryEditor({ preloadedYacht }: LogEntryEditorProps) { const { t, i18n } = useTranslation() - const { showAlert } = useDialog() + const { showAlert, showConfirm } = useDialog() // General details state const [date, setDate] = useState('') @@ -141,6 +142,8 @@ export default function LogEntryEditor({ const [dragOver, setDragOver] = useState(false) const [uploadError, setUploadError] = useState(null) const fileInputRef = useRef(null) + const lockedContentHashRef = useRef(null) + const contentReadyRef = useRef(false) const applyTrackStats = (waypoints: SavedTrack['waypoints']) => { const stats = computeTrackStats(waypoints) @@ -221,10 +224,56 @@ export default function LogEntryEditor({ 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 => { + 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, @@ -234,9 +283,13 @@ export default function LogEntryEditor({ }) 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, @@ -246,6 +299,7 @@ export default function LogEntryEditor({ }) setSignCrew(signature) setEntryHash(hash) + lockedContentHashRef.current = hash } // Auto-calculate Freshwater Consumption @@ -296,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 || '') @@ -307,11 +363,13 @@ 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(normalizeSignature(preloadedEntry.signSkipper) || '') @@ -337,11 +395,13 @@ 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(normalizeSignature(decrypted.signSkipper) || '') @@ -1404,6 +1464,7 @@ export default function LogEntryEditor({ onSignCrewChange={setSignCrew} onPasskeySignSkipper={handlePasskeySignSkipper} onPasskeySignCrew={handlePasskeySignCrew} + onBeforeSign={confirmSignWarning} /> {/* Save Controls */} diff --git a/client/src/components/SignaturePad.tsx b/client/src/components/SignaturePad.tsx index 331904a..c475c60 100644 --- a/client/src/components/SignaturePad.tsx +++ b/client/src/components/SignaturePad.tsx @@ -10,6 +10,7 @@ interface SignaturePadProps { onChange: (value: string) => void disabled?: boolean readOnly?: boolean + onBeforeSign?: () => Promise | boolean } const STROKE_COLOR = '#0f172a' @@ -21,7 +22,8 @@ export default function SignaturePad({ value, onChange, disabled = false, - readOnly = false + readOnly = false, + onBeforeSign }: SignaturePadProps) { const { t } = useTranslation() const containerRef = useRef(null) @@ -138,9 +140,15 @@ export default function SignaturePad({ onChange(canvas.toDataURL('image/png')) } - const handlePointerDown = (event: React.PointerEvent) => { + const handlePointerDown = async (event: React.PointerEvent) => { 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 diff --git a/client/src/components/SignatureSection.tsx b/client/src/components/SignatureSection.tsx index ed7842c..98d3cf4 100644 --- a/client/src/components/SignatureSection.tsx +++ b/client/src/components/SignatureSection.tsx @@ -22,6 +22,7 @@ interface SignatureSectionProps { onSignCrewChange: (value: SignatureValue | '') => void onPasskeySignSkipper: () => Promise onPasskeySignCrew: () => Promise + onBeforeSign?: () => Promise } function padValue(value: SignatureValue | ''): string { @@ -49,6 +50,7 @@ interface RoleSignatureBlockProps { offlineHint?: string onChange: (value: SignatureValue | '') => void onPasskeySign: () => Promise + onBeforeSign?: () => Promise } function RoleSignatureBlock({ @@ -64,7 +66,8 @@ function RoleSignatureBlock({ classicHint, offlineHint, onChange, - onPasskeySign + onPasskeySign, + onBeforeSign }: RoleSignatureBlockProps) { const { t } = useTranslation() const [mode, setMode] = useState(() => modeFromValue(value, showPasskey)) @@ -166,6 +169,7 @@ function RoleSignatureBlock({ onChange={handlePadChange} disabled={disabled} readOnly={false} + onBeforeSign={onBeforeSign} /> {classicHint && !passkeySignature && (

{classicHint}

@@ -193,12 +197,14 @@ export default function SignatureSection({ onSignSkipperChange, onSignCrewChange, onPasskeySignSkipper, - onPasskeySignCrew + onPasskeySignCrew, + onBeforeSign }: SignatureSectionProps) { const { t } = useTranslation() const showSkipperPasskey = isOwner && isOnline const showCrewPasskey = hasWriteCollaborators && isOnline + const hasSignature = !!(signSkipper || signCrew) return (
@@ -207,6 +213,12 @@ export default function SignatureSection({

{t('logs.signatures')}

+ {!readOnly && ( +

+ {hasSignature ? t('logs.sign_lock_active') : t('logs.sign_lock_notice')} +

+ )} +
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 2d49a93..c9094f1 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -131,6 +131,14 @@ "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", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 6d49237..7615268 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -131,6 +131,14 @@ "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", diff --git a/client/src/utils/signatures.ts b/client/src/utils/signatures.ts index 1e2e53a..4d005ef 100644 --- a/client/src/utils/signatures.ts +++ b/client/src/utils/signatures.ts @@ -20,6 +20,13 @@ export function normalizeSignature(value: unknown): SignatureValue | undefined { 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 }