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) {
@@ -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 && (
-
-
- {t('logs.new_entry')}
-
+ {!readOnly && (
+
+
+ {t('logs.new_entry')}
+
+ )}
@@ -291,9 +348,11 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
- handleDelete(item.id, e)} title={t('logs.delete_entry')}>
-
-
+ {!readOnly && (
+ handleDelete(item.id, e)} title={t('logs.delete_entry')}>
+
+
+ )}
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
-
+
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} |
-
- handleDeleteEvent(idx)}>
-
-
- |
+ {!readOnly && (
+
+ handleDeleteEvent(idx)}>
+
+
+ |
+ )}
))}
@@ -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')}
-
-
- {t('logs.gps_track_delete')}
-
+ {!readOnly && (
+
+
+ {t('logs.gps_track_delete')}
+
+ )}
@@ -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')}
-
- )}
-
-
-
- {saving ? t('logs.saving') : t('logs.save')}
-
-
+ {!readOnly && (
+
+ {success && (
+
+
+ {t('logs.saved')}
+
+ )}
+
+
+
+ {saving ? t('logs.saving') : t('logs.save')}
+
+
+ )}
)
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 && (
+
+
-
-
-
-
- {uploading ? (
- ⏳
- ) : (
-
- )}
- {uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
-
-
+ )}
{/* Photo Grid */}
{decryptedPhotos.length === 0 ? (
@@ -246,14 +264,16 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)

-
handleDelete(photo.payloadId)}
- title="Remove photo"
- >
-
-
+ {!readOnly && (
+
handleDelete(photo.payloadId)}
+ title="Remove photo"
+ >
+
+
+ )}
{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}
+
+ {i18n.language.startsWith('de') ? 'Erneut versuchen' : 'Retry'}
+
+
+ )
+ }
+
+ return (
+
+ {/* Top Banner indicating read-only shared view */}
+
+
+
+
+
+
+
+
+ {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()}
+ />
+
+ {shareCopied ? : }
+
+
+ )}
+
+ )}
+
{/* 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) {
@@ -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}
- handleRemoveSail(idx)}
- disabled={saving}
- >
-
-
+ {!readOnly && (
+ handleRemoveSail(idx)}
+ disabled={saving}
+ >
+
+
+ )}
))
)}
-
-
setNewSailName(e.target.value)}
- disabled={saving}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- handleAddSail();
- }
- }}
- />
-
-
- {t('vessel.add_sail')}
-
-
+ {!readOnly && (
+
+
setNewSailName(e.target.value)}
+ disabled={saving}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleAddSail();
+ }
+ }}
+ />
+
+
+ {t('vessel.add_sail')}
+
+
+ )}
-
- {success && (
-
-
- {t('vessel.saved')}
-
- )}
-
-
-
- {saving ? t('vessel.saving') : t('vessel.save')}
-
-
+ {!readOnly && (
+
+ {success && (
+
+
+ {t('vessel.saved')}
+
+ )}
+
+
+
+ {saving ? t('vessel.saving') : t('vessel.save')}
+
+
+ )}
)
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