From 20ff2a0baae97d1a03935fa7867dac4c6f6ebf67 Mon Sep 17 00:00:00 2001 From: elpatron Date: Thu, 28 May 2026 20:47:52 +0200 Subject: [PATCH] Implement E2E-compliant anonymous read-only logbook sharing links --- client/src/App.tsx | 23 +++ client/src/components/CrewForm.tsx | 167 +++++++++------ client/src/components/DeviationForm.tsx | 46 +++-- client/src/components/LogEntriesList.tsx | 91 +++++++-- client/src/components/LogEntryEditor.tsx | 177 ++++++++++------ client/src/components/PhotoCapture.tsx | 110 +++++----- client/src/components/ReadOnlyViewer.tsx | 245 +++++++++++++++++++++++ client/src/components/SettingsForm.tsx | 140 +++++++++++++ client/src/components/VesselForm.tsx | 195 ++++++++++-------- client/src/i18n/locales/de.json | 7 +- client/src/i18n/locales/en.json | 7 +- client/src/services/csvExport.ts | 90 +++++---- client/src/services/pdfExport.ts | 74 ++++--- server/src/routes/collaboration.ts | 159 +++++++++++++++ 14 files changed, 1172 insertions(+), 359 deletions(-) create mode 100644 client/src/components/ReadOnlyViewer.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 054da7f..edf00c9 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -11,6 +11,7 @@ import SettingsForm from './components/SettingsForm.tsx' import InvitationAcceptance from './components/InvitationAcceptance.tsx' import { getActiveMasterKey, logoutUser } from './services/auth.js' import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js' +import ReadOnlyViewer from './components/ReadOnlyViewer.tsx' import { db } from './services/db.js' import { useLiveQuery } from 'dexie-react-hooks' import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react' @@ -27,6 +28,11 @@ function App() { const [appliedTheme, setAppliedTheme] = useState<'ocean' | 'material' | 'cupertino'>('ocean') const [isAcceptingInvite, setIsAcceptingInvite] = useState(false) + // Viewer mode for read-only shared links + const [isViewerMode, setIsViewerMode] = useState(false) + const [shareToken, setShareToken] = useState('') + const [shareKey, setShareKey] = useState('') + const syncQueueCount = useLiveQuery( () => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(), [activeLogbookId] @@ -89,6 +95,15 @@ function App() { useEffect(() => { const params = new URLSearchParams(window.location.search) + const hashParams = new URLSearchParams(window.location.hash.substring(1)) + + if (window.location.pathname === '/share' && params.has('token') && hashParams.has('key')) { + setShareToken(params.get('token') || '') + setShareKey(hashParams.get('key') || '') + setIsViewerMode(true) + return + } + if (params.has('token')) { setIsAcceptingInvite(true) } @@ -139,6 +154,14 @@ function App() { localStorage.removeItem('active_logbook_title') } + if (isViewerMode) { + return ( +
+ +
+ ) + } + if (isAcceptingInvite) { return (
diff --git a/client/src/components/CrewForm.tsx b/client/src/components/CrewForm.tsx index dcd6459..954cd13 100644 --- a/client/src/components/CrewForm.tsx +++ b/client/src/components/CrewForm.tsx @@ -10,6 +10,8 @@ import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide interface CrewFormProps { logbookId: string + readOnly?: boolean + preloadedData?: any[] } interface CrewMemberData { @@ -31,7 +33,7 @@ interface DecryptedCrew { data: CrewMemberData } -export default function CrewForm({ logbookId }: CrewFormProps) { +export default function CrewForm({ logbookId, readOnly = false, preloadedData }: CrewFormProps) { const { t } = useTranslation() const { showConfirm } = useDialog() @@ -78,7 +80,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { useEffect(() => { loadCrewData() - }, [logbookId]) + }, [logbookId, preloadedData]) const resizeImageFile = (file: File): Promise => { return new Promise((resolve, reject) => { @@ -124,6 +126,31 @@ export default function CrewForm({ logbookId }: CrewFormProps) { setLoading(true) setError(null) try { + if (readOnly && preloadedData) { + const decryptedCrews: DecryptedCrew[] = [] + for (const c of preloadedData) { + if (c.payloadId === 'skipper') { + setSkipName(c.data.name || '') + setSkipAddress(c.data.address || '') + setSkipBirthDate(c.data.birthDate || '') + setSkipPhone(c.data.phone || '') + setSkipNationality(c.data.nationality || '') + setSkipPassport(c.data.passportNumber || '') + setSkipBloodType(c.data.bloodType || '') + setSkipAllergies(c.data.allergies || '') + setSkipDiseases(c.data.diseases || '') + setSkipPhoto(c.data.photo || null) + } else { + decryptedCrews.push({ + payloadId: c.payloadId, + data: c.data + }) + } + } + setCrewList(decryptedCrews) + return + } + const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') @@ -164,6 +191,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { const handleSaveSkipper = async (e: React.FormEvent) => { e.preventDefault() + if (readOnly) return setSavingSkipper(true) setError(null) setSkipperSuccess(false) @@ -253,7 +281,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { const handleSaveMember = async (e: React.FormEvent) => { e.preventDefault() - if (!memName.trim()) return + if (readOnly || !memName.trim()) return setSavingMember(true) setError(null) @@ -319,6 +347,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { } const handleDeleteMember = async (memberId: string) => { + if (readOnly) return if (await showConfirm(t('crew.delete_confirm'), t('crew.title'), t('logs.confirm_yes'), t('logs.confirm_no'))) { setError(null) try { @@ -368,7 +397,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
-
skipFileInputRef.current?.click()}> +
skipFileInputRef.current?.click()} style={{ cursor: readOnly ? 'default' : 'pointer' }}> {skipPhoto ? ( {skipName ) : ( @@ -376,39 +405,43 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
)} -
- - {skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')} -
-
- -
- - - {skipPhoto && ( - + {!readOnly && ( +
+ + {skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')} +
)}
+ {!readOnly && ( +
+ + + {skipPhoto && ( + + )} +
+ )} + setSkipName(e.target.value)} - disabled={savingSkipper} + disabled={savingSkipper || readOnly} required />
@@ -448,7 +481,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { className="input-text" value={skipAddress} onChange={(e) => setSkipAddress(e.target.value)} - disabled={savingSkipper} + disabled={savingSkipper || readOnly} />
@@ -459,7 +492,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { className="input-text" value={skipBirthDate} onChange={(e) => setSkipBirthDate(e.target.value)} - disabled={savingSkipper} + disabled={savingSkipper || readOnly} />
@@ -470,7 +503,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { className="input-text" value={skipPhone} onChange={(e) => setSkipPhone(e.target.value)} - disabled={savingSkipper} + disabled={savingSkipper || readOnly} /> @@ -481,7 +514,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { className="input-text" value={skipNationality} onChange={(e) => setSkipNationality(e.target.value)} - disabled={savingSkipper} + disabled={savingSkipper || readOnly} /> @@ -492,7 +525,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { className="input-text" value={skipPassport} onChange={(e) => setSkipPassport(e.target.value)} - disabled={savingSkipper} + disabled={savingSkipper || readOnly} /> @@ -503,7 +536,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { className="input-text" value={skipBloodType} onChange={(e) => setSkipBloodType(e.target.value)} - disabled={savingSkipper} + disabled={savingSkipper || readOnly} /> @@ -514,7 +547,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) { className="input-text" value={skipAllergies} onChange={(e) => setSkipAllergies(e.target.value)} - disabled={savingSkipper} + disabled={savingSkipper || readOnly} /> @@ -525,24 +558,26 @@ export default function CrewForm({ logbookId }: CrewFormProps) { className="input-text" value={skipDiseases} onChange={(e) => setSkipDiseases(e.target.value)} - disabled={savingSkipper} + disabled={savingSkipper || readOnly} /> -
- {skipperSuccess && ( -
- - {t('crew.saved')} -
- )} - - -
+ {!readOnly && ( +
+ {skipperSuccess && ( +
+ + {t('crew.saved')} +
+ )} + + +
+ )} @@ -553,7 +588,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {

{t('crew.crew_section')}

- {crewList.length < 5 && !showMemberForm && ( + {!readOnly && crewList.length < 5 && !showMemberForm && ( - - + {!readOnly && ( +
+ + +
+ )}
diff --git a/client/src/components/DeviationForm.tsx b/client/src/components/DeviationForm.tsx index e4b9690..aa7f9ac 100644 --- a/client/src/components/DeviationForm.tsx +++ b/client/src/components/DeviationForm.tsx @@ -9,9 +9,11 @@ import { Compass, Save, Check } from 'lucide-react' interface DeviationFormProps { logbookId: string + readOnly?: boolean + preloadedData?: any } -export default function DeviationForm({ logbookId }: DeviationFormProps) { +export default function DeviationForm({ logbookId, readOnly = false, preloadedData }: DeviationFormProps) { const { t } = useTranslation() // Generate headings: 0, 10, 20, ..., 360 (37 items) @@ -29,6 +31,17 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) { setLoading(true) setError(null) try { + if (readOnly && preloadedData) { + const map: Record = {} + if (preloadedData.deviations) { + Object.entries(preloadedData.deviations).forEach(([k, v]) => { + map[Number(k)] = String(v) + }) + } + setDeviations(map) + return + } + const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') @@ -71,6 +84,7 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() + if (readOnly) return setSaving(true) setError(null) setSuccess(false) @@ -162,26 +176,28 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) { className="input-text cell-input" value={deviations[h] || ''} onChange={(e) => handleInputChange(h, e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
) })} -
- {success && ( -
- - {t('deviation.saved')} -
- )} - - -
+ {!readOnly && ( +
+ {success && ( +
+ + {t('deviation.saved')} +
+ )} + + +
+ )} ) diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index 2eb697b..248d4d9 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -13,6 +13,11 @@ import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from interface LogEntriesListProps { logbookId: string + readOnly?: boolean + preloadedYacht?: any + preloadedEntries?: any[] + preloadedPhotos?: any[] + preloadedGpsTracks?: any[] } interface DecryptedEntryItem { @@ -24,7 +29,14 @@ interface DecryptedEntryItem { updatedAt: string } -export default function LogEntriesList({ logbookId }: LogEntriesListProps) { +export default function LogEntriesList({ + logbookId, + readOnly = false, + preloadedYacht, + preloadedEntries, + preloadedPhotos, + preloadedGpsTracks +}: LogEntriesListProps) { const { t } = useTranslation() const { showConfirm } = useDialog() const [entries, setEntries] = useState([]) @@ -43,6 +55,26 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { setLoading(true) setError(null) try { + if (readOnly && preloadedEntries) { + const list = preloadedEntries.map((entry: any) => ({ + id: entry.payloadId || entry.id, + date: entry.date || '', + dayOfTravel: entry.dayOfTravel || '', + departure: entry.departure || '', + destination: entry.destination || '', + updatedAt: entry.updatedAt || new Date().toISOString() + })) + + list.sort((a, b) => { + const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime() + if (dateCompare !== 0) return dateCompare + return Number(b.dayOfTravel) - Number(a.dayOfTravel) + }) + + setEntries(list) + return + } + const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') @@ -84,8 +116,12 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { setExporting(true) setError(null) try { - const title = localStorage.getItem('active_logbook_title') || 'Logbook' - await downloadCsv(logbookId, title) + const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook' + if (readOnly && preloadedEntries && preloadedYacht) { + await downloadCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries }) + } else { + await downloadCsv(logbookId, title) + } } catch (err: any) { console.error('Failed to download CSV:', err) setError(err.message || 'Failed to generate CSV export.') @@ -98,12 +134,20 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { setExporting(true) setError(null) try { - const title = localStorage.getItem('active_logbook_title') || 'Logbook' - await shareCsv(logbookId, title) + const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook' + if (readOnly && preloadedEntries && preloadedYacht) { + await shareCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries }) + } else { + await shareCsv(logbookId, title) + } } catch (err: any) { if (err.message === 'share_unsupported') { - const title = localStorage.getItem('active_logbook_title') || 'Logbook' - await downloadCsv(logbookId, title) + const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook' + if (readOnly && preloadedEntries && preloadedYacht) { + await downloadCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries }) + } else { + await downloadCsv(logbookId, title) + } setError(t('logs.share_unsupported')) } else { console.error('Failed to share CSV:', err) @@ -119,7 +163,12 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { setExporting(true) setError(null) try { - await downloadLogbookPagePdf(logbookId, entryId, date) + if (readOnly && preloadedEntries && preloadedYacht) { + const fullEntry = preloadedEntries.find(entry => (entry.payloadId || entry.id) === entryId) + await downloadLogbookPagePdf(logbookId, entryId, date, { yacht: preloadedYacht, entry: fullEntry }) + } else { + await downloadLogbookPagePdf(logbookId, entryId, date) + } } catch (err: any) { console.error('Failed to download PDF:', err) setError(err.message || 'Failed to generate PDF export.') @@ -129,6 +178,7 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { } const handleCreate = async () => { + if (readOnly) return setLoading(true) setError(null) try { @@ -188,7 +238,8 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { } const handleDelete = async (entryId: string, e: React.MouseEvent) => { - e.stopPropagation() // Prevent selecting the card + e.stopPropagation() + if (readOnly) return if (await showConfirm(t('logs.delete_confirm'), t('logs.delete_entry'), t('logs.confirm_yes'), t('logs.confirm_no'))) { setError(null) @@ -221,6 +272,10 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { entryId={selectedEntryId} logbookId={logbookId} onBack={() => setSelectedEntryId(null)} + readOnly={readOnly} + preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)} + preloadedPhotos={preloadedPhotos} + preloadedGpsTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)} /> ) } @@ -252,10 +307,12 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { {t('logs.share_csv')} - + {!readOnly && ( + + )} @@ -291,9 +348,11 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) { - + {!readOnly && ( + + )} diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index f642bbe..e7dc886 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -25,6 +25,11 @@ interface LogEntryEditorProps { entryId: string logbookId: string onBack: () => void + readOnly?: boolean + preloadedEntry?: any + preloadedPhotos?: any[] + preloadedGpsTrack?: any + preloadedYacht?: any } interface LogEvent { @@ -46,7 +51,16 @@ interface LogEvent { remarks: string } -export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryEditorProps) { +export default function LogEntryEditor({ + entryId, + logbookId, + onBack, + readOnly = false, + preloadedEntry, + preloadedPhotos, + preloadedGpsTrack, + preloadedYacht +}: LogEntryEditorProps) { const { t, i18n } = useTranslation() const { showAlert } = useDialog() @@ -132,6 +146,10 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE // Load Yacht Sails useEffect(() => { async function loadYachtSails() { + if (readOnly && preloadedYacht?.sails) { + setYachtSails(preloadedYacht.sails) + return + } try { const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) return @@ -148,7 +166,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE } } loadYachtSails() - }, [logbookId]) + }, [logbookId, preloadedYacht]) // Load entry details useEffect(() => { @@ -156,6 +174,29 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE setLoading(true) setError(null) try { + if (readOnly && preloadedEntry) { + setDate(preloadedEntry.date || '') + setDayOfTravel(preloadedEntry.dayOfTravel || '') + setDeparture(preloadedEntry.departure || '') + setDestination(preloadedEntry.destination || '') + + if (preloadedEntry.freshwater) { + setFwMorning(String(preloadedEntry.freshwater.morning || 0)) + setFwRefilled(String(preloadedEntry.freshwater.refilled || 0)) + setFwEvening(String(preloadedEntry.freshwater.evening || 0)) + } + if (preloadedEntry.fuel) { + setFuelMorning(String(preloadedEntry.fuel.morning || 0)) + setFuelRefilled(String(preloadedEntry.fuel.refilled || 0)) + setFuelEvening(String(preloadedEntry.fuel.evening || 0)) + } + + setSignSkipper(preloadedEntry.signSkipper || '') + setSignCrew(preloadedEntry.signCrew || '') + setEvents(preloadedEntry.events || []) + return + } + const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') @@ -193,10 +234,14 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE } loadEntry() - }, [entryId]) + }, [entryId, preloadedEntry]) // GPS Track Loader const loadGpsTrack = async () => { + if (readOnly && preloadedGpsTrack) { + setSavedTrack(preloadedGpsTrack) + return + } try { const track = await getDecryptedGpsTrack(entryId) setSavedTrack(track) @@ -207,7 +252,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE useEffect(() => { loadGpsTrack() - }, [entryId]) + }, [entryId, preloadedGpsTrack]) // Leaflet Map Initialization and Rendering useEffect(() => { @@ -277,6 +322,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE // GPX/KML/GeoJSON Upload Handlers const handleFileUpload = async (file: File) => { + if (readOnly) return setUploadError(null) const reader = new FileReader() reader.onload = async (e) => { @@ -329,6 +375,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE } const handleDeleteTrack = async () => { + if (readOnly) return if (!window.confirm(t('logs.gps_track_delete_confirm'))) { return } @@ -366,6 +413,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE } const handleGetGps = () => { + if (readOnly) return const lookupFallback = async () => { const locationQuery = evLocationName.trim() || departure.trim() || destination.trim() if (!locationQuery) { @@ -526,7 +574,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE const handleAddEvent = (e: React.FormEvent) => { e.preventDefault() - if (!evTime) return + if (readOnly || !evTime) return const newEvent: LogEvent = { time: evTime, @@ -570,6 +618,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE } const handleDeleteEvent = (index: number) => { + if (readOnly) return setEvents((prev) => prev.filter((_, idx) => idx !== index)) } @@ -588,6 +637,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() + if (readOnly) return setSaving(true) setError(null) setSuccess(false) @@ -715,20 +765,19 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE className="input-text" value={date} onChange={(e) => setDate(e.target.value)} - disabled={saving} + disabled={saving || readOnly} required />
- + setDayOfTravel(e.target.value)} - disabled={saving} + disabled={saving || readOnly} required />
@@ -738,10 +787,9 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE setDeparture(e.target.value)} - disabled={saving} + disabled={saving || readOnly} /> @@ -750,10 +798,9 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE setDestination(e.target.value)} - disabled={saving} + disabled={saving || readOnly} /> @@ -769,38 +816,35 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
- + setFwMorning(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
- + setFwRefilled(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
- + setFwEvening(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
@@ -825,38 +869,35 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
- + setFuelMorning(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
- + setFuelRefilled(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
- + setFuelEvening(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
@@ -899,7 +940,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE {t('logs.event_log')} {t('logs.event_gps')} {t('logs.event_remarks')} - + {!readOnly && } @@ -928,11 +969,13 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE {ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'} {ev.remarks} - - - + {!readOnly && ( + + + + )} ))} @@ -941,8 +984,9 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE )} {/* Add New Event Form Sub-Card */} -
-

{t('logs.add_event')}

+ {!readOnly && ( +
+

{t('logs.add_event')}

@@ -1187,6 +1231,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE Add Event Entry
+ )}
{/* GPS Track Upload & Map Visualization */} @@ -1245,15 +1290,17 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE {t('logs.gps_tracking_btn_gpx')} - + {!readOnly && ( + + )}
@@ -1263,7 +1310,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE )}
- + {/* Section 4: Sign-Off Signatures */}
@@ -1280,7 +1327,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE className="input-text" value={signSkipper} onChange={(e) => setSignSkipper(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
@@ -1292,26 +1339,28 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE className="input-text" value={signCrew} onChange={(e) => setSignCrew(e.target.value)} - disabled={saving} + disabled={saving || readOnly} /> {/* Save Controls */} -
- {success && ( -
- - {t('logs.saved')} -
- )} - - -
+ {!readOnly && ( +
+ {success && ( +
+ + {t('logs.saved')} +
+ )} + + +
+ )} ) diff --git a/client/src/components/PhotoCapture.tsx b/client/src/components/PhotoCapture.tsx index 871bab5..090bed2 100644 --- a/client/src/components/PhotoCapture.tsx +++ b/client/src/components/PhotoCapture.tsx @@ -12,6 +12,8 @@ import { Camera, Trash2 } from 'lucide-react' interface PhotoCaptureProps { entryId: string logbookId: string + readOnly?: boolean + preloadedPhotos?: any[] } interface DecryptedPhoto { @@ -21,7 +23,7 @@ interface DecryptedPhoto { updatedAt: string } -export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps) { +export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) { const { t } = useTranslation() const { showConfirm } = useDialog() const [caption, setCaption] = useState('') @@ -40,6 +42,19 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps) // Decrypt photos on query updates useEffect(() => { async function decryptPhotosList() { + if (readOnly && preloadedPhotos) { + const filtered = preloadedPhotos + .filter((p: any) => p.entryId === entryId) + .map((p: any) => ({ + payloadId: p.payloadId || p.id, + image: p.image, + caption: p.caption || '', + updatedAt: p.updatedAt || new Date().toISOString() + })) + setDecryptedPhotos(filtered) + return + } + if (!localPhotos) return const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() @@ -65,7 +80,7 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps) } decryptPhotosList() - }, [localPhotos]) + }, [localPhotos, readOnly, preloadedPhotos, entryId]) const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] @@ -197,45 +212,48 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps) {error &&
{error}
} {/* Upload area */} -
-
-
- + {/* Upload Form */} + {!readOnly && ( +
+
+
+ + setCaption(e.target.value)} + disabled={uploading} + /> +
+ setCaption(e.target.value)} - disabled={uploading} + type="file" + accept="image/*" + capture="environment" + ref={fileInputRef} + onChange={handleFileChange} + style={{ display: 'none' }} /> + +
- - - -
-
+ )} {/* Photo Grid */} {decryptedPhotos.length === 0 ? ( @@ -246,14 +264,16 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
{photo.caption - + {!readOnly && ( + + )}
{photo.caption && (
diff --git a/client/src/components/ReadOnlyViewer.tsx b/client/src/components/ReadOnlyViewer.tsx new file mode 100644 index 0000000..3754ea1 --- /dev/null +++ b/client/src/components/ReadOnlyViewer.tsx @@ -0,0 +1,245 @@ +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { decryptJson } from '../services/crypto.js' +import VesselForm from './VesselForm.tsx' +import CrewForm from './CrewForm.tsx' +import LogEntriesList from './LogEntriesList.tsx' +import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react' + +interface ReadOnlyViewerProps { + token: string + hexKey: string +} + +// Convert Hex String back to ArrayBuffer +const hexToBuffer = (hex: string): ArrayBuffer => { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16) + } + return bytes.buffer +} + +export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) { + const { t, i18n } = useTranslation() + const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'logs'>('logs') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Logbook data states + const [logbookTitle, setLogbookTitle] = useState('Logbook') + const [yacht, setYacht] = useState(null) + const [crews, setCrews] = useState([]) + const [entries, setEntries] = useState([]) + const [photos, setPhotos] = useState([]) + const [gpsTracks, setGpsTracks] = useState([]) + + useEffect(() => { + loadData() + }, [token, hexKey]) + + const loadData = async () => { + setLoading(true) + setError(null) + try { + const keyBuffer = hexToBuffer(hexKey) + + const res = await fetch(`/api/collaboration/share-pull?token=${token}`) + if (!res.ok) { + if (res.status === 410) { + throw new Error(i18n.language.startsWith('de') ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.') + } + throw new Error(i18n.language.startsWith('de') ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.') + } + + const data = await res.json() + + // Decrypt Title + let decryptedTitle = 'Shared Logbook' + if (data.title) { + const parsed = JSON.parse(data.title) + decryptedTitle = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, keyBuffer) + } + setLogbookTitle(decryptedTitle) + + // Decrypt Yacht + let decYacht = null + if (data.yacht) { + decYacht = await decryptJson(data.yacht.encryptedData, data.yacht.iv, data.yacht.tag, keyBuffer) + } + setYacht(decYacht) + + // Decrypt Crews + const decCrews = [] + if (data.crews) { + for (const c of data.crews) { + const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer) + decCrews.push({ + payloadId: c.payloadId, + data: dec + }) + } + } + setCrews(decCrews) + + // Decrypt Entries + const decEntries = [] + if (data.entries) { + for (const e of data.entries) { + const dec = await decryptJson(e.encryptedData, e.iv, e.tag, keyBuffer) + decEntries.push({ + payloadId: e.payloadId, + ...dec + }) + } + } + setEntries(decEntries) + + // Decrypt Photos + const decPhotos = [] + if (data.photos) { + for (const p of data.photos) { + const dec = await decryptJson(p.encryptedData, p.iv, p.tag, keyBuffer) + decPhotos.push({ + payloadId: p.payloadId, + entryId: p.entryId, + image: dec.image, + caption: dec.caption || '', + updatedAt: p.updatedAt + }) + } + } + setPhotos(decPhotos) + + // Decrypt GPS Tracks + const decGpsTracks = [] + if (data.gpsTracks) { + for (const tr of data.gpsTracks) { + const dec = await decryptJson(tr.encryptedData, tr.iv, tr.tag, keyBuffer) + decGpsTracks.push({ + entryId: tr.entryId, + waypoints: dec.waypoints || dec || [], + filename: dec.filename || 'track.gpx' + }) + } + } + setGpsTracks(decGpsTracks) + + } catch (err: any) { + console.error(err) + setError(err.message || 'Failed to decrypt logbook details.') + } finally { + setLoading(false) + } + } + + const toggleLanguage = () => { + const nextLang = i18n.language.startsWith('de') ? 'en' : 'de' + i18n.changeLanguage(nextLang) + } + + if (loading) { + return ( +
+ +

{i18n.language.startsWith('de') ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}

+
+ ) + } + + if (error) { + return ( +
+ +

{i18n.language.startsWith('de') ? 'Verbindungsfehler' : 'Access Error'}

+

{error}

+ +
+ ) + } + + return ( +
+ {/* Top Banner indicating read-only shared view */} +
+ +
+
+
+

{logbookTitle}

+

+ + {i18n.language.startsWith('de') ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'} +

+
+
+ +
+ +
+
+ +
+ + +
+ {activeTab === 'logs' && ( + + )} + + {activeTab === 'vessel' && ( + + )} + + {activeTab === 'crew' && ( + + )} +
+
+
+ ) +} diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx index 97c94c6..a3d61b4 100644 --- a/client/src/components/SettingsForm.tsx +++ b/client/src/components/SettingsForm.tsx @@ -40,12 +40,101 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) { const [collabError, setCollabError] = useState(null) const [loadingCollabs, setLoadingCollabs] = useState(false) + // Public Share Link States + const [shareEnabled, setShareEnabled] = useState(false) + const [shareLink, setShareLink] = useState('') + const [shareCopied, setShareCopied] = useState(false) + const [loadingShareLink, setLoadingShareLink] = useState(false) + useEffect(() => { if (logbookId) { loadCollaborators() + loadShareLink() } }, [logbookId]) + const loadShareLink = async () => { + if (!logbookId) return + setLoadingShareLink(true) + const userId = localStorage.getItem('active_userid') + if (!userId) return + + try { + const res = await fetch(`/api/collaboration/share-link?logbookId=${logbookId}`, { + headers: { + 'X-User-Id': userId + } + }) + + if (res.ok) { + const data = await res.json() + if (data.token) { + setShareEnabled(true) + const logbookKey = await ensureLogbookKey(logbookId) + const hexKey = bufferToHex(logbookKey) + setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`) + } else { + setShareEnabled(false) + setShareLink('') + } + } + } catch (err) { + console.error('Failed to load share link:', err) + } finally { + setLoadingShareLink(false) + } + } + + const handleToggleShare = async (e: React.ChangeEvent) => { + if (!logbookId) return + const checked = e.target.checked + const userId = localStorage.getItem('active_userid') + if (!userId) return + + setLoadingShareLink(true) + try { + const res = await fetch('/api/collaboration/share-link', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-User-Id': userId + }, + body: JSON.stringify({ logbookId, enabled: checked }) + }) + + if (res.ok) { + const data = await res.json() + if (checked && data.token) { + setShareEnabled(true) + const logbookKey = await ensureLogbookKey(logbookId) + const hexKey = bufferToHex(logbookKey) + setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`) + showAlert('Public share link enabled!') + } else { + setShareEnabled(false) + setShareLink('') + showAlert('Public share link disabled.') + } + } else { + throw new Error('Failed to toggle public share link.') + } + } catch (err: any) { + console.error('Toggle share link failed:', err) + showAlert(err.message || 'Failed to update public share link.') + } finally { + setLoadingShareLink(false) + } + } + + const handleCopyShareLink = () => { + if (shareLink) { + navigator.clipboard.writeText(shareLink) + setShareCopied(true) + setTimeout(() => setShareCopied(false), 2000) + } + } + + const loadCollaborators = async () => { setLoadingCollabs(true) setCollabError(null) @@ -247,6 +336,57 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
+ {/* Public Share Link Card (Only visible to Logbook Owner) */} + {logbookId && isOwner && ( +
+
+ +

+ {t('settings.share_title')} +

+
+ +

+ {t('settings.share_desc')} +

+ +
+ + {loadingShareLink && Updating...} +
+ + {shareEnabled && shareLink && ( +
+ (e.target as HTMLInputElement).select()} + /> + +
+ )} +
+ )} + {/* Crew Collaboration Card (Only visible to Logbook Owner) */} {logbookId && isOwner && (
diff --git a/client/src/components/VesselForm.tsx b/client/src/components/VesselForm.tsx index 1210687..3779f14 100644 --- a/client/src/components/VesselForm.tsx +++ b/client/src/components/VesselForm.tsx @@ -9,9 +9,11 @@ import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react' interface VesselFormProps { logbookId: string + readOnly?: boolean + preloadedData?: any } -export default function VesselForm({ logbookId }: VesselFormProps) { +export default function VesselForm({ logbookId, readOnly = false, preloadedData }: VesselFormProps) { const { t } = useTranslation() const [name, setName] = useState('') const [homePort, setHomePort] = useState('') @@ -39,6 +41,20 @@ export default function VesselForm({ logbookId }: VesselFormProps) { setLoading(true) setError(null) try { + if (readOnly && preloadedData) { + setName(preloadedData.name || '') + setHomePort(preloadedData.homePort || '') + setCharterCompany(preloadedData.charterCompany || '') + setOwner(preloadedData.owner || '') + setRegistrationNumber(preloadedData.registrationNumber || '') + setCallSign(preloadedData.callSign || '') + setAtis(preloadedData.atis || '') + setMmsi(preloadedData.mmsi || '') + setSails(preloadedData.sails || []) + setPhoto(preloadedData.photo || null) + return + } + const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') @@ -143,6 +159,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() + if (readOnly) return setSaving(true) setError(null) setSuccess(false) @@ -221,7 +238,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
-
+
{photo ? ( {name ) : ( @@ -229,36 +246,40 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
)} -
- - {photo ? t('vessel.photo_change') : t('vessel.photo_add')} -
-
- -
- - - {photo && ( - + {!readOnly && ( +
+ + {photo ? t('vessel.photo_change') : t('vessel.photo_add')} +
)}
+ {!readOnly && ( +
+ + + {photo && ( + + )} +
+ )} + setName(e.target.value)} - disabled={saving} + disabled={saving || readOnly} required />
@@ -288,7 +309,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) { className="input-text" value={homePort} onChange={(e) => setHomePort(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
@@ -299,7 +320,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) { className="input-text" value={owner} onChange={(e) => setOwner(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
@@ -310,7 +331,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) { className="input-text" value={charterCompany} onChange={(e) => setCharterCompany(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
@@ -321,7 +342,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) { className="input-text" value={registrationNumber} onChange={(e) => setRegistrationNumber(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
@@ -332,7 +353,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) { className="input-text" value={callSign} onChange={(e) => setCallSign(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
@@ -343,7 +364,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) { className="input-text" value={atis} onChange={(e) => setAtis(e.target.value)} - disabled={saving} + disabled={saving || readOnly} />
@@ -354,7 +375,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) { className="input-text" value={mmsi} onChange={(e) => setMmsi(e.target.value)} - disabled={saving} + disabled={saving || readOnly} /> @@ -369,61 +390,67 @@ export default function VesselForm({ logbookId }: VesselFormProps) { sails.map((sail, idx) => ( {sail} - + {!readOnly && ( + + )} )) )} -
- setNewSailName(e.target.value)} - disabled={saving} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleAddSail(); - } - }} - /> - -
+ {!readOnly && ( +
+ setNewSailName(e.target.value)} + disabled={saving} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddSail(); + } + }} + /> + +
+ )} -
- {success && ( -
- - {t('vessel.saved')} -
- )} - - -
+ {!readOnly && ( +
+ {success && ( +
+ + {t('vessel.saved')} +
+ )} + + +
+ )} ) diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 9f4b440..828a0b0 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -209,7 +209,12 @@ "theme_auto": "Automatisch (OS-Erkennung)", "theme_ocean": "Ocean (Glassmorphismus)", "theme_material": "Material (Android)", - "theme_cupertino": "Cupertino (iOS)" + "theme_cupertino": "Cupertino (iOS)", + "share_title": "Logbuch teilen (Schreibgeschützt)", + "share_desc": "Aktivieren Sie diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann Ihre Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).", + "share_enable": "Öffentlichen Link aktivieren", + "share_copied": "Link kopiert!", + "share_copy_btn": "Link kopieren" } } } diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index a4fdca6..03d5815 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -209,7 +209,12 @@ "theme_auto": "Auto (OS Detect)", "theme_ocean": "Ocean (Glassmorphism)", "theme_material": "Material (Android)", - "theme_cupertino": "Cupertino (iOS)" + "theme_cupertino": "Cupertino (iOS)", + "share_title": "Share Logbook (Read-Only)", + "share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).", + "share_enable": "Enable Public Link", + "share_copied": "Link copied!", + "share_copy_btn": "Copy Link" } } } diff --git a/client/src/services/csvExport.ts b/client/src/services/csvExport.ts index 9b84b78..7fd07fc 100644 --- a/client/src/services/csvExport.ts +++ b/client/src/services/csvExport.ts @@ -12,42 +12,56 @@ function escapeCsvValue(val: string | number | undefined | null): string { return str; } -export async function exportLogbookToCsv(logbookId: string): Promise { - const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() - if (!masterKey) { - throw new Error('Encryption key not found. User must log in.') - } - - // 1. Fetch Yacht details +export async function exportLogbookToCsv(logbookId: string, preloadedData?: { yacht: any; entries: any[] }): Promise { let yachtName = '', homePort = '', owner = '', charter = '', registration = '', callsign = '', atis = '', mmsi = ''; - const yachtRecord = await db.yachts.get(logbookId); - if (yachtRecord) { - try { - const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey); - yachtName = yacht.name || ''; - homePort = yacht.port || ''; - owner = yacht.owner || ''; - charter = yacht.charter || ''; - registration = yacht.registration || ''; - callsign = yacht.callsign || ''; - atis = yacht.atis || ''; - mmsi = yacht.mmsi || ''; - } catch (e) { - console.error('Failed to decrypt yacht details for CSV:', e); - } - } + let decryptedEntries: any[] = []; - // 2. Fetch logbook entries - const localEntries = await db.entries.where({ logbookId }).toArray(); - const decryptedEntries = []; - for (const entry of localEntries) { - try { - const dec = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey); - if (dec) { - decryptedEntries.push(dec); + if (preloadedData) { + const yacht = preloadedData.yacht || {}; + yachtName = yacht.name || ''; + homePort = yacht.port || ''; + owner = yacht.owner || ''; + charter = yacht.charter || ''; + registration = yacht.registration || ''; + callsign = yacht.callsign || ''; + atis = yacht.atis || ''; + mmsi = yacht.mmsi || ''; + decryptedEntries = [...preloadedData.entries]; + } else { + const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() + if (!masterKey) { + throw new Error('Encryption key not found. User must log in.') + } + + // 1. Fetch Yacht details + const yachtRecord = await db.yachts.get(logbookId); + if (yachtRecord) { + try { + const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey); + yachtName = yacht.name || ''; + homePort = yacht.port || ''; + owner = yacht.owner || ''; + charter = yacht.charter || ''; + registration = yacht.registration || ''; + callsign = yacht.callsign || ''; + atis = yacht.atis || ''; + mmsi = yacht.mmsi || ''; + } catch (e) { + console.error('Failed to decrypt yacht details for CSV:', e); + } + } + + // 2. Fetch logbook entries + const localEntries = await db.entries.where({ logbookId }).toArray(); + for (const entry of localEntries) { + try { + const dec = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey); + if (dec) { + decryptedEntries.push(dec); + } + } catch (e) { + console.error('Failed to decrypt entry for CSV:', e); } - } catch (e) { - console.error('Failed to decrypt entry for CSV:', e); } } @@ -127,8 +141,8 @@ export async function exportLogbookToCsv(logbookId: string): Promise { return rows.map(r => r.join(',')).join('\n'); } -export async function downloadCsv(logbookId: string, title: string): Promise { - const csvContent = await exportLogbookToCsv(logbookId); +export async function downloadCsv(logbookId: string, title: string, preloadedData?: { yacht: any; entries: any[] }): Promise { + const csvContent = await exportLogbookToCsv(logbookId, preloadedData); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); @@ -142,8 +156,8 @@ export async function downloadCsv(logbookId: string, title: string): Promise { - const csvContent = await exportLogbookToCsv(logbookId); +export async function shareCsv(logbookId: string, title: string, preloadedData?: { yacht: any; entries: any[] }): Promise { + const csvContent = await exportLogbookToCsv(logbookId, preloadedData); const filename = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_logbook.csv`; const file = new File([csvContent], filename, { type: 'text/csv' }); @@ -158,7 +172,7 @@ export async function shareCsv(logbookId: string, title: string): Promise } catch (e: any) { if (e.name !== 'AbortError') { console.error('Sharing failed, falling back to download:', e); - await downloadCsv(logbookId, title); + await downloadCsv(logbookId, title, preloadedData); } } } else { diff --git a/client/src/services/pdfExport.ts b/client/src/services/pdfExport.ts index 4f3dfde..5318043 100644 --- a/client/src/services/pdfExport.ts +++ b/client/src/services/pdfExport.ts @@ -4,41 +4,55 @@ import { getActiveMasterKey } from './auth.js' import { getLogbookKey } from './logbookKeys.js' import { decryptJson } from './crypto.js' -export async function generateLogbookPagePdf(logbookId: string, entryId: string): Promise { - const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() - if (!masterKey) { - throw new Error('Encryption key not found. Please log in.') - } - - // 1. Fetch Yacht details +export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise { let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = ''; - const yachtRecord = await db.yachts.get(logbookId); - if (yachtRecord) { - try { - const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey); - yachtName = yacht.name || ''; - homePort = yacht.port || ''; - // owner not needed in PDF layout - registration = yacht.registrationNumber || yacht.registration || ''; - callsign = yacht.callSign || ''; - atis = yacht.atis || ''; - mmsi = yacht.mmsi || ''; - } catch (e) { - console.error('Failed to decrypt yacht details for PDF:', e); + let entry: any = null; + + if (preloadedData) { + const yacht = preloadedData.yacht || {}; + yachtName = yacht.name || ''; + homePort = yacht.port || ''; + registration = yacht.registrationNumber || yacht.registration || ''; + callsign = yacht.callSign || ''; + atis = yacht.atis || ''; + mmsi = yacht.mmsi || ''; + entry = preloadedData.entry; + } else { + const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() + if (!masterKey) { + throw new Error('Encryption key not found. Please log in.') } + + // 1. Fetch Yacht details + const yachtRecord = await db.yachts.get(logbookId); + if (yachtRecord) { + try { + const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey); + yachtName = yacht.name || ''; + homePort = yacht.port || ''; + registration = yacht.registrationNumber || yacht.registration || ''; + callsign = yacht.callSign || ''; + atis = yacht.atis || ''; + mmsi = yacht.mmsi || ''; + } catch (e) { + console.error('Failed to decrypt yacht details for PDF:', e); + } + } + + // 2. Fetch active Entry + const entryRecord = await db.entries.get(entryId); + if (!entryRecord) { + throw new Error('Entry not found'); + } + + entry = await decryptJson(entryRecord.encryptedData, entryRecord.iv, entryRecord.tag, masterKey); } - // 2. Fetch active Entry - const entryRecord = await db.entries.get(entryId); - if (!entryRecord) { - throw new Error('Entry not found'); - } - - const entry = await decryptJson(entryRecord.encryptedData, entryRecord.iv, entryRecord.tag, masterKey); if (!entry) { - throw new Error('Failed to decrypt entry'); + throw new Error('Failed to load entry'); } + // Create PDF landscape A4 const doc = new jsPDF({ orientation: 'landscape', @@ -217,8 +231,8 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string) return doc; } -export async function downloadLogbookPagePdf(logbookId: string, entryId: string, dateStr: string): Promise { - const doc = await generateLogbookPagePdf(logbookId, entryId); +export async function downloadLogbookPagePdf(logbookId: string, entryId: string, dateStr: string, preloadedData?: { yacht: any; entry: any }): Promise { + const doc = await generateLogbookPagePdf(logbookId, entryId, preloadedData); const filename = `logbook_${dateStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`; doc.save(filename); } diff --git a/server/src/routes/collaboration.ts b/server/src/routes/collaboration.ts index c18c380..d6dc148 100644 --- a/server/src/routes/collaboration.ts +++ b/server/src/routes/collaboration.ts @@ -54,6 +54,58 @@ router.get('/invite-details', async (req: any, res) => { } }) +// 1b. Public read-only share pull endpoint (does not require authentication) +router.get('/share-pull', async (req: any, res) => { + try { + const { token } = req.query + if (!token) { + return res.status(400).json({ error: 'Token is required' }) + } + + const invitation = await prisma.invitation.findUnique({ + where: { token }, + include: { + logbook: true + } + }) + + if (!invitation) { + return res.status(404).json({ error: 'Share link not found' }) + } + + if (new Date() > invitation.expiresAt) { + return res.status(410).json({ error: 'Share link has expired' }) + } + + if (invitation.role !== 'READ') { + return res.status(403).json({ error: 'Forbidden: Invalid role for public pull' }) + } + + const logbookId = invitation.logbookId + + const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } }) + const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } }) + const crews = await prisma.crewPayload.findMany({ where: { logbookId } }) + const entries = await prisma.entryPayload.findMany({ where: { logbookId } }) + const photos = await prisma.photoPayload.findMany({ where: { logbookId } }) + const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } }) + + return res.json({ + title: invitation.logbook.encryptedTitle, + yacht, + deviation, + crews, + entries, + photos, + gpsTracks + }) + } catch (error: any) { + console.error('Error in share-pull:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + + // 2. Accept invitation (requires authenticated invitee) router.post('/accept', requireUser, async (req: any, res) => { try { @@ -240,4 +292,111 @@ router.delete('/collaborators/:id', async (req: any, res) => { } }) +// 6. Get public share link for a logbook (Owner only) +router.get('/share-link', async (req: any, res) => { + try { + const { logbookId } = req.query + if (!logbookId) { + return res.status(400).json({ error: 'logbookId is required' }) + } + + const logbook = await prisma.logbook.findUnique({ + where: { id: logbookId } + }) + + if (!logbook) { + return res.status(404).json({ error: 'Logbook not found' }) + } + + if (logbook.userId !== req.userId) { + return res.status(403).json({ error: 'Forbidden: Access denied' }) + } + + const invitation = await prisma.invitation.findFirst({ + where: { + logbookId, + role: 'READ', + expiresAt: { + gt: new Date() + } + } + }) + + return res.json({ + token: invitation ? invitation.token : null, + expiresAt: invitation ? invitation.expiresAt : null + }) + } catch (error: any) { + console.error('Error fetching share link:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +// 7. Toggle public share link for a logbook (Owner only) +router.post('/share-link', async (req: any, res) => { + try { + const { logbookId, enabled } = req.body + if (!logbookId || typeof enabled !== 'boolean') { + return res.status(400).json({ error: 'logbookId and enabled are required' }) + } + + const logbook = await prisma.logbook.findUnique({ + where: { id: logbookId } + }) + + if (!logbook) { + return res.status(404).json({ error: 'Logbook not found' }) + } + + if (logbook.userId !== req.userId) { + return res.status(403).json({ error: 'Forbidden: Access denied' }) + } + + if (enabled) { + // Find existing active read-only invitation + let invitation = await prisma.invitation.findFirst({ + where: { + logbookId, + role: 'READ', + expiresAt: { + gt: new Date() + } + } + }) + + if (!invitation) { + // Create one that lasts 100 years + const expiresAt = new Date() + expiresAt.setFullYear(expiresAt.getFullYear() + 100) + + invitation = await prisma.invitation.create({ + data: { + logbookId, + role: 'READ', + expiresAt + } + }) + } + + return res.json({ + token: invitation.token, + expiresAt: invitation.expiresAt + }) + } else { + // Delete any READ invitations + await prisma.invitation.deleteMany({ + where: { + logbookId, + role: 'READ' + } + }) + + return res.json({ success: true }) + } + } catch (error: any) { + console.error('Error toggling share link:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + export default router