From 6e2dce6ec54e989637b2bdbe0e2aa1fd873277cb Mon Sep 17 00:00:00 2001 From: elpatron Date: Wed, 27 May 2026 21:33:16 +0200 Subject: [PATCH] feat & docs: implement multi-logbook database cache, API routes, and switcher dashboard --- .planning/STATE.md | 6 +- client/src/App.css | 463 +++++++++++++++++++++ client/src/App.tsx | 196 +++++++-- client/src/components/LogbookDashboard.tsx | 209 ++++++++++ client/src/i18n/locales/de.json | 12 + client/src/i18n/locales/en.json | 12 + client/src/services/auth.ts | 9 +- client/src/services/db.ts | 75 ++++ client/src/services/logbook.ts | 215 ++++++++++ server/src/index.ts | 2 + server/src/routes/logbooks.ts | 83 ++++ 11 files changed, 1252 insertions(+), 30 deletions(-) create mode 100644 client/src/components/LogbookDashboard.tsx create mode 100644 client/src/services/db.ts create mode 100644 client/src/services/logbook.ts create mode 100644 server/src/routes/logbooks.ts diff --git a/.planning/STATE.md b/.planning/STATE.md index 3963c20..d500911 100755 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -10,11 +10,11 @@ See: .planning/PROJECT.md (updated 2026-05-26) ## Current Position Phase: 2 of 4 (Sync Protocol & Multi-Logbooks) -Plan: 1 of 2 in current phase +Plan: 2 of 2 in current phase Status: Ready to plan -Last activity: 2026-05-27 — Phase 1 completed (Vite PWA, Express backend, WebAuthn Passkeys, and client E2E Crypto integration complete) +Last activity: 2026-05-27 — Plan 02-01 completed (Multi-logbooks IndexedDB Dexie.js caching, E2E title encryption client service, and dashboard UI switching complete) -Progress: [███░░░░░░░] 30% +Progress: [████░░░░░░] 40% ## Performance Metrics diff --git a/client/src/App.css b/client/src/App.css index 966fd25..f85e33d 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -320,3 +320,466 @@ body { line-height: 150%; margin-bottom: 24px; } + +/* Dashboard Layout and Styles */ +.dashboard-container { + width: 100%; + max-width: 1200px; + min-height: 100vh; + display: flex; + flex-direction: column; + padding: 24px; + box-sizing: border-box; + color: #f1f5f9; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 32px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(212, 175, 55, 0.15); +} + +.header-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.header-logo { + color: #fbbf24; + filter: drop-shadow(0 0 8px rgba(251, 191, 36, 0.4)); +} + +.header-brand h1 { + font-size: 24px; + font-weight: 700; + margin: 0; + background: linear-gradient(135deg, #fef08a 0%, #d97706 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.header-brand .subtitle { + font-size: 12px; + color: #94a3b8; + margin: 2px 0 0 0; +} + +.header-actions { + display: flex; + align-items: center; + gap: 16px; +} + +.conn-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + padding: 6px 12px; + border-radius: 20px; + font-weight: 500; +} + +.conn-status.online { + background: rgba(34, 197, 94, 0.1); + color: #4ade80; + border: 1px solid rgba(34, 197, 94, 0.2); +} + +.conn-status.offline { + background: rgba(239, 68, 68, 0.1); + color: #f87171; + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.skipper-badge { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + padding: 6px 12px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #e2e8f0; +} + +.btn-icon { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #94a3b8; + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-icon:hover { + background: rgba(217, 119, 6, 0.1); + border-color: #d97706; + color: #fbbf24; +} + +.btn-icon.logout:hover { + background: rgba(239, 68, 68, 0.1); + border-color: #ef4444; + color: #f87171; +} + +.dashboard-main { + display: grid; + grid-template-columns: 350px 1fr; + gap: 32px; + align-items: start; +} + +@media (max-width: 900px) { + .dashboard-main { + grid-template-columns: 1fr; + padding: 0 10px; + } +} + +.create-section { + background: rgba(11, 12, 16, 0.6); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(212, 175, 55, 0.2); + border-radius: 16px; + padding: 24px; + box-sizing: border-box; +} + +.create-section h2 { + font-size: 18px; + margin-top: 0; + margin-bottom: 20px; + color: #f8fafc; +} + +.dashboard-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.mt-4 { + margin-top: 16px; +} + +.list-section { + display: flex; + flex-direction: column; + gap: 20px; +} + +.section-title-bar { + display: flex; + justify-content: space-between; + align-items: center; +} + +.section-title-bar h2 { + font-size: 20px; + margin: 0; + color: #f8fafc; +} + +.btn-refresh { + background: none; + border: none; + color: #94a3b8; + cursor: pointer; + padding: 6px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.btn-refresh:hover { + color: #fbbf24; +} + +.spin { + animation: rotation 1s infinite linear; +} + +@keyframes rotation { + from { transform: rotate(0deg); } + to { transform: rotate(359deg); } +} + +.dashboard-status-msg { + padding: 40px; + text-align: center; + color: #94a3b8; + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); + border: 1px dashed rgba(255, 255, 255, 0.08); +} + +.logbooks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.logbook-card { + background: rgba(11, 12, 16, 0.6); + backdrop-filter: blur(15px); + -webkit-backdrop-filter: blur(15px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 20px; + display: flex; + align-items: center; + gap: 16px; + cursor: pointer; + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.logbook-card:hover { + transform: translateY(-2px); + border-color: rgba(212, 175, 55, 0.4); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); + background: rgba(11, 12, 16, 0.75); +} + +.card-icon { + background: rgba(217, 119, 6, 0.1); + color: #fbbf24; + width: 48px; + height: 48px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border: 1px solid rgba(217, 119, 6, 0.2); +} + +.card-info { + flex-grow: 1; + min-width: 0; +} + +.card-info h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #f1f5f9; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-meta { + display: flex; + align-items: center; + gap: 10px; + margin-top: 6px; + font-size: 11px; +} + +.sync-badge { + padding: 2px 6px; + border-radius: 4px; + font-weight: 500; +} + +.sync-badge.synced { + background: rgba(34, 197, 94, 0.1); + color: #86efac; +} + +.sync-badge.local { + background: rgba(234, 179, 8, 0.1); + color: #fde047; +} + +.date-badge { + color: #64748b; +} + +.btn-delete { + background: none; + border: none; + color: #475569; + cursor: pointer; + padding: 6px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all 0.2s ease; + position: absolute; + top: 12px; + right: 12px; +} + +.logbook-card:hover .btn-delete { + opacity: 1; +} + +.btn-delete:hover { + color: #f43f5e; + background: rgba(244, 63, 94, 0.1); +} + +/* Active Logbook App Layout */ +.app-layout { + width: 100%; + max-width: 1200px; + min-height: 100vh; + display: flex; + flex-direction: column; + padding: 24px; + box-sizing: border-box; + color: #f1f5f9; +} + +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(212, 175, 55, 0.15); +} + +.app-header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.btn-back { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #fbbf24; + padding: 8px 16px; + border-radius: 8px; + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-weight: 500; + transition: all 0.2s; +} + +.btn-back:hover { + background: rgba(217, 119, 6, 0.1); + border-color: #d97706; +} + +.app-title-area h2 { + font-size: 20px; + margin: 0; + color: #f8fafc; +} + +.app-title-area .app-subtitle { + font-size: 12px; + color: #94a3b8; + margin: 2px 0 0 0; +} + +.app-body { + display: grid; + grid-template-columns: 240px 1fr; + gap: 24px; + align-items: start; +} + +@media (max-width: 768px) { + .app-body { + grid-template-columns: 1fr; + } +} + +.app-sidebar { + display: flex; + flex-direction: column; + gap: 8px; + background: rgba(11, 12, 16, 0.6); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 16px; + box-sizing: border-box; +} + +.sidebar-btn { + background: none; + border: none; + color: #94a3b8; + padding: 12px 16px; + border-radius: 8px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + text-align: left; + font-weight: 500; + transition: all 0.2s; + width: 100%; + box-sizing: border-box; +} + +.sidebar-btn:hover { + background: rgba(255, 255, 255, 0.04); + color: #f1f5f9; +} + +.sidebar-btn.active { + background: rgba(217, 119, 6, 0.1); + border: 1px solid rgba(217, 119, 6, 0.2); + color: #fbbf24; +} + +.app-content { + background: rgba(11, 12, 16, 0.6); + backdrop-filter: blur(20px); + border: 1px solid rgba(212, 175, 55, 0.2); + border-radius: 16px; + padding: 32px; + min-height: 400px; + box-sizing: border-box; + animation: fadeIn 0.3s ease-out; +} + +.tab-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + text-align: center; + color: #94a3b8; +} + +.tab-placeholder h3 { + font-size: 24px; + color: #fbbf24; + margin-top: 16px; + margin-bottom: 8px; +} + +.tab-placeholder p { + max-width: 400px; + line-height: 150%; + margin: 0; +} diff --git a/client/src/App.tsx b/client/src/App.tsx index f77c1d1..6654b7c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,61 +1,205 @@ import { useState, useEffect } from 'react' import './App.css' import AuthOnboarding from './components/AuthOnboarding.tsx' +import LogbookDashboard from './components/LogbookDashboard.tsx' import { getActiveMasterKey, logoutUser } from './services/auth.js' -import { Anchor, LogOut, ShieldCheck, Database } from 'lucide-react' +import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react' +import { useTranslation } from 'react-i18next' function App() { + const { t } = useTranslation() const [isAuthenticated, setIsAuthenticated] = useState(false) - const [username, setUsername] = useState(null) + const [activeLogbookId, setActiveLogbookId] = useState(null) + const [activeLogbookTitle, setActiveLogbookTitle] = useState(null) + const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'deviation' | 'logs' | 'settings'>('logs') + const [online, setOnline] = useState(navigator.onLine) + + useEffect(() => { + const handleOnline = () => setOnline(true) + const handleOffline = () => setOnline(false) + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + return () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + }, []) useEffect(() => { const savedUser = localStorage.getItem('active_username') const key = getActiveMasterKey() if (savedUser && key) { setIsAuthenticated(true) - setUsername(savedUser) + const savedLogbookId = localStorage.getItem('active_logbook_id') + const savedLogbookTitle = localStorage.getItem('active_logbook_title') + if (savedLogbookId && savedLogbookTitle) { + setActiveLogbookId(savedLogbookId) + setActiveLogbookTitle(savedLogbookTitle) + } } }, []) const handleAuthenticated = () => { setIsAuthenticated(true) - setUsername(localStorage.getItem('active_username')) + const savedLogbookId = localStorage.getItem('active_logbook_id') + const savedLogbookTitle = localStorage.getItem('active_logbook_title') + if (savedLogbookId && savedLogbookTitle) { + setActiveLogbookId(savedLogbookId) + setActiveLogbookTitle(savedLogbookTitle) + } } const handleLogout = () => { logoutUser() setIsAuthenticated(false) - setUsername(null) + setActiveLogbookId(null) + setActiveLogbookTitle(null) + localStorage.removeItem('active_logbook_id') + localStorage.removeItem('active_logbook_title') + } + + const handleSelectLogbook = (id: string, title: string) => { + setActiveLogbookId(id) + setActiveLogbookTitle(title) + localStorage.setItem('active_logbook_id', id) + localStorage.setItem('active_logbook_title', title) + } + + const handleBackToDashboard = () => { + setActiveLogbookId(null) + setActiveLogbookTitle(null) + localStorage.removeItem('active_logbook_id') + localStorage.removeItem('active_logbook_title') } if (!isAuthenticated) { return } + if (!activeLogbookId) { + return ( + + ) + } + return ( -
-
- -

