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:
@@ -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;
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const lockedContentHashRef = useRef<string | null>(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<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 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 */}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SignaturePadProps {
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
onBeforeSign?: () => Promise<boolean> | 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<HTMLDivElement>(null)
|
||||
@@ -138,9 +140,15 @@ export default function SignaturePad({
|
||||
onChange(canvas.toDataURL('image/png'))
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const handlePointerDown = async (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
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
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ interface SignatureSectionProps {
|
||||
onSignCrewChange: (value: SignatureValue | '') => void
|
||||
onPasskeySignSkipper: () => Promise<void>
|
||||
onPasskeySignCrew: () => Promise<void>
|
||||
onBeforeSign?: () => Promise<boolean>
|
||||
}
|
||||
|
||||
function padValue(value: SignatureValue | ''): string {
|
||||
@@ -49,6 +50,7 @@ interface RoleSignatureBlockProps {
|
||||
offlineHint?: string
|
||||
onChange: (value: SignatureValue | '') => void
|
||||
onPasskeySign: () => Promise<void>
|
||||
onBeforeSign?: () => Promise<boolean>
|
||||
}
|
||||
|
||||
function RoleSignatureBlock({
|
||||
@@ -64,7 +66,8 @@ function RoleSignatureBlock({
|
||||
classicHint,
|
||||
offlineHint,
|
||||
onChange,
|
||||
onPasskeySign
|
||||
onPasskeySign,
|
||||
onBeforeSign
|
||||
}: RoleSignatureBlockProps) {
|
||||
const { t } = useTranslation()
|
||||
const [mode, setMode] = useState<SignatureMode>(() => modeFromValue(value, showPasskey))
|
||||
@@ -166,6 +169,7 @@ function RoleSignatureBlock({
|
||||
onChange={handlePadChange}
|
||||
disabled={disabled}
|
||||
readOnly={false}
|
||||
onBeforeSign={onBeforeSign}
|
||||
/>
|
||||
{classicHint && !passkeySignature && (
|
||||
<p className="signature-hint">{classicHint}</p>
|
||||
@@ -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 (
|
||||
<div className="form-card">
|
||||
@@ -207,6 +213,12 @@ export default function SignatureSection({
|
||||
<h3>{t('logs.signatures')}</h3>
|
||||
</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">
|
||||
<RoleSignatureBlock
|
||||
roleLabel={t('logs.sign_skipper')}
|
||||
@@ -222,6 +234,7 @@ export default function SignatureSection({
|
||||
offlineHint={!isOnline && isOwner ? t('logs.sign_offline_hint') : undefined}
|
||||
onChange={onSignSkipperChange}
|
||||
onPasskeySign={onPasskeySignSkipper}
|
||||
onBeforeSign={onBeforeSign}
|
||||
/>
|
||||
|
||||
<RoleSignatureBlock
|
||||
@@ -237,6 +250,7 @@ export default function SignatureSection({
|
||||
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
|
||||
onChange={onSignCrewChange}
|
||||
onPasskeySign={onPasskeySignCrew}
|
||||
onBeforeSign={onBeforeSign}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user