Implement E2E-compliant anonymous read-only logbook sharing links

This commit is contained in:
2026-05-28 20:47:52 +02:00
parent b3978ed294
commit 20ff2a0baa
14 changed files with 1172 additions and 359 deletions
+23
View File
@@ -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 (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<ReadOnlyViewer token={shareToken} hexKey={shareKey} />
</div>
)
}
if (isAcceptingInvite) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
+101 -64
View File
@@ -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<string> => {
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) {
<form onSubmit={handleSaveSkipper} className="vessel-form">
<div className="form-grid">
<div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={() => skipFileInputRef.current?.click()}>
<div className="vessel-photo-preview" onClick={readOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: readOnly ? 'default' : 'pointer' }}>
{skipPhoto ? (
<img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" />
) : (
@@ -376,39 +405,43 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
<User size={48} className="placeholder-icon" />
</div>
)}
<div className="vessel-photo-overlay">
<Camera size={24} />
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
</div>
</div>
<div className="vessel-photo-actions">
<button
type="button"
className="btn secondary btn-sm"
onClick={() => skipFileInputRef.current?.click()}
disabled={savingSkipper}
>
<Camera size={16} />
{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}
</button>
{skipPhoto && (
<button
type="button"
className="btn danger btn-sm"
onClick={() => {
setSkipPhoto(null)
if (skipFileInputRef.current) skipFileInputRef.current.value = ''
}}
disabled={savingSkipper}
>
<Trash2 size={16} />
{t('vessel.photo_delete')}
</button>
{!readOnly && (
<div className="vessel-photo-overlay">
<Camera size={24} />
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
</div>
)}
</div>
{!readOnly && (
<div className="vessel-photo-actions">
<button
type="button"
className="btn secondary btn-sm"
onClick={() => skipFileInputRef.current?.click()}
disabled={savingSkipper}
>
<Camera size={16} />
{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}
</button>
{skipPhoto && (
<button
type="button"
className="btn danger btn-sm"
onClick={() => {
setSkipPhoto(null)
if (skipFileInputRef.current) skipFileInputRef.current.value = ''
}}
disabled={savingSkipper}
>
<Trash2 size={16} />
{t('vessel.photo_delete')}
</button>
)}
</div>
)}
<input
type="file"
ref={skipFileInputRef}
@@ -436,7 +469,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
className="input-text"
value={skipName}
onChange={(e) => setSkipName(e.target.value)}
disabled={savingSkipper}
disabled={savingSkipper || readOnly}
required
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
</div>
<div className="form-actions">
{skipperSuccess && (
<div className="success-toast">
<Check size={16} />
<span>{t('crew.saved')}</span>
</div>
)}
{!readOnly && (
<div className="form-actions">
{skipperSuccess && (
<div className="success-toast">
<Check size={16} />
<span>{t('crew.saved')}</span>
</div>
)}
<button type="submit" className="btn primary" disabled={savingSkipper || !skipName.trim()}>
<Save size={18} />
{t('crew.save')}
</button>
</div>
<button type="submit" className="btn primary" disabled={savingSkipper || !skipName.trim()}>
<Save size={18} />
{t('crew.save')}
</button>
</div>
)}
</form>
</div>
@@ -553,7 +588,7 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
<Users size={24} className="form-icon" />
<h2>{t('crew.crew_section')}</h2>
</div>
{crewList.length < 5 && !showMemberForm && (
{!readOnly && crewList.length < 5 && !showMemberForm && (
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
<Plus size={16} />
{t('crew.add_crew')}
@@ -761,14 +796,16 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
)}
<h4>{m.data.name}</h4>
</div>
<div className="card-actions">
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
<Edit2 size={14} />
</button>
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
<Trash2 size={14} />
</button>
</div>
{!readOnly && (
<div className="card-actions">
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
<Edit2 size={14} />
</button>
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
<Trash2 size={14} />
</button>
</div>
)}
</div>
<div className="crew-card-body">
+30 -14
View File
@@ -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<number, string> = {}
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}
/>
</div>
)
})}
</div>
<div className="form-actions mt-6">
{success && (
<div className="success-toast">
<Check size={16} />
<span>{t('deviation.saved')}</span>
</div>
)}
{!readOnly && (
<div className="form-actions mt-6">
{success && (
<div className="success-toast">
<Check size={16} />
<span>{t('deviation.saved')}</span>
</div>
)}
<button type="submit" className="btn primary" disabled={saving}>
<Save size={18} />
{saving ? t('deviation.saving') : t('deviation.save')}
</button>
</div>
<button type="submit" className="btn primary" disabled={saving}>
<Save size={18} />
{saving ? t('deviation.saving') : t('deviation.save')}
</button>
</div>
)}
</form>
</div>
)
+75 -16
View File
@@ -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<DecryptedEntryItem[]>([])
@@ -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) {
<span className="hide-mobile">{t('logs.share_csv')}</span>
</button>
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}>
<Plus size={16} />
{t('logs.new_entry')}
</button>
{!readOnly && (
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}>
<Plus size={16} />
{t('logs.new_entry')}
</button>
)}
</div>
</div>
@@ -291,9 +348,11 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
<Download size={18} />
</button>
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
<Trash2 size={18} />
</button>
{!readOnly && (
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
<Trash2 size={18} />
</button>
)}
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
</div>
+112 -63
View File
@@ -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
/>
</div>
<div className="input-group">
<label>{t('logs.day_of_travel')} *</label>
<label>{t('logs.day_of_travel')}</label>
<input
type="text"
className="input-text"
placeholder="e.g. 1"
value={dayOfTravel}
onChange={(e) => setDayOfTravel(e.target.value)}
disabled={saving}
disabled={saving || readOnly}
required
/>
</div>
@@ -738,10 +787,9 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
<input
type="text"
className="input-text"
placeholder="Starting port name"
value={departure}
onChange={(e) => setDeparture(e.target.value)}
disabled={saving}
disabled={saving || readOnly}
/>
</div>
@@ -750,10 +798,9 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
<input
type="text"
className="input-text"
placeholder="Destination port name"
value={destination}
onChange={(e) => setDestination(e.target.value)}
disabled={saving}
disabled={saving || readOnly}
/>
</div>
</div>
@@ -769,38 +816,35 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
</div>
<div className="consumption-grid">
<div className="input-group">
<label>{t('logs.morning')} (L)</label>
<label>{t('logs.freshwater')} ({t('logs.morning')})</label>
<input
type="number"
step="any"
className="input-text"
value={fwMorning}
onChange={(e) => setFwMorning(e.target.value)}
disabled={saving}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.refilled')} (L)</label>
<label>{t('logs.freshwater')} ({t('logs.refilled')})</label>
<input
type="number"
step="any"
className="input-text"
value={fwRefilled}
onChange={(e) => setFwRefilled(e.target.value)}
disabled={saving}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.evening')} (L)</label>
<label>{t('logs.freshwater')} ({t('logs.evening')})</label>
<input
type="number"
step="any"
className="input-text"
value={fwEvening}
onChange={(e) => setFwEvening(e.target.value)}
disabled={saving}
disabled={saving || readOnly}
/>
</div>
@@ -825,38 +869,35 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
</div>
<div className="consumption-grid">
<div className="input-group">
<label>{t('logs.morning')} (L)</label>
<label>{t('logs.fuel')} ({t('logs.morning')})</label>
<input
type="number"
step="any"
className="input-text"
value={fuelMorning}
onChange={(e) => setFuelMorning(e.target.value)}
disabled={saving}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.refilled')} (L)</label>
<label>{t('logs.fuel')} ({t('logs.refilled')})</label>
<input
type="number"
step="any"
className="input-text"
value={fuelRefilled}
onChange={(e) => setFuelRefilled(e.target.value)}
disabled={saving}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.evening')} (L)</label>
<label>{t('logs.fuel')} ({t('logs.evening')})</label>
<input
type="number"
step="any"
className="input-text"
value={fuelEvening}
onChange={(e) => setFuelEvening(e.target.value)}
disabled={saving}
disabled={saving || readOnly}
/>
</div>
@@ -899,7 +940,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
<th>{t('logs.event_log')}</th>
<th>{t('logs.event_gps')}</th>
<th>{t('logs.event_remarks')}</th>
<th></th>
{!readOnly && <th></th>}
</tr>
</thead>
<tbody>
@@ -928,11 +969,13 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'}
</td>
<td className="remarks-td">{ev.remarks}</td>
<td>
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
<Trash2 size={14} />
</button>
</td>
{!readOnly && (
<td>
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
<Trash2 size={14} />
</button>
</td>
)}
</tr>
))}
</tbody>
@@ -941,8 +984,9 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
)}
{/* Add New Event Form Sub-Card */}
<div className="member-editor-card glass">
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>{t('logs.add_event')}</h4>
{!readOnly && (
<div className="member-editor-card glass">
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>{t('logs.add_event')}</h4>
<div className="form-grid mb-4">
<div className="input-group">
@@ -1187,6 +1231,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
Add Event Entry
</button>
</div>
)}
</div>
{/* GPS Track Upload & Map Visualization */}
@@ -1245,15 +1290,17 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
<Download size={14} />
{t('logs.gps_tracking_btn_gpx')}
</button>
<button
type="button"
className="btn secondary"
onClick={handleDeleteTrack}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
>
<Trash2 size={14} />
{t('logs.gps_track_delete')}
</button>
{!readOnly && (
<button
type="button"
className="btn secondary"
onClick={handleDeleteTrack}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
>
<Trash2 size={14} />
{t('logs.gps_track_delete')}
</button>
)}
</div>
</div>
@@ -1263,7 +1310,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
)}
</div>
<PhotoCapture entryId={entryId} logbookId={logbookId} />
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
{/* Section 4: Sign-Off Signatures */}
<div className="form-card">
@@ -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}
/>
</div>
@@ -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}
/>
</div>
</div>
</div>
{/* Save Controls */}
<div className="form-actions mt-4">
{success && (
<div className="success-toast">
<Check size={16} />
<span>{t('logs.saved')}</span>
</div>
)}
{!readOnly && (
<div className="form-actions mt-4">
{success && (
<div className="success-toast">
<Check size={16} />
<span>{t('logs.saved')}</span>
</div>
)}
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}>
<Save size={18} />
{saving ? t('logs.saving') : t('logs.save')}
</button>
</div>
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}>
<Save size={18} />
{saving ? t('logs.saving') : t('logs.save')}
</button>
</div>
)}
</form>
</div>
)
+65 -45
View File
@@ -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<HTMLInputElement>) => {
const file = e.target.files?.[0]
@@ -197,45 +212,48 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
{error && <div className="auth-error mb-4">{error}</div>}
{/* Upload area */}
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
<label>{t('logs.photo_caption_label')}</label>
{/* Upload Form */}
{!readOnly && (
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
<label>{t('logs.photo_caption_label')}</label>
<input
type="text"
placeholder={t('logs.photo_caption_placeholder')}
className="input-text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
disabled={uploading}
/>
</div>
<input
type="text"
placeholder={t('logs.photo_caption_placeholder')}
className="input-text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
disabled={uploading}
type="file"
accept="image/*"
capture="environment"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<button
type="button"
className="btn primary"
onClick={triggerSelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
</div>
<input
type="file"
accept="image/*"
capture="environment"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<button
type="button"
className="btn primary"
onClick={triggerSelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
</div>
</div>
)}
{/* Photo Grid */}
{decryptedPhotos.length === 0 ? (
@@ -246,14 +264,16 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
<div key={photo.payloadId} className="photo-card glass">
<div className="photo-container">
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
<button
type="button"
className="photo-btn-delete"
onClick={() => handleDelete(photo.payloadId)}
title="Remove photo"
>
<Trash2 size={16} />
</button>
{!readOnly && (
<button
type="button"
className="photo-btn-delete"
onClick={() => handleDelete(photo.payloadId)}
title="Remove photo"
>
<Trash2 size={16} />
</button>
)}
</div>
{photo.caption && (
<div className="photo-caption-bar">
+245
View File
@@ -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<string | null>(null)
// Logbook data states
const [logbookTitle, setLogbookTitle] = useState('Logbook')
const [yacht, setYacht] = useState<any>(null)
const [crews, setCrews] = useState<any[]>([])
const [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([])
const [gpsTracks, setGpsTracks] = useState<any[]>([])
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 (
<div className="tab-placeholder" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<Ship className="header-logo spin" size={48} />
<p>{i18n.language.startsWith('de') ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
</div>
)
}
if (error) {
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '20px', textAlign: 'center' }}>
<AlertCircle size={48} style={{ color: '#ef4444', marginBottom: '16px' }} />
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{i18n.language.startsWith('de') ? 'Verbindungsfehler' : 'Access Error'}</h2>
<p style={{ color: '#94a3b8', maxWidth: '400px', marginBottom: '24px' }}>{error}</p>
<button className="btn primary" onClick={loadData} style={{ width: 'auto' }}>
{i18n.language.startsWith('de') ? 'Erneut versuchen' : 'Retry'}
</button>
</div>
)
}
return (
<div className="app-layout">
{/* Top Banner indicating read-only shared view */}
<div className="sync-progress-bar" style={{ height: '4px', background: 'linear-gradient(90deg, #10b981, #3b82f6)' }} />
<header className="app-header" style={{ borderBottom: '1px solid rgba(16, 185, 129, 0.2)' }}>
<div className="app-header-left">
<div className="app-title-area" style={{ marginLeft: 0 }}>
<h2>{logbookTitle}</h2>
<p className="app-subtitle" style={{ color: '#10b981', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Lock size={12} />
<span>{i18n.language.startsWith('de') ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
</p>
</div>
</div>
<div className="header-actions">
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
</button>
</div>
</header>
<div className="app-body">
<aside className="app-sidebar">
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')}
>
<FileText size={18} />
{t('nav.logs')}
</button>
<button
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => setActiveTab('vessel')}
>
<Ship size={18} />
{t('nav.vessel')}
</button>
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
>
<Users size={18} />
{t('nav.crew')}
</button>
</aside>
<main className="app-content">
{activeTab === 'logs' && (
<LogEntriesList
logbookId="shared"
readOnly={true}
preloadedYacht={yacht}
preloadedEntries={entries}
preloadedPhotos={photos}
preloadedGpsTracks={gpsTracks}
/>
)}
{activeTab === 'vessel' && (
<VesselForm
logbookId="shared"
readOnly={true}
preloadedData={yacht}
/>
)}
{activeTab === 'crew' && (
<CrewForm
logbookId="shared"
readOnly={true}
preloadedData={crews}
/>
)}
</main>
</div>
</div>
)
}
+140
View File
@@ -40,12 +40,101 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
const [collabError, setCollabError] = useState<string | null>(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<HTMLInputElement>) => {
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) {
</div>
</form>
{/* Public Share Link Card (Only visible to Logbook Owner) */}
{logbookId && isOwner && (
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<LinkIcon size={20} style={{ color: '#fbbf24' }} />
<h3 style={{ margin: 0, color: '#fbbf24', fontSize: '16px' }}>
{t('settings.share_title')}
</h3>
</div>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.share_desc')}
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
<input
type="checkbox"
checked={shareEnabled}
onChange={handleToggleShare}
disabled={loadingShareLink}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<span>{t('settings.share_enable')}</span>
</label>
{loadingShareLink && <span style={{ fontSize: '12px', color: '#64748b' }}>Updating...</span>}
</div>
{shareEnabled && shareLink && (
<div className="input-group mb-4" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
readOnly
value={shareLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyShareLink}
style={{ width: 'auto', padding: '10px' }}
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
)}
</div>
)}
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
{logbookId && isOwner && (
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
+110 -83
View File
@@ -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) {
<form onSubmit={handleSubmit} className="vessel-form">
<div className="form-grid">
<div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={triggerFileInput}>
<div className="vessel-photo-preview" onClick={readOnly ? undefined : triggerFileInput} style={{ cursor: readOnly ? 'default' : 'pointer' }}>
{photo ? (
<img src={photo} alt={name || 'Yacht'} className="vessel-photo" />
) : (
@@ -229,36 +246,40 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
<Ship size={48} className="placeholder-icon" />
</div>
)}
<div className="vessel-photo-overlay">
<Camera size={24} />
<span>{photo ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
</div>
</div>
<div className="vessel-photo-actions">
<button
type="button"
className="btn secondary btn-sm"
onClick={triggerFileInput}
disabled={saving}
>
<Camera size={16} />
{photo ? t('vessel.photo_change') : t('vessel.photo_add')}
</button>
{photo && (
<button
type="button"
className="btn danger btn-sm"
onClick={handleRemovePhoto}
disabled={saving}
>
<Trash2 size={16} />
{t('vessel.photo_delete')}
</button>
{!readOnly && (
<div className="vessel-photo-overlay">
<Camera size={24} />
<span>{photo ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
</div>
)}
</div>
{!readOnly && (
<div className="vessel-photo-actions">
<button
type="button"
className="btn secondary btn-sm"
onClick={triggerFileInput}
disabled={saving}
>
<Camera size={16} />
{photo ? t('vessel.photo_change') : t('vessel.photo_add')}
</button>
{photo && (
<button
type="button"
className="btn danger btn-sm"
onClick={handleRemovePhoto}
disabled={saving}
>
<Trash2 size={16} />
{t('vessel.photo_delete')}
</button>
)}
</div>
)}
<input
type="file"
ref={fileInputRef}
@@ -276,7 +297,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
className="input-text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={saving}
disabled={saving || readOnly}
required
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
@@ -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}
/>
</div>
@@ -369,61 +390,67 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
sails.map((sail, idx) => (
<span key={idx} className="sail-badge">
{sail}
<button
type="button"
className="remove-btn"
onClick={() => handleRemoveSail(idx)}
disabled={saving}
>
<X size={14} />
</button>
{!readOnly && (
<button
type="button"
className="remove-btn"
onClick={() => handleRemoveSail(idx)}
disabled={saving}
>
<X size={14} />
</button>
)}
</span>
))
)}
</div>
<div className="add-sail-form">
<input
type="text"
className="input-text"
placeholder={t('vessel.sail_name_placeholder')}
value={newSailName}
onChange={(e) => setNewSailName(e.target.value)}
disabled={saving}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddSail();
}
}}
/>
<button
type="button"
className="btn secondary"
onClick={handleAddSail}
disabled={saving || !newSailName.trim()}
style={{ width: 'auto' }}
>
<Plus size={16} />
{t('vessel.add_sail')}
</button>
</div>
{!readOnly && (
<div className="add-sail-form">
<input
type="text"
className="input-text"
placeholder={t('vessel.sail_name_placeholder')}
value={newSailName}
onChange={(e) => setNewSailName(e.target.value)}
disabled={saving}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddSail();
}
}}
/>
<button
type="button"
className="btn secondary"
onClick={handleAddSail}
disabled={saving || !newSailName.trim()}
style={{ width: 'auto' }}
>
<Plus size={16} />
{t('vessel.add_sail')}
</button>
</div>
)}
</div>
</div>
<div className="form-actions">
{success && (
<div className="success-toast">
<Check size={16} />
<span>{t('vessel.saved')}</span>
</div>
)}
{!readOnly && (
<div className="form-actions">
{success && (
<div className="success-toast">
<Check size={16} />
<span>{t('vessel.saved')}</span>
</div>
)}
<button type="submit" className="btn primary" disabled={saving || !name.trim()}>
<Save size={18} />
{saving ? t('vessel.saving') : t('vessel.save')}
</button>
</div>
<button type="submit" className="btn primary" disabled={saving || !name.trim()}>
<Save size={18} />
{saving ? t('vessel.saving') : t('vessel.save')}
</button>
</div>
)}
</form>
</div>
)
+6 -1
View File
@@ -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"
}
}
}
+6 -1
View File
@@ -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"
}
}
}
+52 -38
View File
@@ -12,42 +12,56 @@ function escapeCsvValue(val: string | number | undefined | null): string {
return str;
}
export async function exportLogbookToCsv(logbookId: string): Promise<string> {
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<string> {
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<string> {
return rows.map(r => r.join(',')).join('\n');
}
export async function downloadCsv(logbookId: string, title: string): Promise<void> {
const csvContent = await exportLogbookToCsv(logbookId);
export async function downloadCsv(logbookId: string, title: string, preloadedData?: { yacht: any; entries: any[] }): Promise<void> {
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<voi
document.body.removeChild(link);
}
export async function shareCsv(logbookId: string, title: string): Promise<void> {
const csvContent = await exportLogbookToCsv(logbookId);
export async function shareCsv(logbookId: string, title: string, preloadedData?: { yacht: any; entries: any[] }): Promise<void> {
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<void>
} 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 {
+44 -30
View File
@@ -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<jsPDF> {
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<jsPDF> {
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<void> {
const doc = await generateLogbookPagePdf(logbookId, entryId);
export async function downloadLogbookPagePdf(logbookId: string, entryId: string, dateStr: string, preloadedData?: { yacht: any; entry: any }): Promise<void> {
const doc = await generateLogbookPagePdf(logbookId, entryId, preloadedData);
const filename = `logbook_${dateStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
doc.save(filename);
}
+159
View File
@@ -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