fix(logs): Crew-Unterschrift mit Benutzerzuordnung und Owner-Crew-Signatur

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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-30 19:24:46 +02:00
parent 4484724d38
commit 4acb9b1290
9 changed files with 161 additions and 20 deletions
+26 -1
View File
@@ -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}
+23 -5
View File
@@ -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<boolean>
}
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 (
<div className="passkey-sign-badge valid signature-attribution-badge">
<span>{t('logs.sign_passkey_signed', { username: attribution.username })}</span>
<span className="passkey-sign-date">{formattedDate}</span>
</div>
)
}
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 (
<div className="signature-role-block">
<SignerAttributionBadge value={value} />
<SignaturePad
id={padId}
label={roleLabel}
@@ -162,6 +179,7 @@ function RoleSignatureBlock({
{showClassicPanel && (
<>
<SignerAttributionBadge value={value} />
<SignaturePad
id={padId}
label={roleLabel}
@@ -203,7 +221,7 @@ export default function SignatureSection({
const { t } = useTranslation()
const showSkipperPasskey = canSignSkipper && isOnline
const showCrewPasskey = canSignCrew && isOnline && !canSignSkipper
const showCrewPasskey = canSignCrew && isOnline
const hasSignature = !!(signSkipper || signCrew)
return (
+1
View File
@@ -151,6 +151,7 @@
"sign_passkey_signing": "Passkey wird angefordert…",
"sign_passkey_signed": "Freigegeben von {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Passkey-Freigabe entfernen",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Klassisch",
+1
View File
@@ -151,6 +151,7 @@
"sign_passkey_signing": "Requesting Passkey…",
"sign_passkey_signed": "Signed by {{username}}",
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
"sign_attribution_export": "{{username}} ({{date}})",
"sign_passkey_clear": "Remove Passkey signature",
"sign_mode_passkey": "Passkey",
"sign_mode_classic": "Classic",
+4
View File
@@ -96,6 +96,10 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
passkeyLabel: (username: string, signedAt: string) => {
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 })
}
};
+11 -3
View File
@@ -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);
+13 -2
View File
@@ -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
+57 -4
View File
@@ -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
}
+25 -5
View File
@@ -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<boolean> {
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'
})
}