feat: Skipper-Signatur-Badge auf Reisetag-Kacheln
Zeigt in der Journal-Liste an, ob ein Eintrag vom Skipper freigegeben ist und ob eine Passkey-Signatur nach Inhaltsänderung ungültig geworden ist. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -931,6 +931,30 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
color: var(--app-text-subtle);
|
color: var(--app-text-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-sign-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-sign-badge--skipper.valid {
|
||||||
|
color: #86efac;
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-sign-badge--skipper.invalid {
|
||||||
|
color: #fde68a;
|
||||||
|
background: rgba(251, 191, 36, 0.12);
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-delete {
|
.btn-delete {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { AlertTriangle, Fingerprint } from 'lucide-react'
|
||||||
|
import type { SkipperSignStatus } from '../utils/signatures.js'
|
||||||
|
|
||||||
|
interface EntrySkipperSignBadgeProps {
|
||||||
|
status: SkipperSignStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntrySkipperSignBadge({ status }: EntrySkipperSignBadgeProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (status === 'none') return null
|
||||||
|
|
||||||
|
const isValid = status === 'valid'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`entry-sign-badge entry-sign-badge--skipper ${isValid ? 'valid' : 'invalid'}`}
|
||||||
|
title={
|
||||||
|
isValid
|
||||||
|
? t('logs.sign_badge_skipper_title_valid')
|
||||||
|
: t('logs.sign_badge_skipper_title_invalid')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isValid ? <Fingerprint size={12} /> : <AlertTriangle size={12} />}
|
||||||
|
{isValid ? t('logs.sign_badge_skipper') : t('logs.sign_badge_skipper_invalid')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
|||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||||
|
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
|
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
carryOverFromPreviousDay,
|
carryOverFromPreviousDay,
|
||||||
@@ -41,6 +43,7 @@ interface DecryptedEntryItem {
|
|||||||
departure: string
|
departure: string
|
||||||
destination: string
|
destination: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
skipperSignStatus: SkipperSignStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LogEntriesList({
|
export default function LogEntriesList({
|
||||||
@@ -79,14 +82,18 @@ export default function LogEntriesList({
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
if (readOnly && preloadedEntries) {
|
if (readOnly && preloadedEntries) {
|
||||||
const list = preloadedEntries.map((entry: any) => ({
|
const list: DecryptedEntryItem[] = []
|
||||||
|
for (const entry of preloadedEntries) {
|
||||||
|
list.push({
|
||||||
id: entry.payloadId || entry.id,
|
id: entry.payloadId || entry.id,
|
||||||
date: entry.date || '',
|
date: entry.date || '',
|
||||||
dayOfTravel: entry.dayOfTravel || '',
|
dayOfTravel: entry.dayOfTravel || '',
|
||||||
departure: entry.departure || '',
|
departure: entry.departure || '',
|
||||||
destination: entry.destination || '',
|
destination: entry.destination || '',
|
||||||
updatedAt: entry.updatedAt || new Date().toISOString()
|
updatedAt: entry.updatedAt || new Date().toISOString(),
|
||||||
}))
|
skipperSignStatus: await getSkipperSignStatus(entry)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
@@ -114,7 +121,8 @@ export default function LogEntriesList({
|
|||||||
dayOfTravel: decrypted.dayOfTravel || '',
|
dayOfTravel: decrypted.dayOfTravel || '',
|
||||||
departure: decrypted.departure || '',
|
departure: decrypted.departure || '',
|
||||||
destination: decrypted.destination || '',
|
destination: decrypted.destination || '',
|
||||||
updatedAt: entry.updatedAt
|
updatedAt: entry.updatedAt,
|
||||||
|
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,6 +419,7 @@ export default function LogEntriesList({
|
|||||||
<span className="sync-badge synced">
|
<span className="sync-badge synced">
|
||||||
{t('logs.day_of_travel')} {item.dayOfTravel}
|
{t('logs.day_of_travel')} {item.dayOfTravel}
|
||||||
</span>
|
</span>
|
||||||
|
<EntrySkipperSignBadge status={item.skipperSignStatus} />
|
||||||
<span className="date-badge">
|
<span className="date-badge">
|
||||||
{new Date(item.date).toLocaleDateString()}
|
{new Date(item.date).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -141,6 +141,10 @@
|
|||||||
"sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen",
|
"sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen",
|
||||||
"sign_passkey_cancelled": "Passkey-Freigabe abgebrochen",
|
"sign_passkey_cancelled": "Passkey-Freigabe abgebrochen",
|
||||||
"sign_invalid": "Signatur ungültig — Inhalt wurde geändert",
|
"sign_invalid": "Signatur ungültig — Inhalt wurde geändert",
|
||||||
|
"sign_badge_skipper": "Skipper",
|
||||||
|
"sign_badge_skipper_invalid": "Ungültig",
|
||||||
|
"sign_badge_skipper_title_valid": "Skipper hat freigegeben",
|
||||||
|
"sign_badge_skipper_title_invalid": "Skipper-Signatur ungültig — Inhalt wurde geändert",
|
||||||
"sign_classic_or_passkey": "Optional: klassisch unterschreiben oder Passkey-Freigabe oben",
|
"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_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_offline_hint": "Passkey-Freigabe erfordert Internet — klassische Unterschrift offline möglich",
|
||||||
|
|||||||
@@ -141,6 +141,10 @@
|
|||||||
"sign_passkey_failed": "Passkey signing failed",
|
"sign_passkey_failed": "Passkey signing failed",
|
||||||
"sign_passkey_cancelled": "Passkey signing cancelled",
|
"sign_passkey_cancelled": "Passkey signing cancelled",
|
||||||
"sign_invalid": "Signature invalid — entry content changed",
|
"sign_invalid": "Signature invalid — entry content changed",
|
||||||
|
"sign_badge_skipper": "Skipper",
|
||||||
|
"sign_badge_skipper_invalid": "Invalid",
|
||||||
|
"sign_badge_skipper_title_valid": "Signed by skipper",
|
||||||
|
"sign_badge_skipper_title_invalid": "Skipper signature invalid — entry content changed",
|
||||||
"sign_classic_or_passkey": "Optional: sign classically below or use Passkey above",
|
"sign_classic_or_passkey": "Optional: sign classically below or use Passkey above",
|
||||||
"sign_crew_passkey_hint": "Write collaborators can sign with their Passkey",
|
"sign_crew_passkey_hint": "Write collaborators can sign with their Passkey",
|
||||||
"sign_offline_hint": "Passkey signing requires internet — classic signature works offline",
|
"sign_offline_hint": "Passkey signing requires internet — classic signature works offline",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { hashEntryForSigning } from './entryCanonicalHash.js'
|
||||||
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||||
|
|
||||||
|
export type SkipperSignStatus = 'none' | 'valid' | 'invalid'
|
||||||
|
|
||||||
export function isSignatureImage(value: string | undefined | null): boolean {
|
export function isSignatureImage(value: string | undefined | null): boolean {
|
||||||
return typeof value === 'string' && value.startsWith('data:image/')
|
return typeof value === 'string' && value.startsWith('data:image/')
|
||||||
}
|
}
|
||||||
@@ -31,6 +34,16 @@ export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: strin
|
|||||||
return sig.entryHash === entryHash
|
return sig.entryHash === entryHash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSkipperSignStatus(
|
||||||
|
entry: Record<string, unknown>
|
||||||
|
): Promise<SkipperSignStatus> {
|
||||||
|
const signSkipper = normalizeSignature(entry.signSkipper)
|
||||||
|
if (!signSkipper) return 'none'
|
||||||
|
if (!isPasskeySignature(signSkipper)) return 'valid'
|
||||||
|
const hash = await hashEntryForSigning(entry)
|
||||||
|
return isSignatureValidForEntry(signSkipper, hash) ? 'valid' : 'invalid'
|
||||||
|
}
|
||||||
|
|
||||||
export interface SignatureExportLabels {
|
export interface SignatureExportLabels {
|
||||||
imagePlaceholder: string
|
imagePlaceholder: string
|
||||||
passkeyLabel: (username: string, signedAt: string) => string
|
passkeyLabel: (username: string, signedAt: string) => string
|
||||||
|
|||||||
Reference in New Issue
Block a user