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:
@@ -0,0 +1,24 @@
|
||||
function sortValue(value: unknown): unknown {
|
||||
if (value === null || typeof value !== 'object') return value
|
||||
if (Array.isArray(value)) return value.map(sortValue)
|
||||
const obj = value as Record<string, unknown>
|
||||
const sorted: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(obj).sort()) {
|
||||
sorted[key] = sortValue(obj[key])
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
function bufferToBase64url(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (const b of bytes) binary += String.fromCharCode(b)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/** Stabil sortiertes JSON → SHA-256 → base64url */
|
||||
export async function hashEntryForSigning(entry: Record<string, unknown>): Promise<string> {
|
||||
const canonical = JSON.stringify(sortValue(entry))
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical))
|
||||
return bufferToBase64url(digest)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface LogEventPayload {
|
||||
time: string
|
||||
mgk: string
|
||||
rwk: string
|
||||
windPressure: string
|
||||
windDirection: string
|
||||
windStrength: string
|
||||
seaState: string
|
||||
weatherIcon: string
|
||||
current: string
|
||||
heel: string
|
||||
sailsOrMotor: string
|
||||
logReading: string
|
||||
distance: string
|
||||
gpsLat: string
|
||||
gpsLng: string
|
||||
remarks: string
|
||||
}
|
||||
|
||||
export interface LogEntryPayloadInput {
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
trackDistanceNm?: number
|
||||
trackSpeedMaxKn?: number
|
||||
trackSpeedAvgKn?: number
|
||||
events: LogEventPayload[]
|
||||
}
|
||||
|
||||
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
date: input.date,
|
||||
dayOfTravel: input.dayOfTravel.trim(),
|
||||
departure: input.departure.trim(),
|
||||
destination: input.destination.trim(),
|
||||
freshwater: { ...input.freshwater },
|
||||
fuel: { ...input.fuel },
|
||||
events: input.events.map((e) => ({ ...e }))
|
||||
}
|
||||
|
||||
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
|
||||
if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
|
||||
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -1,12 +1,50 @@
|
||||
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||
|
||||
export function isSignatureImage(value: string | undefined | null): boolean {
|
||||
return typeof value === 'string' && value.startsWith('data:image/')
|
||||
}
|
||||
|
||||
export function formatSignatureForExport(
|
||||
value: string | undefined | null,
|
||||
export function isPasskeySignature(value: unknown): value is PasskeySignature {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
(value as PasskeySignature).kind === 'passkey' &&
|
||||
(value as PasskeySignature).version === 1
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeSignature(value: unknown): SignatureValue | undefined {
|
||||
if (value === null || value === undefined || value === '') return undefined
|
||||
if (isPasskeySignature(value)) return value
|
||||
if (typeof value === 'string') return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: string): boolean {
|
||||
return sig.entryHash === entryHash
|
||||
}
|
||||
|
||||
export interface SignatureExportLabels {
|
||||
imagePlaceholder: string
|
||||
passkeyLabel: (username: string, signedAt: string) => string
|
||||
}
|
||||
|
||||
export function formatSignatureForExport(
|
||||
value: SignatureValue | undefined | null,
|
||||
labels: SignatureExportLabels
|
||||
): string {
|
||||
if (!value) return ''
|
||||
if (isSignatureImage(value)) return imagePlaceholder
|
||||
if (isPasskeySignature(value)) {
|
||||
return labels.passkeyLabel(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()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user