From 4acb9b129027890af1d4c02c8157847dcc8dce68 Mon Sep 17 00:00:00 2001 From: elpatron Date: Sat, 30 May 2026 19:24:46 +0200 Subject: [PATCH] fix(logs): Crew-Unterschrift mit Benutzerzuordnung und Owner-Crew-Signatur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Klassische Crew-Signaturen speichern Unterzeichner und Datum; Export und UI zeigen die Zuordnung. Eigner ohne WRITE-Collaborators dürfen wieder als Crew per Passkey signieren. Co-authored-by: Cursor --- client/src/components/LogEntryEditor.tsx | 27 +++++++++- client/src/components/SignatureSection.tsx | 28 ++++++++-- client/src/i18n/locales/de.json | 1 + client/src/i18n/locales/en.json | 1 + client/src/services/csvExport.ts | 4 ++ client/src/services/pdfExport.ts | 14 +++-- client/src/types/signatures.ts | 15 +++++- client/src/utils/signatures.ts | 61 ++++++++++++++++++++-- server/src/routes/sign.ts | 30 +++++++++-- 9 files changed, 161 insertions(+), 20 deletions(-) diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 1624679..57c0bf0 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -16,6 +16,8 @@ import { fingerprintSignature, normalizedSerializedSignature, isPasskeySignature, + isClassicSignature, + createClassicSignature, isSignatureValidForEntry, hasAnySignature } from '../utils/signatures.js' @@ -1711,7 +1713,30 @@ export default function LogEntryEditor({ if (canSignSkipper && !readOnly) setSignSkipper(value) }} onSignCrewChange={(value) => { - if (canSignCrew && !readOnly) setSignCrew(value) + if (!canSignCrew || readOnly) return + if (!value) { + setSignCrew('') + return + } + if (isPasskeySignature(value) || isClassicSignature(value)) { + setSignCrew(value) + return + } + if (!canSignSkipper) { + const userId = localStorage.getItem('active_userid') || '' + const username = localStorage.getItem('active_username') || '' + if (userId && username) { + setSignCrew(createClassicSignature({ + role: 'crew', + userId, + username, + signedAt: new Date().toISOString(), + payload: value + })) + return + } + } + setSignCrew(value) }} onPasskeySignSkipper={handlePasskeySignSkipper} onPasskeySignCrew={handlePasskeySignCrew} diff --git a/client/src/components/SignatureSection.tsx b/client/src/components/SignatureSection.tsx index dbf7f59..fb8484b 100644 --- a/client/src/components/SignatureSection.tsx +++ b/client/src/components/SignatureSection.tsx @@ -4,7 +4,7 @@ import { Check } from 'lucide-react' import SignaturePad from './SignaturePad.tsx' import PasskeySignButton from './PasskeySignButton.tsx' import type { PasskeySignature, SignatureValue } from '../types/signatures.js' -import { isPasskeySignature } from '../utils/signatures.js' +import { isPasskeySignature, getSignaturePayload, getSignatureAttribution } from '../utils/signatures.js' type SignatureMode = 'passkey' | 'classic' @@ -25,14 +25,30 @@ interface SignatureSectionProps { onBeforeSign?: () => Promise } +function SignerAttributionBadge({ value }: { value: SignatureValue | '' }) { + const { t, i18n } = useTranslation() + const attribution = getSignatureAttribution(value) + if (!attribution) return null + + const formattedDate = new Date(attribution.signedAt).toLocaleString( + i18n.language === 'de' ? 'de-DE' : 'en-GB' + ) + + return ( +
+ {t('logs.sign_passkey_signed', { username: attribution.username })} + {formattedDate} +
+ ) +} + function padValue(value: SignatureValue | ''): string { - if (!value || isPasskeySignature(value)) return '' - return value + return getSignaturePayload(value) } function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode { if (isPasskeySignature(value)) return 'passkey' - if (value) return 'classic' + if (getSignaturePayload(value)) return 'classic' return passkeyAvailable ? 'passkey' : 'classic' } @@ -108,6 +124,7 @@ function RoleSignatureBlock({ } return (
+ + { const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB') return i18n.t('logs.sign_passkey_export', { username, date }) + }, + attributionLabel: (username: string, signedAt: string) => { + const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB') + return i18n.t('logs.sign_attribution_export', { username, date }) } }; diff --git a/client/src/services/pdfExport.ts b/client/src/services/pdfExport.ts index bb2c4f5..db5179d 100644 --- a/client/src/services/pdfExport.ts +++ b/client/src/services/pdfExport.ts @@ -3,7 +3,7 @@ import { db } from './db.js' import { getActiveMasterKey } from './auth.js' import { getLogbookKey } from './logbookKeys.js' import { decryptJson } from './crypto.js' -import { isSignatureImage, isPasskeySignature } from '../utils/signatures.js' +import { isSignatureImage, isPasskeySignature, isClassicSignature, getSignaturePayload } from '../utils/signatures.js' import { sortLogEventsByTime } from '../utils/logEntryPayload.js' import i18n from '../i18n/index.js' @@ -256,8 +256,16 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string, const crewDate = formatPasskeySignDate(entry.signCrew.signedAt); doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9); doc.text(crewDate, sigX + 80.5, sigY + 13.5); - } else if (isSignatureImage(entry.signCrew)) { - doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14) + } else if (isClassicSignature(entry.signCrew)) { + doc.setFont('Helvetica', 'normal'); + const crewDate = formatPasskeySignDate(entry.signCrew.signedAt); + doc.text(entry.signCrew.username, sigX + 80.5, sigY + 9); + doc.text(crewDate, sigX + 80.5, sigY + 13.5); + if (isSignatureImage(entry.signCrew.payload)) { + doc.addImage(entry.signCrew.payload, 'PNG', sigX + 80.5, sigY + 6, 72, 14) + } + } else if (isSignatureImage(getSignaturePayload(entry.signCrew))) { + doc.addImage(getSignaturePayload(entry.signCrew), 'PNG', sigX + 80.5, sigY + 6, 72, 14) } else { doc.setFont('Helvetica', 'normal'); doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2); diff --git a/client/src/types/signatures.ts b/client/src/types/signatures.ts index 8190a91..8ae9881 100644 --- a/client/src/types/signatures.ts +++ b/client/src/types/signatures.ts @@ -11,5 +11,16 @@ export interface PasskeySignature { clientVerified: boolean } -/** Legacy: PNG data URL oder getippter Name */ -export type SignatureValue = string | PasskeySignature +/** Klassische Unterschrift mit Benutzer-Zuordnung (Crew) */ +export interface ClassicSignature { + kind: 'classic' + version: 1 + role: 'skipper' | 'crew' + userId: string + username: string + signedAt: string + payload: string +} + +/** Legacy: PNG data URL oder getippter Name; oder strukturierte Signaturen */ +export type SignatureValue = string | PasskeySignature | ClassicSignature diff --git a/client/src/utils/signatures.ts b/client/src/utils/signatures.ts index 25978a3..baaf51c 100644 --- a/client/src/utils/signatures.ts +++ b/client/src/utils/signatures.ts @@ -1,8 +1,13 @@ import { hashEntryForSigning } from './entryCanonicalHash.js' -import type { PasskeySignature, SignatureValue } from '../types/signatures.js' +import type { ClassicSignature, PasskeySignature, SignatureValue } from '../types/signatures.js' export type SkipperSignStatus = 'none' | 'valid' | 'invalid' +export interface SignatureAttribution { + username: string + signedAt: string +} + export function isSignatureImage(value: string | undefined | null): boolean { return typeof value === 'string' && value.startsWith('data:image/') } @@ -16,9 +21,52 @@ export function isPasskeySignature(value: unknown): value is PasskeySignature { ) } +export function isClassicSignature(value: unknown): value is ClassicSignature { + return ( + typeof value === 'object' && + value !== null && + (value as ClassicSignature).kind === 'classic' && + (value as ClassicSignature).version === 1 + ) +} + +export function getSignaturePayload(value: SignatureValue | '' | undefined | null): string { + if (!value) return '' + if (isClassicSignature(value)) return value.payload + if (isPasskeySignature(value)) return '' + return value +} + +export function getSignatureAttribution(value: SignatureValue | '' | undefined | null): SignatureAttribution | null { + if (!value || typeof value === 'string') return null + if (isPasskeySignature(value) || isClassicSignature(value)) { + return { username: value.username, signedAt: value.signedAt } + } + return null +} + +export function createClassicSignature(input: { + role: 'skipper' | 'crew' + userId: string + username: string + signedAt: string + payload: string +}): ClassicSignature { + return { + kind: 'classic', + version: 1, + role: input.role, + userId: input.userId, + username: input.username, + signedAt: input.signedAt, + payload: input.payload + } +} + export function normalizeSignature(value: unknown): SignatureValue | undefined { if (value === null || value === undefined || value === '') return undefined if (isPasskeySignature(value)) return value + if (isClassicSignature(value)) return value if (typeof value === 'string') return value return undefined } @@ -47,6 +95,7 @@ export async function getSkipperSignStatus( export interface SignatureExportLabels { imagePlaceholder: string passkeyLabel: (username: string, signedAt: string) => string + attributionLabel: (username: string, signedAt: string) => string } export function formatSignatureForExport( @@ -57,15 +106,19 @@ export function formatSignatureForExport( if (isPasskeySignature(value)) { return labels.passkeyLabel(value.username, value.signedAt) } + if (isClassicSignature(value)) { + return labels.attributionLabel(value.username, value.signedAt) + } if (isSignatureImage(value)) return labels.imagePlaceholder return value } export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined { if (!value) return undefined - if (isPasskeySignature(value)) return value - if (isSignatureImage(value)) return value - const trimmed = value.trim() + if (isPasskeySignature(value) || isClassicSignature(value)) return value + const payload = typeof value === 'string' ? value : getSignaturePayload(value) + if (isSignatureImage(payload)) return payload + const trimmed = payload.trim() return trimmed || undefined } diff --git a/server/src/routes/sign.ts b/server/src/routes/sign.ts index e694ab7..15ec464 100644 --- a/server/src/routes/sign.ts +++ b/server/src/routes/sign.ts @@ -57,6 +57,13 @@ function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: stri return access.isOwner || access.collaboration?.role === 'WRITE' } +async function hasWriteCollaborators(logbookId: string): Promise { + const count = await prisma.collaboration.count({ + where: { logbookId, role: 'WRITE' } + }) + return count > 0 +} + async function getAllowCredentialsForRole( logbookId: string, role: 'skipper' | 'crew', @@ -79,7 +86,16 @@ async function getAllowCredentialsForRole( }) const userIds = collaborations.map((c) => c.userId) - if (userIds.length === 0) return [] + if (userIds.length === 0) { + const credentials = await prisma.credential.findMany({ + where: { userId: requestingUserId } + }) + return credentials.map((cred) => ({ + id: Buffer.from(cred.credentialId, 'base64url'), + type: 'public-key' as const, + transports: cred.transports as any[] + })) + } const credentials = await prisma.credential.findMany({ where: { userId: { in: userIds } } @@ -107,7 +123,13 @@ async function isAuthorizedSigner( logbookId_userId: { logbookId, userId: signerUserId } } }) - return collaboration?.role === 'WRITE' + if (collaboration?.role === 'WRITE') return true + + if (signerUserId === ownerUserId) { + return !(await hasWriteCollaborators(logbookId)) + } + + return false } router.post('/options', async (req: any, res) => { @@ -149,9 +171,7 @@ router.post('/options', async (req: any, res) => { if (allowCredentials.length === 0) { return res.status(400).json({ - error: role === 'crew' - ? 'No write collaborators with passkeys found' - : 'No passkey credentials found for signer' + error: 'No passkey credentials found for signer' }) }