Kapteins Daagbox

-

- - Session Decrypted (Zero-Knowledge) -

-
+
+ {/* Active Logbook Header */} +
+
+ +
+

{activeLogbookTitle}

+

{t('app.name')} / {activeLogbookId.substring(0, 8)}...

+
+
-
-

Skipper: {username}

-

Status: E2E Secure Connection Active

-

- - Local IndexedDB synced with zero-knowledge PostgreSQL server payload -

-
+
+
+ {online ? : } + {online ? 'Online' : t('sync.status_offline')} +
- + +
+
+ + {/* Active Workspace */} +
+ {/* Navigation Sidebar */} + + + {/* Tab Content Panels (Placeholder until Phase 3) */} +
+ {activeTab === 'logs' && ( +
+ +

{t('nav.logs')}

+

Journal event entries, GPS navigation records, and meteorological reports will be listed and edited here.

+
+ )} + + {activeTab === 'vessel' && ( +
+ +

{t('nav.vessel')}

+

Master vessel profile details such as name, home port, call sign, and MMSI registration are managed here.

+
+ )} + + {activeTab === 'crew' && ( +
+ +

{t('nav.crew')}

+

Skipper, mate, and crew records conforming to marine credentials list are maintained here.

+
+ )} + + {activeTab === 'deviation' && ( +
+ +

{t('nav.deviation')}

+

Magnetic compass deviation table calibration grids and calculations are rendered here.

+
+ )} + + {activeTab === 'settings' && ( +
+ +

{t('nav.settings')}

+

Logbook sync properties, local cache maintenance, and CSV data tools are configured here.

+
+ )} +
+
) } diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx new file mode 100644 index 0000000..f63581a --- /dev/null +++ b/client/src/components/LogbookDashboard.tsx @@ -0,0 +1,209 @@ +import React, { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js' +import { logoutUser } from '../services/auth.js' +import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react' + +interface LogbookDashboardProps { + onSelectLogbook: (id: string, title: string) => void + onLogout: () => void +} + +export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookDashboardProps) { + const { t, i18n } = useTranslation() + const [logbooks, setLogbooks] = useState([]) + const [newTitle, setNewTitle] = useState('') + const [loading, setLoading] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [error, setError] = useState(null) + const [online, setOnline] = useState(navigator.onLine) + const [username] = useState(localStorage.getItem('active_username') || 'Skipper') + + // Listen to connectivity changes + useEffect(() => { + const handleOnline = () => setOnline(true) + const handleOffline = () => setOnline(false) + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + return () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + }, []) + + // Load logbooks on mount + useEffect(() => { + loadLogbooks() + }, []) + + const loadLogbooks = async (isRefresh = false) => { + if (isRefresh) setRefreshing(true) + else setLoading(true) + setError(null) + try { + const data = await fetchLogbooks() + setLogbooks(data) + } catch (err: any) { + setError(err.message || 'Failed to load logbooks') + } finally { + setLoading(false) + setRefreshing(false) + } + } + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault() + if (!newTitle.trim()) return + + setLoading(true) + setError(null) + try { + const created = await createLogbook(newTitle.trim()) + setLogbooks((prev) => [created, ...prev]) + setNewTitle('') + } catch (err: any) { + setError(err.message || 'Failed to create logbook') + } finally { + setLoading(false) + } + } + + const handleDelete = async (id: string, e: React.MouseEvent) => { + e.stopPropagation() // Prevent selecting the logbook when clicking delete + + if (window.confirm(t('dashboard.delete_confirm'))) { + setLoading(true) + setError(null) + try { + await deleteLogbook(id) + setLogbooks((prev) => prev.filter((lb) => lb.id !== id)) + } catch (err: any) { + setError(err.message || 'Failed to delete logbook') + } finally { + setLoading(false) + } + } + } + + const handleLogout = () => { + logoutUser() + onLogout() + } + + const toggleLanguage = () => { + const nextLang = i18n.language.startsWith('de') ? 'en' : 'de' + i18n.changeLanguage(nextLang) + } + + return ( +
+ {/* Premium Dashboard Header */} +
+
+ +
+

{t('app.name')}

+

{t('app.tagline')}

+
+
+ +
+ {/* Connection Indicator */} +
+ {online ? : } + {online ? 'Online' : t('sync.status_offline')} +
+ + {/* Skipper profile */} +
+ + {username} +
+ + {/* Lang toggle */} + + + {/* Logout */} + +
+
+ + {/* Main Dashboard Layout */} +
+ {/* Left Side: Create form */} +
+

{t('dashboard.create_btn')}

+
+
+ setNewTitle(e.target.value)} + disabled={loading} + required + /> +
+ +
+ + {error &&
{error}
} +
+ + {/* Right Side: Logbooks list */} +
+
+

