From 27c780d2b88b3cde764b58e874ef9b5ce0dde6d2 Mon Sep 17 00:00:00 2001 From: elpatron Date: Sun, 31 May 2026 12:57:02 +0200 Subject: [PATCH] fix: Passkey-Signatur beim Speichern der Logbuchseite erhalten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leeres Event-Formular (nur Uhrzeit) galt fälschlich als Änderung und invalidierte frische Signaturen. Speichern-Button und Hash-Sperre folgen nun echten Entwürfen und synchronisiertem Seiteninhalt. Co-authored-by: Cursor --- client/src/components/LogEntryEditor.tsx | 80 ++++++++++++++++++------ client/src/utils/logEntryPayload.test.ts | 42 +++++++++++++ client/src/utils/logEntryPayload.ts | 21 +++++++ 3 files changed, 123 insertions(+), 20 deletions(-) create mode 100644 client/src/utils/logEntryPayload.test.ts diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 0213a5c..69df5a5 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -22,7 +22,7 @@ import { hasAnySignature } from '../utils/signatures.js' import type { SignatureValue } from '../types/signatures.js' -import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js' +import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js' import EventTimeInput24h from './EventTimeInput24h.tsx' import CourseDialInput from './CourseDialInput.tsx' import { degreesToCardinal } from '../utils/courseAngle.js' @@ -202,6 +202,7 @@ export default function LogEntryEditor({ const contentReadyRef = useRef(false) const lastSignatureAlertHashRef = useRef(null) const skipCrewSignClearRef = useRef(false) + const entryHashSeqRef = useRef(0) const [editingEventIndex, setEditingEventIndex] = useState(null) const applyTrackStats = (waypoints: SavedTrack['waypoints']) => { @@ -304,13 +305,7 @@ export default function LogEntryEditor({ } const hasPendingEventForm = useMemo(() => { - if (!evTime.trim()) return false - const draft = buildEventFromForm() - if (editingEventIndex !== null) { - const original = events[editingEventIndex] - return original ? !logEventsEqual(draft, original) : false - } - return true + return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events) }, [ evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState, evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance, @@ -331,16 +326,27 @@ export default function LogEntryEditor({ onBack() } - const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => { + const persistEntryToDb = useCallback(async ( + options?: LogEvent[] | { + eventsOverride?: LogEvent[] + signSkipper?: SignatureValue | '' + signCrew?: SignatureValue | '' + } + ) => { if (readOnly) return + const normalized = Array.isArray(options) ? { eventsOverride: options } : (options ?? {}) + const eventsOverride = normalized.eventsOverride + const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper + const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew + const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') const entryData = { ...buildPayloadForSigning(eventsOverride), - signSkipper: normalizedSerializedSignature(signSkipper), - signCrew: normalizedSerializedSignature(signCrew) + signSkipper: normalizedSerializedSignature(skipperToSave), + signCrew: normalizedSerializedSignature(crewToSave) } const encrypted = await encryptJson(entryData, masterKey) @@ -368,9 +374,14 @@ export default function LogEntryEditor({ setSavedFingerprint(JSON.stringify({ ...buildPayloadForSigning(eventsOverride), - signSkipper: fingerprintSignature(signSkipper), - signCrew: fingerprintSignature(signCrew) + signSkipper: fingerprintSignature(skipperToSave), + signCrew: fingerprintSignature(crewToSave) })) + + const hash = await hashEntryForSigning(buildPayloadForSigning(eventsOverride)) + entryHashSeqRef.current += 1 + setEntryHash(hash) + lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null }, [ readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew ]) @@ -398,9 +409,11 @@ export default function LogEntryEditor({ }, [logbookId]) useEffect(() => { + const seq = ++entryHashSeqRef.current let cancelled = false hashEntryForSigning(buildPayloadForSigning()).then((hash) => { - if (!cancelled) setEntryHash(hash) + if (cancelled || seq !== entryHashSeqRef.current) return + setEntryHash(hash) }) return () => { cancelled = true } }, [buildPayloadForSigning]) @@ -471,6 +484,7 @@ export default function LogEntryEditor({ role: 'skipper' }) setSignSkipper(signature) + entryHashSeqRef.current += 1 setEntryHash(hash) lockedContentHashRef.current = hash trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' }) @@ -489,6 +503,7 @@ export default function LogEntryEditor({ role: 'crew' }) setSignCrew(signature) + entryHashSeqRef.current += 1 setEntryHash(hash) lockedContentHashRef.current = hash trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' }) @@ -921,10 +936,23 @@ export default function LogEntryEditor({ setEvLocationName('') } + const resolveSignaturesAfterContentChange = (skipperOnly = false) => { + const hadSkipper = !!signSkipper + const hadCrew = !!signCrew + const cleared = hadSkipper || (hadCrew && !skipperOnly) + skipCrewSignClearRef.current = skipperOnly + const nextSkipper: SignatureValue | '' = hadSkipper ? '' : signSkipper + const nextCrew: SignatureValue | '' = hadCrew && !skipperOnly ? '' : signCrew + if (cleared) { + if (hadSkipper) setSignSkipper('') + if (hadCrew && !skipperOnly) setSignCrew('') + lockedContentHashRef.current = null + } + return { signSkipper: nextSkipper, signCrew: nextCrew, cleared } + } + const markSkipperSignatureClearedForEventChange = () => { - if (!signSkipper) return - skipCrewSignClearRef.current = true - setSignSkipper('') + resolveSignaturesAfterContentChange(true) } const handleEditEvent = (index: number) => { @@ -1014,11 +1042,20 @@ export default function LogEntryEditor({ if (readOnly) return let eventsToSave = events + let signaturesForSave: { signSkipper: SignatureValue | ''; signCrew: SignatureValue | '' } | undefined if (hasPendingEventForm) { const isEdit = editingEventIndex !== null - if (isEdit && signSkipper) { - markSkipperSignatureClearedForEventChange() + const resolved = resolveSignaturesAfterContentChange(isEdit) + signaturesForSave = { + signSkipper: resolved.signSkipper, + signCrew: resolved.signCrew + } + if (resolved.cleared) { + void showAlertRef.current( + isEdit ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'), + isEdit ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title') + ) } eventsToSave = applyEventFormToEvents(buildEventFromForm()) setEvents(eventsToSave) @@ -1032,7 +1069,10 @@ export default function LogEntryEditor({ setSuccess(false) try { - await persistEntryToDb(eventsToSave) + await persistEntryToDb({ + eventsOverride: eventsToSave, + ...signaturesForSave + }) setSuccess(true) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) diff --git a/client/src/utils/logEntryPayload.test.ts b/client/src/utils/logEntryPayload.test.ts new file mode 100644 index 0000000..0d1249e --- /dev/null +++ b/client/src/utils/logEntryPayload.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { + hasUnsavedEventDraft, + isLogEventDraftEmpty, + normalizeLogEvent, + type LogEventPayload +} from './logEntryPayload.js' + +const emptyDraft = (): LogEventPayload => + normalizeLogEvent({ time: '12:34' }) + +const filledDraft = (): LogEventPayload => + normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' }) + +describe('logEntryPayload event drafts', () => { + it('treats time-only draft as empty', () => { + expect(isLogEventDraftEmpty(emptyDraft())).toBe(true) + }) + + it('detects draft with content', () => { + expect(isLogEventDraftEmpty(filledDraft())).toBe(false) + }) + + it('does not flag empty open form as unsaved', () => { + expect(hasUnsavedEventDraft(emptyDraft(), null, [])).toBe(false) + }) + + it('flags new event draft with content as unsaved', () => { + expect(hasUnsavedEventDraft(filledDraft(), null, [])).toBe(true) + }) + + it('flags edited event when values differ', () => { + const events = [emptyDraft()] + const edited = filledDraft() + expect(hasUnsavedEventDraft(edited, 0, events)).toBe(true) + }) + + it('ignores edit mode when values match', () => { + const events = [filledDraft()] + expect(hasUnsavedEventDraft(filledDraft(), 0, events)).toBe(false) + }) +}) diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts index 5530db8..38e3e17 100644 --- a/client/src/utils/logEntryPayload.ts +++ b/client/src/utils/logEntryPayload.ts @@ -111,6 +111,27 @@ export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean return LOG_EVENT_FIELDS.every((key) => a[key] === b[key]) } +const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time') + +/** Draft with only a time (or empty fields) — not an unsaved log entry change. */ +export function isLogEventDraftEmpty(event: LogEventPayload): boolean { + return LOG_EVENT_CONTENT_FIELDS.every((key) => !event[key]?.trim()) +} + +/** Whether the event form holds unsaved changes worth merging on page save. */ +export function hasUnsavedEventDraft( + draft: LogEventPayload, + editingEventIndex: number | null, + events: LogEventPayload[] +): boolean { + if (!isValidTimeHHMM(draft.time)) return false + if (editingEventIndex !== null) { + const original = events[editingEventIndex] + return original ? !logEventsEqual(draft, original) : false + } + return !isLogEventDraftEmpty(draft) +} + /** Chronological order: earliest time first (HH:MM). */ export function sortLogEventsByTime(events: T[]): T[] { return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))