From b3978ed294373d8717c0e4e26d7cc4ba665036ca Mon Sep 17 00:00:00 2001 From: elpatron Date: Thu, 28 May 2026 20:31:10 +0200 Subject: [PATCH] feat(collab): E2E-compliant crew invitations and link-sharing collaboration --- client/src/App.tsx | 29 +- client/src/components/CrewForm.tsx | 13 +- client/src/components/DeviationForm.tsx | 9 +- .../src/components/InvitationAcceptance.tsx | 367 ++++++++++++++++++ client/src/components/LogEntriesList.tsx | 9 +- client/src/components/LogEntryEditor.tsx | 11 +- client/src/components/PhotoCapture.tsx | 7 +- client/src/components/SettingsForm.tsx | 247 +++++++++++- client/src/components/VesselForm.tsx | 9 +- client/src/i18n/locales/de.json | 10 +- client/src/i18n/locales/en.json | 10 +- client/src/services/auth.ts | 2 + client/src/services/csvExport.ts | 5 +- client/src/services/db.ts | 19 + client/src/services/gpsTracker.ts | 16 +- client/src/services/logbook.ts | 77 +++- client/src/services/logbookKeys.ts | 133 +++++++ client/src/services/pdfExport.ts | 5 +- server/prisma/schema.prisma | 48 ++- server/src/index.ts | 2 + server/src/routes/collaboration.ts | 243 ++++++++++++ server/src/routes/sync.ts | 38 +- 22 files changed, 1243 insertions(+), 66 deletions(-) create mode 100644 client/src/components/InvitationAcceptance.tsx create mode 100644 client/src/services/logbookKeys.ts create mode 100644 server/src/routes/collaboration.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index d23ac16..054da7f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 ( +
+ { + 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) + }} + /> +
+ ) + } + if (!isAuthenticated) { return (
@@ -251,7 +278,7 @@ function App() { )} {activeTab === 'settings' && ( - + )}
diff --git a/client/src/components/CrewForm.tsx b/client/src/components/CrewForm.tsx index 18b0d0c..dcd6459 100644 --- a/client/src/components/CrewForm.tsx +++ b/client/src/components/CrewForm.tsx @@ -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(), diff --git a/client/src/components/DeviationForm.tsx b/client/src/components/DeviationForm.tsx index c2dcf3c..e4b9690 100644 --- a/client/src/components/DeviationForm.tsx +++ b/client/src/components/DeviationForm.tsx @@ -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 = {} diff --git a/client/src/components/InvitationAcceptance.tsx b/client/src/components/InvitationAcceptance.tsx new file mode 100644 index 0000000..ea5d9b5 --- /dev/null +++ b/client/src/components/InvitationAcceptance.tsx @@ -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(null) + + // Link parameters + const [token, setToken] = useState('') + const [logbookKey, setLogbookKey] = useState(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(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 ( +
+
+ +

{i18n.language.startsWith('de') ? 'Einladung wird geprüft...' : 'Checking Invitation...'}

+
+

+ {i18n.language.startsWith('de') ? 'Lade Verschlüsselungsschlüssel und Verifizierungstoken...' : 'Retrieving credentials and secure key components...'} +

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

{i18n.language.startsWith('de') ? 'Einladungsfehler' : 'Invitation Error'}

+
+

{error}

+ +
+ +
+
+ ) + } + + return ( +
+
+ +

{i18n.language.startsWith('de') ? 'Logbuch-Einladung' : 'Logbook Invitation'}

+
+ +
+

+ {i18n.language.startsWith('de') ? 'Einladung von' : 'INVITED BY'} +

+

+ Skipper {ownerUsername} +

+

+ {i18n.language.startsWith('de') ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'} +

+

+ {decryptedTitle} +

+
+ + {isLoggedIn ? ( + /* If logged in: Accept and Join immediately */ +
+

+ {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?` + } +

+ +
+ + +
+
+ ) : ( + /* If not logged in: Ask to authenticate or register */ +
+ {loginMode === 'options' && ( +
+

+ {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.' + } +

+ + + +
+
+ + {i18n.language.startsWith('de') ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'} + +
+
+ + +
+ )} + + {loginMode === 'register' && ( +
+
+ + setRegUsername(e.target.value)} + required + /> +
+ +
+ + +
+
+ )} + + {authError && ( +
+ {authError} +
+ )} +
+ )} + +
+ +
+
+ ) +} diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index 075fb65..2eb697b 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -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() diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index da227ef..f642bbe 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -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, diff --git a/client/src/components/PhotoCapture.tsx b/client/src/components/PhotoCapture.tsx index df2ae90..871bab5 100644 --- a/client/src/components/PhotoCapture.tsx +++ b/client/src/components/PhotoCapture.tsx @@ -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 = { diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx index c2511ab..97c94c6 100644 --- a/client/src/components/SettingsForm.tsx +++ b/client/src/components/SettingsForm.tsx @@ -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([]) + const [isOwner, setIsOwner] = useState(false) + const [inviteLink, setInviteLink] = useState('') + const [inviteCopied, setInviteCopied] = useState(false) + const [generatingInvite, setGeneratingInvite] = useState(false) + const [collabError, setCollabError] = useState(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 (
- +

{t('settings.title')}

@@ -90,7 +232,7 @@ export default function SettingsForm() {

-
+
{success && (
@@ -104,6 +246,101 @@ export default function SettingsForm() {
+ + {/* Crew Collaboration Card (Only visible to Logbook Owner) */} + {logbookId && isOwner && ( +
+
+ +

+ {t('logs.invite_crew')} +

+
+ +

+ {t('logs.invite_link_desc')} +

+ +
+ + {t('logs.invite_expires')} +
+ + {inviteLink && ( +
+ (e.target as HTMLInputElement).select()} + /> + +
+ )} + + {/* Collaborator List */} +

+ {t('logs.collaborators_list')} +

+ + {loadingCollabs ? ( +
Loading crew members...
+ ) : collabError ? ( +
{collabError}
+ ) : collaborators.length === 0 ? ( +
No active crew members.
+ ) : ( +
+ + + + + + + + + + + {collaborators.map((c) => ( + + + + + + + ))} + +
Username{t('logs.invite_role')}Joined
{c.username}{c.role}{new Date(c.createdAt).toLocaleDateString()} + +
+
+ )} +
+ )}
) } diff --git a/client/src/components/VesselForm.tsx b/client/src/components/VesselForm.tsx index 3289f6f..1210687 100644 --- a/client/src/components/VesselForm.tsx +++ b/client/src/components/VesselForm.tsx @@ -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(), diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 8487436..9f4b440 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -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", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index f3d98c7..a4fdca6 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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", diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index abee67a..e269af3 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -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') } diff --git a/client/src/services/csvExport.ts b/client/src/services/csvExport.ts index 70148e7..9b84b78 100644 --- a/client/src/services/csvExport.ts +++ b/client/src/services/csvExport.ts @@ -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 { - 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 diff --git a/client/src/services/db.ts b/client/src/services/db.ts index 447c7af..212a4c5 100644 --- a/client/src/services/db.ts +++ b/client/src/services/db.ts @@ -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 photos!: Table gpsTracks!: Table + logbookKeys!: Table syncQueue!: Table 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' + }) } } diff --git a/client/src/services/gpsTracker.ts b/client/src/services/gpsTracker.ts index a22c8b2..e23cd5e 100644 --- a/client/src/services/gpsTracker.ts +++ b/client/src/services/gpsTracker.ts @@ -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 { - 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 { - 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, diff --git a/client/src/services/logbook.ts b/client/src/services/logbook.ts index d5a15d4..5392820 100644 --- a/client/src/services/logbook.ts +++ b/client/src/services/logbook.ts @@ -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 { +// Helper to decrypt a logbook's title using the active logbook key or master key +export async function decryptLogbookTitle(logbookId: string, encryptedTitle: string): Promise { 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 { 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 { })) // 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 { // 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 { 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 { }, body: JSON.stringify({ id: localId, - encryptedTitle: encryptedTitleStr + ...payloadData }) }) @@ -154,7 +200,7 @@ export async function createLogbook(title: string): Promise { 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 { } } +// 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 { const userId = localStorage.getItem('active_userid') diff --git a/client/src/services/logbookKeys.ts b/client/src/services/logbookKeys.ts new file mode 100644 index 0000000..9c8c9e8 --- /dev/null +++ b/client/src/services/logbookKeys.ts @@ -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() + +/** + * 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 { + 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 { + 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 { + 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 +} diff --git a/client/src/services/pdfExport.ts b/client/src/services/pdfExport.ts index 3d155e9..4f3dfde 100644 --- a/client/src/services/pdfExport.ts +++ b/client/src/services/pdfExport.ts @@ -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 { - 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 diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b480be3..f35900d 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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 diff --git a/server/src/index.ts b/server/src/index.ts index 3f6767a..20c40ba 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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) => { diff --git a/server/src/routes/collaboration.ts b/server/src/routes/collaboration.ts new file mode 100644 index 0000000..c18c380 --- /dev/null +++ b/server/src/routes/collaboration.ts @@ -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 diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts index 0bbef9f..d26da61 100644 --- a/server/src/routes/sync.ts +++ b/server/src/routes/sync.ts @@ -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' }) }