feat: Hybride Passkey-Freigabe für Skipper und Crew
Skipper (nur Owner) und Crew (WRITE-Collaborators) können Logbuchseiten optional per WebAuthn freigeben; klassische Unterschrift bleibt als Fallback. Signatur ist an den Eintrags-Hash gebunden, Export in CSV/PDF angepasst. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { formatSignatureForExport } from '../utils/signatures.js'
|
||||
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
|
||||
function escapeCsvValue(val: string | number | undefined | null): string {
|
||||
@@ -90,15 +90,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
];
|
||||
|
||||
const rows: string[][] = [headers];
|
||||
const signaturePlaceholder = i18n.t('logs.sign_export_image');
|
||||
const exportLabels = {
|
||||
imagePlaceholder: i18n.t('logs.sign_export_image'),
|
||||
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 })
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of decryptedEntries) {
|
||||
const dateVal = entry.date || '';
|
||||
const travelDay = entry.dayOfTravel || '';
|
||||
const dep = entry.departure || '';
|
||||
const dest = entry.destination || '';
|
||||
const signS = formatSignatureForExport(entry.signSkipper, signaturePlaceholder);
|
||||
const signC = formatSignatureForExport(entry.signCrew, signaturePlaceholder);
|
||||
const signS = formatSignatureForExport(normalizeSignature(entry.signSkipper), exportLabels);
|
||||
const signC = formatSignatureForExport(normalizeSignature(entry.signCrew), exportLabels);
|
||||
const trackDist = entry.trackDistanceNm ?? '';
|
||||
const trackMax = entry.trackSpeedMaxKn ?? '';
|
||||
const trackAvg = entry.trackSpeedAvgKn ?? '';
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { startAuthentication } from '@simplewebauthn/browser'
|
||||
import type { PasskeySignature } from '../types/signatures.js'
|
||||
|
||||
export async function signLogEntry(params: {
|
||||
logbookId: string
|
||||
entryId: string
|
||||
entryHash: string
|
||||
role: 'skipper' | 'crew'
|
||||
}): Promise<PasskeySignature> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) throw new Error('User not authenticated')
|
||||
|
||||
const optionsRes = await fetch('/api/sign/options', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
|
||||
if (!optionsRes.ok) {
|
||||
const err = await optionsRes.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Failed to start passkey signing')
|
||||
}
|
||||
|
||||
const options = await optionsRes.json()
|
||||
const credentialResponse = await startAuthentication({ optionsJSON: options })
|
||||
|
||||
const verifyRes = await fetch('/api/sign/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialResponse,
|
||||
challenge: options.challenge,
|
||||
logbookId: params.logbookId,
|
||||
entryId: params.entryId,
|
||||
entryHash: params.entryHash,
|
||||
role: params.role
|
||||
})
|
||||
})
|
||||
|
||||
if (!verifyRes.ok) {
|
||||
const err = await verifyRes.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Passkey signature verification failed')
|
||||
}
|
||||
|
||||
const result = await verifyRes.json()
|
||||
|
||||
return {
|
||||
kind: 'passkey',
|
||||
version: 1,
|
||||
role: params.role,
|
||||
userId: result.userId,
|
||||
username: result.username,
|
||||
credentialId: result.credentialId,
|
||||
signedAt: result.signedAt,
|
||||
entryHash: params.entryHash,
|
||||
clientVerified: true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface LogbookAccess {
|
||||
isOwner: boolean
|
||||
role: 'OWNER' | 'READ' | 'WRITE'
|
||||
writeCollaboratorCount: number
|
||||
}
|
||||
|
||||
export async function getLogbookAccess(logbookId: string): Promise<LogbookAccess | null> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || !navigator.onLine) return null
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/logbooks/${logbookId}/access`, {
|
||||
headers: { 'X-User-Id': userId }
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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 } from '../utils/signatures.js'
|
||||
import { isSignatureImage, isPasskeySignature } from '../utils/signatures.js'
|
||||
|
||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
@@ -230,7 +230,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
|
||||
|
||||
doc.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2);
|
||||
if (isSignatureImage(entry.signSkipper)) {
|
||||
if (isPasskeySignature(entry.signSkipper)) {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
const skipperDate = new Date(entry.signSkipper.signedAt).toLocaleString('de-DE');
|
||||
doc.text(`Passkey: ${entry.signSkipper.username}`, sigX + 2, sigY + 9);
|
||||
doc.text(skipperDate, sigX + 2, sigY + 13.5);
|
||||
} else if (isSignatureImage(entry.signSkipper)) {
|
||||
doc.addImage(entry.signSkipper, 'PNG', sigX + 2, sigY + 6, 72, 14)
|
||||
} else {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
@@ -239,7 +244,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
|
||||
doc.setFont('Helvetica', 'bold');
|
||||
doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2);
|
||||
if (isSignatureImage(entry.signCrew)) {
|
||||
if (isPasskeySignature(entry.signCrew)) {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
const crewDate = new Date(entry.signCrew.signedAt).toLocaleString('de-DE');
|
||||
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 {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
|
||||
Reference in New Issue
Block a user