feat(collab): E2E-compliant crew invitations and link-sharing collaboration
This commit is contained in:
+28
-1
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user