{t('dashboard.title')}

+ +
+ + {loading && !refreshing ? ( +
{t('dashboard.loading')}
+ ) : logbooks.length === 0 ? ( +
{t('dashboard.no_logbooks')}
+ ) : ( +
+ {logbooks.map((lb) => ( +
onSelectLogbook(lb.id, lb.title)}> +
+ +
+ +
+

{lb.title}

+
+ + {lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')} + + + {new Date(lb.updatedAt).toLocaleDateString(i18n.language, { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + +
+
+ + +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index c0f8f4e..cf5d87a 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -44,6 +44,18 @@ "coordinates": "Koordinaten", "weather": "Wetterbedingungen", "save": "Eintrag speichern" + }, + "dashboard": { + "title": "Ihre Logbücher", + "subtitle": "Wählen Sie ein Logbuch aus oder erstellen Sie ein neues, um Ihre Reisen zu verwalten.", + "create_btn": "Logbuch erstellen", + "new_logbook_placeholder": "Name des Logbuchs oder der Yacht", + "logout": "Abmelden", + "delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Backups werden vernichtet.", + "no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!", + "loading": "Logbücher werden geladen...", + "status_synced": "Synchronisiert", + "status_local": "Nur lokaler Cache" } } } diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index b43d231..7bebe24 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -44,6 +44,18 @@ "coordinates": "Coordinates", "weather": "Weather Conditions", "save": "Save Entry" + }, + "dashboard": { + "title": "Your Logbooks", + "subtitle": "Select a logbook or create a new one to manage your journeys.", + "create_btn": "Create Logbook", + "new_logbook_placeholder": "Logbook or Yacht Name", + "logout": "Logout", + "delete_confirm": "Are you sure you want to permanently delete this logbook? All local cache and server backups will be destroyed.", + "no_logbooks": "No logbooks found. Create your first logbook to begin!", + "loading": "Loading logbooks...", + "status_synced": "Synced", + "status_local": "Local Cache Only" } } } diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index a2b56f1..08f14ba 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -101,6 +101,7 @@ export async function registerUser(username: string): Promise { ) activeMasterKey = decryptedMaster localStorage.setItem('active_username', username) + localStorage.setItem('active_userid', result.userId) return { verified: true, prfSuccess: true } } catch (e) { console.warn('PRF decryption failed, falling back to recovery phrase:', e) @@ -194,7 +197,8 @@ export async function loginUser(username: string): Promise { encryptedPayloads: { encryptedMasterKeyRec: result.encryptedMasterKeyRec, encryptedMasterKeyRecIv: result.encryptedMasterKeyRecIv, - encryptedMasterKeyRecTag: result.encryptedMasterKeyRecTag + encryptedMasterKeyRecTag: result.encryptedMasterKeyRecTag, + userId: result.userId } } } @@ -207,6 +211,7 @@ export async function completeLoginWithRecovery( encryptedMasterKeyRec: string encryptedMasterKeyRecIv: string encryptedMasterKeyRecTag: string + userId: string } ): Promise { try { @@ -219,6 +224,7 @@ export async function completeLoginWithRecovery( ) activeMasterKey = decryptedMaster localStorage.setItem('active_username', username) + localStorage.setItem('active_userid', encryptedPayloads.userId) return true } catch (error) { console.error('Failed to decrypt master key with recovery phrase:', error) @@ -229,4 +235,5 @@ export async function completeLoginWithRecovery( export function logoutUser() { activeMasterKey = null localStorage.removeItem('active_username') + localStorage.removeItem('active_userid') } diff --git a/client/src/services/db.ts b/client/src/services/db.ts new file mode 100644 index 0000000..8a83014 --- /dev/null +++ b/client/src/services/db.ts @@ -0,0 +1,75 @@ +import Dexie, { type Table } from 'dexie' + +export interface LocalLogbook { + id: string + encryptedTitle: string + updatedAt: string + isSynced: number // 1 = yes, 0 = pending local modifications +} + +export interface LocalYacht { + logbookId: string + encryptedData: string + iv: string + tag: string + updatedAt: string +} + +export interface LocalCrew { + payloadId: string + logbookId: string + encryptedData: string + iv: string + tag: string + updatedAt: string +} + +export interface LocalDeviation { + logbookId: string + encryptedData: string + iv: string + tag: string + updatedAt: string +} + +export interface LocalEntry { + payloadId: string + logbookId: string + encryptedData: string + iv: string + tag: string + updatedAt: string +} + +export interface SyncQueueItem { + id?: number + action: 'create' | 'update' | 'delete' + type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' + payloadId: string // payloadId or logbookId depending on the type + logbookId: string + data: string // JSON representation of the local record + updatedAt: string +} + +class DaagboxDatabase extends Dexie { + logbooks!: Table + yachts!: Table + crews!: Table + deviations!: Table + entries!: Table + syncQueue!: Table + + constructor() { + super('DaagboxDatabase') + this.version(1).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' + }) + } +} + +export const db = new DaagboxDatabase() diff --git a/client/src/services/logbook.ts b/client/src/services/logbook.ts new file mode 100644 index 0000000..00893cf --- /dev/null +++ b/client/src/services/logbook.ts @@ -0,0 +1,215 @@ +import { db, type LocalLogbook } from './db.js' +import { getActiveMasterKey } from './auth.js' +import { encryptJson, decryptJson } from './crypto.js' + +const API_BASE = 'http://localhost:5000/api/logbooks' + +export interface DecryptedLogbook { + id: string + title: string + updatedAt: string + isSynced: boolean +} + +// Helper to decrypt a logbook's title using the active master key +export async function decryptLogbookTitle(encryptedTitle: string): Promise { + const masterKey = getActiveMasterKey() + if (!masterKey) { + throw new Error('Master key not found. User must log in.') + } + + try { + const parsed = JSON.parse(encryptedTitle) + const decrypted = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, masterKey) + return decrypted + } catch (error) { + console.error('Failed to decrypt logbook title:', error) + return '[Decryption Failed]' + } +} + +// Fetch logbooks from the server (if online) and update local cache, falling back to cache if offline +export async function fetchLogbooks(): Promise { + const userId = localStorage.getItem('active_userid') + if (!userId) { + throw new Error('User not authenticated') + } + + const masterKey = getActiveMasterKey() + if (!masterKey) { + throw new Error('Master key not found. User must log in.') + } + + if (navigator.onLine) { + try { + const response = await fetch(API_BASE, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-User-Id': userId + } + }) + + if (response.ok) { + const serverLogbooks = await response.json() + + // Update Dexie database cache + const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({ + id: lb.id, + encryptedTitle: lb.encryptedTitle, + updatedAt: lb.updatedAt || new Date().toISOString(), + isSynced: 1 + })) + + // 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) { + console.warn('Network request failed. Reading logbooks from offline cache:', error) + } + } + + // Retrieve all from Dexie cache + const cachedLogbooks = await db.logbooks.toArray() + + // Decrypt titles + const decrypted: DecryptedLogbook[] = [] + for (const lb of cachedLogbooks) { + const title = await decryptLogbookTitle(lb.encryptedTitle) + decrypted.push({ + id: lb.id, + title, + updatedAt: lb.updatedAt, + isSynced: lb.isSynced === 1 + }) + } + + return decrypted +} + +// Create a new logbook. Encrypts the title and registers locally + on server +export async function createLogbook(title: string): Promise { + const userId = localStorage.getItem('active_userid') + if (!userId) { + throw new Error('User not authenticated') + } + + const masterKey = getActiveMasterKey() + if (!masterKey) { + 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() + const now = new Date().toISOString() + + if (navigator.onLine) { + try { + const response = await fetch(API_BASE, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-User-Id': userId + }, + body: JSON.stringify({ + id: localId, + encryptedTitle: encryptedTitleStr + }) + }) + + if (response.ok) { + const serverLb = await response.json() + await db.logbooks.put({ + id: serverLb.id, + encryptedTitle: serverLb.encryptedTitle, + updatedAt: serverLb.updatedAt, + isSynced: 1 + }) + + return { + id: serverLb.id, + title, + updatedAt: serverLb.updatedAt, + isSynced: true + } + } + } catch (error) { + console.warn('Failed to save logbook to server, saving locally instead:', error) + } + } + + // If offline or request failed, store locally as unsynced and add to queue + await db.logbooks.put({ + id: localId, + encryptedTitle: encryptedTitleStr, + updatedAt: now, + isSynced: 0 + }) + + await db.syncQueue.put({ + action: 'create', + type: 'logbook', + payloadId: localId, + logbookId: localId, + data: JSON.stringify({ encryptedTitle: encryptedTitleStr }), + updatedAt: now + }) + + return { + id: localId, + title, + updatedAt: now, + isSynced: false + } +} + +// Delete a logbook and all associated payloads locally and on server +export async function deleteLogbook(id: string): Promise { + const userId = localStorage.getItem('active_userid') + if (!userId) { + throw new Error('User not authenticated') + } + + if (navigator.onLine) { + try { + const response = await fetch(`${API_BASE}/${id}`, { + method: 'DELETE', + headers: { + 'X-User-Id': userId + } + }) + if (!response.ok) { + console.warn('Server deletion failed or was rejected') + } + } catch (error) { + console.warn('Server delete request failed, queuing locally:', error) + await db.syncQueue.put({ + action: 'delete', + type: 'logbook', + payloadId: id, + logbookId: id, + data: '', + updatedAt: new Date().toISOString() + }) + } + } else { + await db.syncQueue.put({ + action: 'delete', + type: 'logbook', + payloadId: id, + logbookId: id, + data: '', + updatedAt: new Date().toISOString() + }) + } + + // Perform local cascading cleanup + await db.logbooks.delete(id) + await db.yachts.where({ logbookId: id }).delete() + await db.crews.where({ logbookId: id }).delete() + await db.deviations.where({ logbookId: id }).delete() + await db.entries.where({ logbookId: id }).delete() +} diff --git a/server/src/index.ts b/server/src/index.ts index 105bade..1a4fb75 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,6 +2,7 @@ import express from 'express' import cors from 'cors' import dotenv from 'dotenv' import authRouter from './routes/auth.js' +import logbooksRouter from './routes/logbooks.js' dotenv.config() @@ -13,6 +14,7 @@ app.use(express.json()) // Mount routes app.use('/api/auth', authRouter) +app.use('/api/logbooks', logbooksRouter) // Health check endpoint app.get('/api/health', (req, res) => { diff --git a/server/src/routes/logbooks.ts b/server/src/routes/logbooks.ts new file mode 100644 index 0000000..e11382e --- /dev/null +++ b/server/src/routes/logbooks.ts @@ -0,0 +1,83 @@ +import { Router } from 'express' +import { prisma } from '../db.js' + +const router = Router() + +// Middleware to extract user ID from headers +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() +} + +router.use(requireUser) + +// 1. Get all logbooks for the authenticated user +router.get('/', async (req: any, res) => { + try { + const logbooks = await prisma.logbook.findMany({ + where: { userId: req.userId }, + orderBy: { createdAt: 'desc' } + }) + return res.json(logbooks) + } catch (error: any) { + console.error('Error fetching logbooks:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +// 2. Create a new logbook +router.post('/', async (req: any, res) => { + try { + const { id, encryptedTitle } = req.body + if (!encryptedTitle) { + return res.status(400).json({ error: 'encryptedTitle is required' }) + } + + const logbook = await prisma.logbook.create({ + data: { + id: id || undefined, + userId: req.userId, + encryptedTitle + } + }) + + return res.json(logbook) + } catch (error: any) { + console.error('Error creating logbook:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +// 3. Delete a logbook +router.delete('/:id', async (req: any, res) => { + try { + const { id } = req.params + + const logbook = await prisma.logbook.findUnique({ + where: { id } + }) + + 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' }) + } + + await prisma.logbook.delete({ + where: { id } + }) + + return res.json({ success: true }) + } catch (error: any) { + console.error('Error deleting logbook:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +export default router