+
{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')}
+
+
+
+
+
+ {generatingInvite ? 'Generating...' : t('logs.invite_crew')}
+
+ {t('logs.invite_expires')}
+
+
+ {inviteLink && (
+
+ (e.target as HTMLInputElement).select()}
+ />
+
+ {inviteCopied ? : }
+
+
+ )}
+
+ {/* Collaborator List */}
+
+ {t('logs.collaborators_list')}
+
+
+ {loadingCollabs ? (
+
Loading crew members...
+ ) : collabError ? (
+
{collabError}
+ ) : collaborators.length === 0 ? (
+
No active crew members.
+ ) : (
+
+
+
+
+ Username
+ {t('logs.invite_role')}
+ Joined
+
+
+
+
+ {collaborators.map((c) => (
+
+ {c.username}
+ {c.role}
+ {new Date(c.createdAt).toLocaleDateString()}
+
+ handleRevoke(c.id, c.username)}
+ title="Revoke access"
+ >
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ )}
)
}
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' })
}