fix: Passkey-Signatur beim Speichern der Logbuchseite erhalten
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -22,7 +22,7 @@ import {
|
|||||||
hasAnySignature
|
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, 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 EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||||
import CourseDialInput from './CourseDialInput.tsx'
|
import CourseDialInput from './CourseDialInput.tsx'
|
||||||
import { degreesToCardinal } from '../utils/courseAngle.js'
|
import { degreesToCardinal } from '../utils/courseAngle.js'
|
||||||
@@ -202,6 +202,7 @@ export default function LogEntryEditor({
|
|||||||
const contentReadyRef = useRef(false)
|
const contentReadyRef = useRef(false)
|
||||||
const lastSignatureAlertHashRef = useRef<string | null>(null)
|
const lastSignatureAlertHashRef = useRef<string | null>(null)
|
||||||
const skipCrewSignClearRef = useRef(false)
|
const skipCrewSignClearRef = useRef(false)
|
||||||
|
const entryHashSeqRef = useRef(0)
|
||||||
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
|
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
||||||
@@ -304,13 +305,7 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasPendingEventForm = useMemo(() => {
|
const hasPendingEventForm = useMemo(() => {
|
||||||
if (!evTime.trim()) return false
|
return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events)
|
||||||
const draft = buildEventFromForm()
|
|
||||||
if (editingEventIndex !== null) {
|
|
||||||
const original = events[editingEventIndex]
|
|
||||||
return original ? !logEventsEqual(draft, original) : false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}, [
|
}, [
|
||||||
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
||||||
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
||||||
@@ -331,16 +326,27 @@ export default function LogEntryEditor({
|
|||||||
onBack()
|
onBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
|
const persistEntryToDb = useCallback(async (
|
||||||
|
options?: LogEvent[] | {
|
||||||
|
eventsOverride?: LogEvent[]
|
||||||
|
signSkipper?: SignatureValue | ''
|
||||||
|
signCrew?: SignatureValue | ''
|
||||||
|
}
|
||||||
|
) => {
|
||||||
if (readOnly) return
|
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()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
const entryData = {
|
const entryData = {
|
||||||
...buildPayloadForSigning(eventsOverride),
|
...buildPayloadForSigning(eventsOverride),
|
||||||
signSkipper: normalizedSerializedSignature(signSkipper),
|
signSkipper: normalizedSerializedSignature(skipperToSave),
|
||||||
signCrew: normalizedSerializedSignature(signCrew)
|
signCrew: normalizedSerializedSignature(crewToSave)
|
||||||
}
|
}
|
||||||
|
|
||||||
const encrypted = await encryptJson(entryData, masterKey)
|
const encrypted = await encryptJson(entryData, masterKey)
|
||||||
@@ -368,9 +374,14 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
setSavedFingerprint(JSON.stringify({
|
setSavedFingerprint(JSON.stringify({
|
||||||
...buildPayloadForSigning(eventsOverride),
|
...buildPayloadForSigning(eventsOverride),
|
||||||
signSkipper: fingerprintSignature(signSkipper),
|
signSkipper: fingerprintSignature(skipperToSave),
|
||||||
signCrew: fingerprintSignature(signCrew)
|
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
|
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
|
||||||
])
|
])
|
||||||
@@ -398,9 +409,11 @@ export default function LogEntryEditor({
|
|||||||
}, [logbookId])
|
}, [logbookId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const seq = ++entryHashSeqRef.current
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
|
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
|
||||||
if (!cancelled) setEntryHash(hash)
|
if (cancelled || seq !== entryHashSeqRef.current) return
|
||||||
|
setEntryHash(hash)
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [buildPayloadForSigning])
|
}, [buildPayloadForSigning])
|
||||||
@@ -471,6 +484,7 @@ export default function LogEntryEditor({
|
|||||||
role: 'skipper'
|
role: 'skipper'
|
||||||
})
|
})
|
||||||
setSignSkipper(signature)
|
setSignSkipper(signature)
|
||||||
|
entryHashSeqRef.current += 1
|
||||||
setEntryHash(hash)
|
setEntryHash(hash)
|
||||||
lockedContentHashRef.current = hash
|
lockedContentHashRef.current = hash
|
||||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||||
@@ -489,6 +503,7 @@ export default function LogEntryEditor({
|
|||||||
role: 'crew'
|
role: 'crew'
|
||||||
})
|
})
|
||||||
setSignCrew(signature)
|
setSignCrew(signature)
|
||||||
|
entryHashSeqRef.current += 1
|
||||||
setEntryHash(hash)
|
setEntryHash(hash)
|
||||||
lockedContentHashRef.current = hash
|
lockedContentHashRef.current = hash
|
||||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
|
||||||
@@ -921,10 +936,23 @@ export default function LogEntryEditor({
|
|||||||
setEvLocationName('')
|
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 = () => {
|
const markSkipperSignatureClearedForEventChange = () => {
|
||||||
if (!signSkipper) return
|
resolveSignaturesAfterContentChange(true)
|
||||||
skipCrewSignClearRef.current = true
|
|
||||||
setSignSkipper('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditEvent = (index: number) => {
|
const handleEditEvent = (index: number) => {
|
||||||
@@ -1014,11 +1042,20 @@ export default function LogEntryEditor({
|
|||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
|
|
||||||
let eventsToSave = events
|
let eventsToSave = events
|
||||||
|
let signaturesForSave: { signSkipper: SignatureValue | ''; signCrew: SignatureValue | '' } | undefined
|
||||||
|
|
||||||
if (hasPendingEventForm) {
|
if (hasPendingEventForm) {
|
||||||
const isEdit = editingEventIndex !== null
|
const isEdit = editingEventIndex !== null
|
||||||
if (isEdit && signSkipper) {
|
const resolved = resolveSignaturesAfterContentChange(isEdit)
|
||||||
markSkipperSignatureClearedForEventChange()
|
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())
|
eventsToSave = applyEventFormToEvents(buildEventFromForm())
|
||||||
setEvents(eventsToSave)
|
setEvents(eventsToSave)
|
||||||
@@ -1032,7 +1069,10 @@ export default function LogEntryEditor({
|
|||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await persistEntryToDb(eventsToSave)
|
await persistEntryToDb({
|
||||||
|
eventsOverride: eventsToSave,
|
||||||
|
...signaturesForSave
|
||||||
|
})
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -111,6 +111,27 @@ export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean
|
|||||||
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
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). */
|
/** Chronological order: earliest time first (HH:MM). */
|
||||||
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
|
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
|
||||||
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
||||||
|
|||||||
Reference in New Issue
Block a user