feat(collab): E2E-compliant crew invitations and link-sharing collaboration

This commit is contained in:
2026-05-28 20:31:10 +02:00
parent d8f9585ac8
commit b3978ed294
22 changed files with 1243 additions and 66 deletions
+28 -1
View File
@@ -8,6 +8,7 @@ import CrewForm from './components/CrewForm.tsx'
import DeviationForm from './components/DeviationForm.tsx'
import LogEntriesList from './components/LogEntriesList.tsx'
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 { db } from './services/db.js'
@@ -24,6 +25,7 @@ function App() {
const [online, setOnline] = useState(navigator.onLine)
const [isSyncing, setIsSyncing] = useState(false)
const [appliedTheme, setAppliedTheme] = useState<'ocean' | 'material' | 'cupertino'>('ocean')
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
const syncQueueCount = useLiveQuery(
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
@@ -86,6 +88,11 @@ function App() {
}, [isAuthenticated])
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.has('token')) {
setIsAcceptingInvite(true)
}
const savedUser = localStorage.getItem('active_username')
const key = getActiveMasterKey()
if (savedUser && key) {
@@ -132,6 +139,26 @@ function App() {
localStorage.removeItem('active_logbook_title')
}
if (isAcceptingInvite) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
<InvitationAcceptance
onAccepted={(logbookId, title) => {
setIsAuthenticated(true)
setIsAcceptingInvite(false)
handleSelectLogbook(logbookId, title)
// Clean URL query parameters and hash anchor
window.history.replaceState({}, document.title, window.location.pathname)
}}
onCancel={() => {
setIsAcceptingInvite(false)
window.history.replaceState({}, document.title, window.location.pathname)
}}
/>
</div>
)
}
if (!isAuthenticated) {
return (
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
@@ -251,7 +278,7 @@ function App() {
)}
{activeTab === 'settings' && (
<SettingsForm />
<SettingsForm logbookId={activeLogbookId} />
)}
</main>
</div>
+7 -6
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { useDialog } from './ModalDialog.tsx'
@@ -123,8 +124,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
setLoading(true)
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const localCrews = await db.crews.where({ logbookId }).toArray()
@@ -168,8 +169,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
setSkipperSuccess(false)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const skipperData: CrewMemberData = {
name: skipName.trim(),
@@ -258,8 +259,8 @@ export default function CrewForm({ logbookId }: CrewFormProps) {
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const memberData: CrewMemberData = {
name: memName.trim(),
+5 -4
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { Compass, Save, Check } from 'lucide-react'
@@ -28,8 +29,8 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) {
setLoading(true)
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.deviations.get(logbookId)
if (local) {
@@ -75,8 +76,8 @@ export default function DeviationForm({ logbookId }: DeviationFormProps) {
setSuccess(false)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
// Parse values, substituting 0 if empty
const sanitizedDeviations: Record<number, number> = {}
@@ -0,0 +1,367 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight } from 'lucide-react'
import { getActiveMasterKey, registerUser, loginUser } from '../services/auth.js'
import { decryptJson, encryptBuffer } from '../services/crypto.js'
import { saveLogbookKey } from '../services/logbookKeys.js'
import { useDialog } from './ModalDialog.tsx'
interface InvitationAcceptanceProps {
onAccepted: (logbookId: string, title: string) => void
onCancel: () => void
}
// 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 InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
const { i18n } = useTranslation()
const { showAlert } = useDialog()
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Link parameters
const [token, setToken] = useState('')
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
// Details loaded from server
const [ownerUsername, setOwnerUsername] = useState('')
const [decryptedTitle, setDecryptedTitle] = useState('')
const [logbookId, setLogbookId] = useState('')
// Authentication states
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [username, setUsername] = useState('')
const [loginMode, setLoginMode] = useState<'options' | 'login' | 'register'>('options')
const [regUsername, setRegUsername] = useState('')
const [authError, setAuthError] = useState<string | null>(null)
// Check login state on mount
useEffect(() => {
const key = getActiveMasterKey()
const savedUser = localStorage.getItem('active_username')
if (key && savedUser) {
setIsLoggedIn(true)
setUsername(savedUser)
}
// Extract parameters from URL
const params = new URLSearchParams(window.location.search)
const tokenVal = params.get('token') || ''
setToken(tokenVal)
// Hash anchor (#key=xxx)
const hash = window.location.hash
if (hash.startsWith('#key=')) {
const hexKey = hash.substring(5)
try {
const keyBuffer = hexToBuffer(hexKey)
setLogbookKey(keyBuffer)
} catch (err) {
console.error('Invalid key in URL fragment:', err)
setError('The invitation link is cryptographically invalid or corrupted (missing key).')
}
} else {
setError('The invitation link is missing the necessary decryption key fragment (#key=...).')
}
// Suggest a random guest skipper username
const rand = Math.floor(1000 + Math.random() * 9000)
setRegUsername(`CrewSkipper_${rand}`)
}, [])
// Load invitation details once parameters are ready
useEffect(() => {
if (token && logbookKey) {
loadDetails()
}
}, [token, logbookKey])
const loadDetails = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/collaboration/invite-details?token=${token}`)
if (res.status === 410) {
setError('This invitation link has expired (valid for 48 hours only).')
return
}
if (!res.ok) {
throw new Error('Failed to verify invitation token.')
}
const details = await res.json()
setOwnerUsername(details.ownerUsername)
setLogbookId(details.logbookId)
// Decrypt title client-side using URL key
const parsed = JSON.parse(details.encryptedTitle)
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey!)
setDecryptedTitle(title)
} catch (err: any) {
console.error('Failed to load invitation details:', err)
setError(err.message || 'Invitation details could not be retrieved from the server.')
} finally {
setLoading(false)
}
}
const handleAccept = async () => {
const masterKey = getActiveMasterKey()
const activeUserId = localStorage.getItem('active_userid')
if (!masterKey || !activeUserId || !logbookKey || !logbookId) return
setAccepting(true)
setError(null)
try {
// 1. Encrypt logbook key with user's master key
const aesMasterKey = await window.crypto.subtle.importKey(
'raw',
masterKey,
{ name: 'AES-GCM' },
false,
['encrypt']
)
const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
// 2. Register collaboration on server
const res = await fetch('/api/collaboration/accept', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': activeUserId
},
body: JSON.stringify({
token,
encryptedLogbookKey: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag
})
})
if (!res.ok) {
const serverError = await res.json()
throw new Error(serverError.error || 'Failed to join logbook on the server.')
}
// 3. Save key locally in Dexie
await saveLogbookKey(logbookId, logbookKey)
// 4. Redirect to workspace
onAccepted(logbookId, decryptedTitle)
} catch (err: any) {
console.error('Accepting invitation failed:', err)
setError(err.message || 'Acceptance failed.')
} finally {
setAccepting(false)
}
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setAuthError(null)
setLoading(true)
try {
const result = await loginUser()
if (result.verified && result.prfSuccess) {
setIsLoggedIn(true)
setUsername(result.username || 'Skipper')
} else if (result.verified) {
// Biometrics succeeded but fallback phrase is needed
setAuthError('Device doesn\'t support PRF key derivation. Traditional login is not supported in the invitation screen. Please log in normally on the main page first.')
}
} catch (err: any) {
setAuthError(err.message || 'Passkey authentication failed.')
} finally {
setLoading(false)
}
}
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
if (!regUsername.trim()) return
setAuthError(null)
setLoading(true)
try {
const result = await registerUser(regUsername.trim())
if (result.verified) {
setIsLoggedIn(true)
setUsername(regUsername.trim())
showAlert(`Account created successfully! Your 12-word recovery phrase is: ${result.recoveryPhrase}. Write it down securely!`)
}
} catch (err: any) {
setAuthError(err.message || 'Registration failed.')
} finally {
setLoading(false)
}
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
}
if (loading && !accepting) {
return (
<div className="auth-card glass">
<div className="auth-header">
<Ship className="auth-icon accent spin" size={48} />
<h2>{i18n.language.startsWith('de') ? 'Einladung wird geprüft...' : 'Checking Invitation...'}</h2>
</div>
<p className="recovery-warning">
{i18n.language.startsWith('de') ? 'Lade Verschlüsselungsschlüssel und Verifizierungstoken...' : 'Retrieving credentials and secure key components...'}
</p>
</div>
)
}
if (error) {
return (
<div className="auth-card glass">
<div className="auth-header">
<AlertTriangle className="auth-icon warn" size={48} />
<h2>{i18n.language.startsWith('de') ? 'Einladungsfehler' : 'Invitation Error'}</h2>
</div>
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p>
<div className="auth-actions mt-6">
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
{i18n.language.startsWith('de') ? 'Zurück zum Start' : 'Back to Dashboard'}
</button>
</div>
</div>
)
}
return (
<div className="auth-card glass">
<div className="auth-header">
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} />
<h2>{i18n.language.startsWith('de') ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2>
</div>
<div style={{ textAlign: 'center', margin: '20px 0', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}>
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
{i18n.language.startsWith('de') ? 'Einladung von' : 'INVITED BY'}
</p>
<p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}>
Skipper {ownerUsername}
</p>
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
{i18n.language.startsWith('de') ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
</p>
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
{decryptedTitle}
</p>
</div>
{isLoggedIn ? (
/* If logged in: Accept and Join immediately */
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{i18n.language.startsWith('de')
? `Sie sind angemeldet als ${username}. Möchten Sie diesem Logbuch als Crewmitglied beitreten?`
: `You are logged in as ${username}. Would you like to join this logbook with write permissions?`
}
</p>
<div className="auth-actions mt-4" style={{ display: 'flex', gap: '12px' }}>
<button className="btn secondary" onClick={onCancel} disabled={accepting} style={{ flex: 1 }}>
{i18n.language.startsWith('de') ? 'Abbrechen' : 'Cancel'}
</button>
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ flex: 2 }}>
{accepting ? (i18n.language.startsWith('de') ? 'Beitritt...' : 'Joining...') : (i18n.language.startsWith('de') ? 'Beitreten' : 'Accept & Join')}
<ArrowRight size={16} />
</button>
</div>
</div>
) : (
/* If not logged in: Ask to authenticate or register */
<div style={{ width: '100%' }}>
{loginMode === 'options' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
{i18n.language.startsWith('de')
? 'Sie müssen ein Passkey-Konto besitzen oder erstellen, um E2E-verschlüsselte Einträge zu schreiben.'
: 'You must authenticate or register an E2E-secured crew account to write entries.'
}
</p>
<button className="btn primary" onClick={handleLogin} style={{ width: '100%', padding: '14px' }}>
<LogIn size={16} />
{i18n.language.startsWith('de') ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
</button>
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div>
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
{i18n.language.startsWith('de') ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
</span>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }}></div>
</div>
<button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}>
<UserPlus size={16} />
{i18n.language.startsWith('de') ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
</button>
</div>
)}
{loginMode === 'register' && (
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="input-group">
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>
{i18n.language.startsWith('de') ? 'Skipper- / Benutzername' : 'Skipper / User Name'}
</label>
<input
type="text"
className="input-text"
placeholder="e.g. Max Mustermann"
value={regUsername}
onChange={(e) => setRegUsername(e.target.value)}
required
/>
</div>
<div className="auth-actions">
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
{i18n.language.startsWith('de') ? 'Zurück' : 'Back'}
</button>
<button type="submit" className="btn primary" disabled={!regUsername.trim()}>
{i18n.language.startsWith('de') ? 'Passkey erstellen & beitreten' : 'Create Passkey & Join'}
</button>
</div>
</form>
)}
{authError && (
<div className="auth-error mt-4" style={{ fontSize: '13px' }}>
{authError}
</div>
)}
</div>
)}
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
<button className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
</button>
</div>
</div>
)
}
+5 -4
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson, encryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
@@ -42,8 +43,8 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
setLoading(true)
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.entries.where({ logbookId }).toArray()
@@ -131,8 +132,8 @@ export default function LogEntriesList({ logbookId }: LogEntriesListProps) {
setLoading(true)
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString()
+6 -5
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
@@ -132,7 +133,7 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
useEffect(() => {
async function loadYachtSails() {
try {
const masterKey = getActiveMasterKey()
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) return
const yacht = await db.yachts.get(logbookId)
@@ -155,8 +156,8 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
setLoading(true)
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.entries.get(entryId)
if (local) {
@@ -592,8 +593,8 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
setSuccess(false)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryData = {
date,
+4 -3
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { useLiveQuery } from 'dexie-react-hooks'
@@ -41,7 +42,7 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
async function decryptPhotosList() {
if (!localPhotos) return
const masterKey = getActiveMasterKey()
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) return
const list: DecryptedPhoto[] = []
@@ -102,8 +103,8 @@ export default function PhotoCapture({ entryId, logbookId }: PhotoCaptureProps)
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
// Encrypt
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const photoId = window.crypto.randomUUID()
const photoPayload = {
+242 -5
View File
@@ -1,14 +1,156 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings, Save, Check } from 'lucide-react'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import { useDialog } from './ModalDialog.tsx'
export default function SettingsForm() {
interface SettingsFormProps {
logbookId?: string | null
}
interface Collaborator {
id: string
userId: string
username: string
role: string
createdAt: string
}
// Convert ArrayBuffer to Hex String for URL fragment
const bufferToHex = (buffer: ArrayBuffer): string => {
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
export default function SettingsForm({ logbookId }: SettingsFormProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
// Collaboration States
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
const [isOwner, setIsOwner] = useState(false)
const [inviteLink, setInviteLink] = useState('')
const [inviteCopied, setInviteCopied] = useState(false)
const [generatingInvite, setGeneratingInvite] = useState(false)
const [collabError, setCollabError] = useState<string | null>(null)
const [loadingCollabs, setLoadingCollabs] = useState(false)
useEffect(() => {
if (logbookId) {
loadCollaborators()
}
}, [logbookId])
const loadCollaborators = async () => {
setLoadingCollabs(true)
setCollabError(null)
const userId = localStorage.getItem('active_userid')
if (!userId) return
try {
const res = await fetch(`/api/collaboration/collaborators?logbookId=${logbookId}`, {
headers: {
'X-User-Id': userId
}
})
if (res.status === 403) {
setIsOwner(false)
return
}
if (res.ok) {
setIsOwner(true)
const data = await res.json()
setCollaborators(data)
}
} catch (err) {
console.error('Failed to load collaborators:', err)
setCollabError('Failed to load collaborator list.')
} finally {
setLoadingCollabs(false)
}
}
const handleGenerateInvite = async () => {
if (!logbookId) return
setGeneratingInvite(true)
setInviteLink('')
const userId = localStorage.getItem('active_userid')
if (!userId) return
try {
// 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed)
const logbookKey = await ensureLogbookKey(logbookId)
// 2. Create invite token on server
const res = await fetch('/api/collaboration/invite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ logbookId, role: 'WRITE' })
})
if (!res.ok) {
throw new Error('Failed to create invitation on the server.')
}
const invite = await res.json()
// 3. Format link containing token (URL params) and key (URL hash anchor)
const hexKey = bufferToHex(logbookKey)
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
setInviteLink(link)
} catch (err: any) {
console.error('Failed to generate invite:', err)
showAlert(err.message || 'Failed to generate invite link.')
} finally {
setGeneratingInvite(false)
}
}
const handleCopyInvite = () => {
if (inviteLink) {
navigator.clipboard.writeText(inviteLink)
setInviteCopied(true)
setTimeout(() => setInviteCopied(false), 2000)
}
}
const handleRevoke = async (collabId: string, collName: string) => {
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (await showConfirm(t('logs.revoke_confirm'), collName, t('logs.confirm_yes'), t('logs.confirm_no'))) {
try {
const res = await fetch(`/api/collaboration/collaborators/${collabId}`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
})
if (res.ok) {
setCollaborators(prev => prev.filter(c => c.id !== collabId))
showAlert('Crew member access revoked successfully.')
} else {
throw new Error('Failed to revoke collaborator access.')
}
} catch (err: any) {
console.error('Revocation failed:', err)
showAlert(err.message || 'Failed to revoke access.')
}
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
@@ -29,7 +171,7 @@ export default function SettingsForm() {
return (
<div className="form-card">
<div className="form-header">
<Settings size={24} className="form-icon" />
<SettingsIcon size={24} className="form-icon" />
<div>
<h2>{t('settings.title')}</h2>
<p className="form-subtitle" style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#94a3b8' }}>
@@ -90,7 +232,7 @@ export default function SettingsForm() {
</div>
</div>
<div className="form-actions mt-4">
<div className="form-actions mt-4 mb-6">
{success && (
<div className="success-toast">
<Check size={16} />
@@ -104,6 +246,101 @@ export default function SettingsForm() {
</button>
</div>
</form>
{/* 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' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Users size={20} style={{ color: '#fbbf24' }} />
<h3 style={{ margin: 0, color: '#fbbf24', fontSize: '16px' }}>
{t('logs.invite_crew')}
</h3>
</div>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('logs.invite_link_desc')}
</p>
<div className="form-actions" style={{ justifyContent: 'flex-start', gap: '12px', marginBottom: '20px' }}>
<button
type="button"
className="btn primary"
onClick={handleGenerateInvite}
disabled={generatingInvite}
>
<LinkIcon size={16} />
{generatingInvite ? 'Generating...' : t('logs.invite_crew')}
</button>
<span style={{ fontSize: '12px', color: '#64748b' }}>{t('logs.invite_expires')}</span>
</div>
{inviteLink && (
<div className="input-group mb-6" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
readOnly
value={inviteLink}
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={handleCopyInvite}
style={{ width: 'auto', padding: '10px' }}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
)}
{/* Collaborator List */}
<h4 style={{ color: '#fbbf24', fontSize: '14px', marginBottom: '12px' }}>
{t('logs.collaborators_list')}
</h4>
{loadingCollabs ? (
<div style={{ fontSize: '13px', color: '#64748b' }}>Loading crew members...</div>
) : collabError ? (
<div className="auth-error">{collabError}</div>
) : collaborators.length === 0 ? (
<div style={{ fontSize: '13.5px', color: '#64748b', fontStyle: 'italic' }}>No active crew members.</div>
) : (
<div className="table-responsive">
<table className="event-table" style={{ width: '100%' }}>
<thead>
<tr>
<th>Username</th>
<th>{t('logs.invite_role')}</th>
<th>Joined</th>
<th style={{ width: '60px' }}></th>
</tr>
</thead>
<tbody>
{collaborators.map((c) => (
<tr key={c.id}>
<td style={{ fontWeight: 500 }}>{c.username}</td>
<td>{c.role}</td>
<td>{new Date(c.createdAt).toLocaleDateString()}</td>
<td>
<button
type="button"
className="btn-icon logout"
onClick={() => handleRevoke(c.id, c.username)}
title="Revoke access"
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)
}
+5 -4
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
@@ -38,8 +39,8 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
setLoading(true)
setError(null)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.yachts.get(logbookId)
if (local) {
@@ -147,8 +148,8 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
setSuccess(false)
try {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const yachtData = {
name: name.trim(),
+9 -1
View File
@@ -135,7 +135,15 @@
"gps_track_delete": "Track-Datei löschen",
"gps_track_delete_confirm": "Sind Sie sicher, dass Sie diese Track-Datei dauerhaft löschen möchten?",
"exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen."
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
"invite_crew": "Crew einladen",
"invite_link_copied": "Einladungslink in die Zwischenablage kopiert!",
"invite_link_desc": "Teilen Sie diesen Link mit Crewmitgliedern, um ihnen Schreibrechte für dieses Logbuch zu gewähren.",
"collaborators_list": "Mitglieder / Crew",
"revoke": "Entfernen",
"revoke_confirm": "Sind Sie sicher, dass Sie diesem Crewmitglied den Zugriff entziehen möchten?",
"invite_role": "Rolle",
"invite_expires": "Link ist 48 Stunden lang gültig"
},
"dashboard": {
"title": "Ihre Logbücher",
+9 -1
View File
@@ -135,7 +135,15 @@
"gps_track_delete": "Delete Track File",
"gps_track_delete_confirm": "Are you sure you want to permanently delete this track file?",
"exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead."
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
"invite_crew": "Invite Crew",
"invite_link_copied": "Invitation link copied to clipboard!",
"invite_link_desc": "Share this link with crew members to grant them write permissions for this logbook.",
"collaborators_list": "Members / Crew",
"revoke": "Revoke Access",
"revoke_confirm": "Are you sure you want to revoke access for this crew member?",
"invite_role": "Role",
"invite_expires": "Link expires in 48 hours"
},
"dashboard": {
"title": "Your Logbooks",
+2
View File
@@ -9,6 +9,7 @@ import {
base64ToBuffer,
bufferToBase64
} from './crypto.js'
import { clearLogbookKeysCache } from './logbookKeys.js'
const API_BASE = '/api/auth'
@@ -261,6 +262,7 @@ export async function completeLoginWithRecovery(
export function logoutUser() {
setActiveMasterKey(null)
clearLogbookKeysCache()
localStorage.removeItem('active_username')
localStorage.removeItem('active_userid')
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
function escapeCsvValue(val: string | number | undefined | null): string {
@@ -12,9 +13,9 @@ function escapeCsvValue(val: string | number | undefined | null): string {
}
export async function exportLogbookToCsv(logbookId: string): Promise<string> {
const masterKey = getActiveMasterKey()
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) {
throw new Error('Master key not found. User must log in.')
throw new Error('Encryption key not found. User must log in.')
}
// 1. Fetch Yacht details
+19
View File
@@ -61,6 +61,13 @@ export interface LocalGpsTrack {
updatedAt: string
}
export interface LocalLogbookKey {
logbookId: string
encryptedKey: string
iv: string
tag: string
}
export interface SyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
@@ -79,6 +86,7 @@ class DaagboxDatabase extends Dexie {
entries!: Table<LocalEntry>
photos!: Table<LocalPhoto>
gpsTracks!: Table<LocalGpsTrack>
logbookKeys!: Table<LocalLogbookKey>
syncQueue!: Table<SyncQueueItem>
constructor() {
@@ -101,6 +109,17 @@ class DaagboxDatabase extends Dexie {
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt'
})
this.version(3).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
}
}
+9 -7
View File
@@ -1,5 +1,6 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson, decryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
@@ -20,14 +21,15 @@ export interface SavedGpsTrack {
// Get the decrypted track data for a journal entry (with legacy array format compatibility)
export async function getDecryptedGpsTrack(entryId: string): Promise<SavedGpsTrack | null> {
const masterKey = getActiveMasterKey()
if (!masterKey) {
throw new Error('Master key not found. Please log in.')
}
const record = await db.gpsTracks.get(entryId)
if (!record) return null
const logbookId = record.logbookId
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) {
throw new Error('Encryption key not found. Please log in.')
}
try {
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
if (Array.isArray(decrypted)) {
@@ -55,8 +57,8 @@ export async function saveUploadedGpsTrack(
filename: string,
fileType: string
): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.')
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const trackData: SavedGpsTrack = {
waypoints,
+65 -12
View File
@@ -1,6 +1,7 @@
import { db, type LocalLogbook } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { encryptJson, decryptJson } from './crypto.js'
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
const API_BASE = '/api/logbooks'
@@ -11,8 +12,8 @@ export interface DecryptedLogbook {
isSynced: boolean
}
// Helper to decrypt a logbook's title using the active master key
export async function decryptLogbookTitle(encryptedTitle: string): Promise<string> {
// Helper to decrypt a logbook's title using the active logbook key or master key
export async function decryptLogbookTitle(logbookId: string, encryptedTitle: string): Promise<string> {
const masterKey = getActiveMasterKey()
if (!masterKey) {
throw new Error('Master key not found. User must log in.')
@@ -20,7 +21,8 @@ export async function decryptLogbookTitle(encryptedTitle: string): Promise<strin
try {
const parsed = JSON.parse(encryptedTitle)
const decrypted = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, masterKey)
const key = await getLogbookKey(logbookId) || masterKey
const decrypted = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, key)
return decrypted
} catch (error) {
console.error('Failed to decrypt logbook title:', error)
@@ -53,6 +55,29 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
if (response.ok) {
const serverLogbooks = await response.json()
// Decrypt and save logbook keys locally if they exist
for (const lb of serverLogbooks) {
const encryptedKeyStr = lb.encryptedKey || (lb.collaborators && lb.collaborators[0]?.encryptedLogbookKey)
const ivStr = lb.iv || (lb.collaborators && lb.collaborators[0]?.iv)
const tagStr = lb.tag || (lb.collaborators && lb.collaborators[0]?.tag)
if (encryptedKeyStr && ivStr && tagStr) {
try {
const aesKey = await window.crypto.subtle.importKey(
'raw',
masterKey,
{ name: 'AES-GCM' },
false,
['decrypt']
)
const decryptedKey = await decryptBuffer(encryptedKeyStr, ivStr, tagStr, aesKey)
await saveLogbookKey(lb.id, decryptedKey)
} catch (err) {
console.error(`Failed to decrypt and save logbook key for logbook ${lb.id}:`, err)
}
}
}
// Update Dexie database cache
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({
id: lb.id,
@@ -62,7 +87,6 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
}))
// Clear existing cache for this user and insert new ones
// Note: Currently Dexie schema doesn't store userId on logbook table, but we can bulkPut.
await db.logbooks.bulkPut(localLogbooks)
}
} catch (error) {
@@ -76,7 +100,7 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
// Decrypt titles
const decrypted: DecryptedLogbook[] = []
for (const lb of cachedLogbooks) {
const title = await decryptLogbookTitle(lb.encryptedTitle)
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
decrypted.push({
id: lb.id,
title,
@@ -100,12 +124,34 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
throw new Error('Master key not found. User must log in.')
}
// 1. E2E Encrypt title
const encrypted = await encryptJson(title, masterKey)
const encryptedTitleStr = JSON.stringify(encrypted)
const localId = window.crypto.randomUUID()
// 1. Generate Logbook Key and save it locally
const logbookKey = generateLogbookKey()
await saveLogbookKey(localIdForCreate(), logbookKey) // Generate temporary ID to bind to key
const localId = tempUUID
const now = new Date().toISOString()
// 2. Encrypt logbook key with user's master key
const aesMasterKey = await window.crypto.subtle.importKey(
'raw',
masterKey,
{ name: 'AES-GCM' },
false,
['encrypt']
)
const encryptedKey = await encryptBuffer(logbookKey, aesMasterKey)
// 3. E2E Encrypt title using the Logbook Key
const encrypted = await encryptJson(title, logbookKey)
const encryptedTitleStr = JSON.stringify(encrypted)
const payloadData = {
encryptedTitle: encryptedTitleStr,
encryptedKey: encryptedKey.ciphertext,
iv: encryptedKey.iv,
tag: encryptedKey.tag
}
if (navigator.onLine) {
try {
const response = await fetch(API_BASE, {
@@ -116,7 +162,7 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
},
body: JSON.stringify({
id: localId,
encryptedTitle: encryptedTitleStr
...payloadData
})
})
@@ -154,7 +200,7 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
type: 'logbook',
payloadId: localId,
logbookId: localId,
data: JSON.stringify({ encryptedTitle: encryptedTitleStr }),
data: JSON.stringify(payloadData),
updatedAt: now
})
@@ -166,6 +212,13 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
}
}
// Temporary UUID helpers to preserve single localId assignment across generation
let tempUUID = ''
function localIdForCreate(): string {
tempUUID = window.crypto.randomUUID()
return tempUUID
}
// Delete a logbook and all associated payloads locally and on server
export async function deleteLogbook(id: string): Promise<void> {
const userId = localStorage.getItem('active_userid')
+133
View File
@@ -0,0 +1,133 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { encryptBuffer, decryptBuffer, generateMasterKey } from './crypto.js'
// In-memory cache of decrypted logbook keys (ArrayBuffer)
const keyCache = new Map<string, ArrayBuffer>()
/**
* Retrieves the logbook-specific key for a given logbookId.
* Falls back to the user's master key if no logbook-specific key exists (legacy logbooks).
*/
export async function getLogbookKey(logbookId: string): Promise<ArrayBuffer | null> {
if (keyCache.has(logbookId)) {
return keyCache.get(logbookId)!
}
const record = await db.logbookKeys.get(logbookId)
if (!record) {
return null // Caller will fall back to getActiveMasterKey()
}
const masterKeyBytes = getActiveMasterKey()
if (!masterKeyBytes) {
throw new Error('Master key not found. Please log in.')
}
// Derive CryptoKey from user master key
const aesKey = await window.crypto.subtle.importKey(
'raw',
masterKeyBytes,
{ name: 'AES-GCM' },
false,
['decrypt']
)
// Decrypt logbook key using User Master Key
const decrypted = await decryptBuffer(record.encryptedKey, record.iv, record.tag, aesKey)
keyCache.set(logbookId, decrypted)
return decrypted
}
/**
* Encrypts and stores a logbook-specific key in the local IndexedDB.
*/
export async function saveLogbookKey(logbookId: string, logbookKeyBuffer: ArrayBuffer): Promise<void> {
const masterKeyBytes = getActiveMasterKey()
if (!masterKeyBytes) {
throw new Error('Master key not found. Please log in.')
}
// Derive CryptoKey from user master key
const aesKey = await window.crypto.subtle.importKey(
'raw',
masterKeyBytes,
{ name: 'AES-GCM' },
false,
['encrypt']
)
const encrypted = await encryptBuffer(logbookKeyBuffer, aesKey)
await db.logbookKeys.put({
logbookId,
encryptedKey: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag
})
keyCache.set(logbookId, logbookKeyBuffer)
}
/**
* Generates a new random 256-bit logbook key.
*/
export function generateLogbookKey(): ArrayBuffer {
return generateMasterKey() // 32 random bytes
}
/**
* Clears the in-memory logbook key cache (called on logout).
*/
export function clearLogbookKeysCache() {
keyCache.clear()
}
/**
* Ensures a logbook-specific key exists for a given logbookId.
* If not, it generates a key, encrypts it with the user's master key, saves it locally and in the sync queue.
*/
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
let key = await getLogbookKey(logbookId)
if (key) return key
// Generate new key
key = generateLogbookKey()
await saveLogbookKey(logbookId, key)
// Encrypt it with user master key
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found')
const aesMasterKey = await window.crypto.subtle.importKey(
'raw',
masterKey,
{ name: 'AES-GCM' },
false,
['encrypt']
)
const encrypted = await encryptBuffer(key, aesMasterKey)
// Retrieve local logbook details to preserve encryptedTitle
const localLb = await db.logbooks.get(logbookId)
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
const payloadData = {
encryptedTitle,
encryptedKey: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag
}
// Put in sync queue to update the server logbook record with the key
await db.syncQueue.put({
action: 'create', // Server sync treats create as upsert
type: 'logbook',
payloadId: logbookId,
logbookId,
data: JSON.stringify(payloadData),
updatedAt: new Date().toISOString()
})
return key
}
+3 -2
View File
@@ -1,12 +1,13 @@
import { jsPDF } from 'jspdf'
import { db } from './db.js'
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 = getActiveMasterKey()
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) {
throw new Error('Master key not found. Please log in.')
throw new Error('Encryption key not found. Please log in.')
}
// 1. Fetch Yacht details
+43 -5
View File
@@ -8,17 +8,18 @@ generator client {
}
model User {
id String @id @default(uuid())
username String @unique
createdAt DateTime @default(now())
encryptedMasterKeyPrf String? // Encrypted using PRF-derived key
id String @id @default(uuid())
username String @unique
createdAt DateTime @default(now())
encryptedMasterKeyPrf String? // Encrypted using PRF-derived key
encryptedMasterKeyPrfIv String?
encryptedMasterKeyPrfTag String?
encryptedMasterKeyRec String // Encrypted using 12-word recovery phrase
encryptedMasterKeyRec String // Encrypted using 12-word recovery phrase
encryptedMasterKeyRecIv String
encryptedMasterKeyRecTag String
credentials Credential[]
logbooks Logbook[]
collaborations Collaboration[]
}
model Credential {
@@ -40,16 +41,53 @@ model Logbook {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// E2E Encrypted key for the owner (encrypted with owner's master key)
encryptedKey String?
iv String?
tag String?
yachts YachtPayload[]
crews CrewPayload[]
deviations DeviationPayload[]
entries EntryPayload[]
photos PhotoPayload[]
gpsTracks GpsTrackPayload[]
collaborators Collaboration[]
invitations Invitation[]
@@index([userId])
}
model Collaboration {
id String @id @default(uuid())
logbookId String
userId String
role String // "READ" | "WRITE"
// The Logbook Key encrypted with this collaborator's master key
encryptedLogbookKey String
iv String
tag String
createdAt DateTime @default(now())
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([logbookId, userId])
}
model Invitation {
token String @id @default(uuid())
logbookId String
role String // "READ" | "WRITE"
createdAt DateTime @default(now())
expiresAt DateTime
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
}
model YachtPayload {
id String @id @default(uuid())
logbookId String @unique
+2
View File
@@ -4,6 +4,7 @@ import dotenv from 'dotenv'
import authRouter from './routes/auth.js'
import logbooksRouter from './routes/logbooks.js'
import syncRouter from './routes/sync.js'
import collaborationRouter from './routes/collaboration.js'
import { prisma } from './db.js'
dotenv.config()
@@ -18,6 +19,7 @@ app.use(express.json({ limit: '50mb' }))
app.use('/api/auth', authRouter)
app.use('/api/logbooks', logbooksRouter)
app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter)
// Health check endpoint
app.get('/api/health', async (req, res) => {
+243
View File
@@ -0,0 +1,243 @@
import { Router } from 'express'
import { prisma } from '../db.js'
const router = Router()
// Middleware to extract user ID from headers (for authenticated routes)
const requireUser = (req: any, res: any, next: any) => {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
req.userId = userId
next()
}
// 1. Get invitation details (public route, does not require authentication)
router.get('/invite-details', 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: {
include: {
user: {
select: { username: true }
}
}
}
}
})
if (!invitation) {
return res.status(404).json({ error: 'Invitation not found' })
}
if (new Date() > invitation.expiresAt) {
return res.status(410).json({ error: 'Invitation has expired' })
}
return res.json({
logbookId: invitation.logbookId,
ownerUsername: invitation.logbook.user.username,
encryptedTitle: invitation.logbook.encryptedTitle,
role: invitation.role
})
} catch (error: any) {
console.error('Error fetching invite details:', 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 {
const { token, encryptedLogbookKey, iv, tag } = req.body
if (!token || !encryptedLogbookKey || !iv || !tag) {
return res.status(400).json({ error: 'Missing required parameters' })
}
const invitation = await prisma.invitation.findUnique({
where: { token }
})
if (!invitation) {
return res.status(404).json({ error: 'Invitation not found' })
}
if (new Date() > invitation.expiresAt) {
return res.status(410).json({ error: 'Invitation has expired' })
}
// Check if user is already a collaborator or owner
const logbook = await prisma.logbook.findUnique({
where: { id: invitation.logbookId }
})
if (!logbook) {
return res.status(404).json({ error: 'Logbook not found' })
}
if (logbook.userId === req.userId) {
return res.status(400).json({ error: 'You are already the owner of this logbook' })
}
// Create collaboration record
const collaboration = await prisma.collaboration.upsert({
where: {
logbookId_userId: {
logbookId: invitation.logbookId,
userId: req.userId
}
},
create: {
logbookId: invitation.logbookId,
userId: req.userId,
role: invitation.role,
encryptedLogbookKey,
iv,
tag
},
update: {
role: invitation.role,
encryptedLogbookKey,
iv,
tag
}
})
return res.json({
success: true,
logbookId: invitation.logbookId,
role: invitation.role
})
} catch (error: any) {
console.error('Error accepting invitation:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
// All subsequent routes require authentication
router.use(requireUser)
// 3. Create invitation token (Owner only)
router.post('/invite', async (req: any, res) => {
try {
const { logbookId, role } = req.body
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' })
}
// Only owner can invite
if (logbook.userId !== req.userId) {
return res.status(403).json({ error: 'Forbidden: Only the owner can invite collaborators' })
}
// Set expiration to 48 hours from now
const expiresAt = new Date()
expiresAt.setHours(expiresAt.getHours() + 48)
const invitation = await prisma.invitation.create({
data: {
logbookId,
role: role || 'WRITE',
expiresAt
}
})
return res.json({
token: invitation.token,
expiresAt: invitation.expiresAt
})
} catch (error: any) {
console.error('Error creating invitation:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
// 4. Get list of current collaborators
router.get('/collaborators', 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 collaborators = await prisma.collaboration.findMany({
where: { logbookId },
include: {
user: {
select: { username: true }
}
}
})
return res.json(collaborators.map((c: any) => ({
id: c.id,
userId: c.userId,
username: c.user.username,
role: c.role,
createdAt: c.createdAt
})))
} catch (error: any) {
console.error('Error fetching collaborators:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
// 5. Revoke collaborator access (Owner only)
router.delete('/collaborators/:id', async (req: any, res) => {
try {
const { id } = req.params
const collaboration = await prisma.collaboration.findUnique({
where: { id },
include: { logbook: true }
})
if (!collaboration) {
return res.status(404).json({ error: 'Collaboration not found' })
}
// Only owner can revoke
if (collaboration.logbook.userId !== req.userId) {
return res.status(403).json({ error: 'Forbidden: Only the owner can revoke access' })
}
await prisma.collaboration.delete({
where: { id }
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error revoking collaboration:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
export default router
+34 -4
View File
@@ -49,10 +49,16 @@ router.post('/push', async (req: any, res) => {
id: logbookId,
userId: req.userId,
encryptedTitle: parsed.encryptedTitle,
encryptedKey: parsed.encryptedKey || null,
iv: parsed.iv || null,
tag: parsed.tag || null,
updatedAt: itemUpdatedAt
},
update: {
encryptedTitle: parsed.encryptedTitle,
encryptedKey: parsed.encryptedKey || null,
iv: parsed.iv || null,
tag: parsed.tag || null,
updatedAt: itemUpdatedAt
}
})
@@ -60,7 +66,7 @@ router.post('/push', async (req: any, res) => {
continue
}
// Standard Authorization: Logbook must exist and belong to user
// Standard Authorization: Logbook must exist and belong to user or collaborator
const logbook = await prisma.logbook.findUnique({
where: { id: logbookId }
})
@@ -70,12 +76,26 @@ router.post('/push', async (req: any, res) => {
continue
}
if (logbook.userId !== req.userId) {
const isOwner = logbook.userId === req.userId
const isCollaborator = await prisma.collaboration.findUnique({
where: {
logbookId_userId: {
logbookId,
userId: req.userId
}
}
})
if (!isOwner && !isCollaborator) {
results.push({ payloadId, status: 'error', error: 'Forbidden: Access denied' })
continue
}
if (type === 'logbook' && action === 'delete') {
if (!isOwner) {
results.push({ payloadId, status: 'error', error: 'Forbidden: Only owner can delete logbook' })
continue
}
await prisma.logbook.delete({
where: { id: logbookId }
})
@@ -211,7 +231,7 @@ router.get('/pull', async (req: any, res) => {
return res.status(400).json({ error: 'logbookId is required' })
}
// Authorize: Check if logbook belongs to user
// Authorize: Check if logbook belongs to user or is collaborator
const logbook = await prisma.logbook.findUnique({
where: { id: logbookId }
})
@@ -220,7 +240,17 @@ router.get('/pull', async (req: any, res) => {
return res.status(404).json({ error: 'Logbook not found' })
}
if (logbook.userId !== req.userId) {
const isOwner = logbook.userId === req.userId
const isCollaborator = await prisma.collaboration.findUnique({
where: {
logbookId_userId: {
logbookId,
userId: req.userId
}
}
})
if (!isOwner && !isCollaborator) {
return res.status(403).json({ error: 'Forbidden: Access denied' })
}