Compare commits

...

17 Commits

Author SHA1 Message Date
elpatron bb6e7f5c32 chore: release v0.1.0.82 2026-06-01 19:29:51 +02:00
elpatron ca0daa8f2a fix(logs): repair journal entry cards and avoid duplicate days
Match dashboard card DOM so entry tiles get correct height, remove nested
form-card chrome, and open today’s entry instead of creating a second one.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:27:09 +02:00
elpatron 2304f95ac1 fix(live-log): prevent freeze without GPS and prompt for day-start position
Harden geolocation with watchdog timeouts and permission checks so
desktop browsers without GPS no longer hang Live-Log. Show a hint to
log a position when none exists for the day.

Return 503 when crew-pool Prisma models are missing instead of crashing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:20:34 +02:00
elpatron 98c0ed81d4 Raise Stammcrew pool limit from 5 to 12 crew members.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:07:37 +02:00
elpatron 3504ec97cc Add account-level crew pool with per-logbook and per-day selection.
Move skipper and crew master data to the user profile pool, replace the logbook crew tab with selection from that pool, inherit crew on new travel days, and sync via new PersonPayload and LogbookCrewSelection models. Includes migration from legacy crew records, tour/demo updates, and i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:05:50 +02:00
elpatron 4c6c2779f2 chore: release v0.1.0.81 2026-06-01 18:35:35 +02:00
elpatron b6c4e9e7d9 fix(dashboard): allow spaces when renaming logbook title
Move the title input out of the card open button so Space no longer
activates navigation; use an overlay button for opening the logbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 18:35:20 +02:00
elpatron 04c6be2b5b chore: release v0.1.0.80 2026-06-01 15:30:57 +02:00
elpatron 9089d017b6 feat(ux): Sprint 3 mobile nav, sync conflicts, and resilience
Improve mobile bottom navigation, accessible dialogs and cards, explicit
sync conflict resolution, i18n error messages, encrypted draft autosave,
and persistent storage hints for offline data safety.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:30:08 +02:00
elpatron f8dc6ace3c chore: release v0.1.0.79 2026-06-01 15:20:55 +02:00
elpatron 18f14d7e0b chore(deploy): run predeploy-check.sh from update-prod.sh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:18:58 +02:00
elpatron 0edf4a789c feat(quality): Sprint 2 pre-deploy gates and server smoke tests
Extract Express app factory for testability, add Vitest/Supertest API
smoke tests, root npm run check script, and deployment docs. Fix
express-rate-limit IPv6 keyGenerator for feedback limiter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:17:46 +02:00
elpatron 4ef56aeb8f fix(ops): force-recreate backend after postgres password rotation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:10:50 +02:00
elpatron 3263fbcec3 chore: restore merge skill accidentally removed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:09:24 +02:00
elpatron b9ce853059 feat(ops): script to rotate PostgreSQL password safely
Add rotate-postgres-password.sh with optional app role, document the
procedure, and stop defaulting production POSTGRES_PASSWORD to postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:09:15 +02:00
elpatron 3d8a505bd9 fix(nginx): security headers on index.html and PWA asset routes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:04:27 +02:00
elpatron e138752dd3 feat(security): Sprint 1 hardening for production behind NPM
Add trust proxy, WebAuthn challenge TTL, stricter public collaboration
rate limits, generic 500 responses, Docker POSTGRES_PASSWORD from env,
nginx security headers/CSP, and deployment documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:02:15 +02:00
68 changed files with 5705 additions and 344 deletions
+14 -1
View File
@@ -6,10 +6,23 @@ DeepLAPIKey=
# Passkey configuration (WebAuthn Relying Party ID and Origin)
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
# Production (kapteins-daagbok.eu):
# RP_ID=kapteins-daagbok.eu
# ORIGIN=https://kapteins-daagbok.eu
RP_ID=localhost
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md
# TRUST_PROXY=172.16.10.10
# TRUST_PROXY=1
# Docker Compose database (required for production deploy)
# Generate: openssl rand -hex 24
# Rotate on running server: ./scripts/rotate-postgres-password.sh (see docs/deployment/postgres-password.md)
# POSTGRES_USER=postgres
# POSTGRES_PASSWORD=
# POSTGRES_DB=daagbox
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
# CORS_ORIGINS=http://localhost:5173
+18 -6
View File
@@ -219,13 +219,19 @@ cd server && npx prisma db push && cd ..
| Health Check | http://localhost:5000/api/health |
| Public Demo | http://localhost:5173/demo |
### 5. Tests (Frontend)
### 5. Qualität & Tests
Vor jedem Deploy auf [kapteins-daagbok.eu](https://kapteins-daagbok.eu/) (kein externes CI):
```bash
cd client && npm test
npm run check
# oder: ./scripts/predeploy-check.sh
```
Vitest-Unit-Tests für Utils, i18n und Services (z. B. Kurswinkel, Benutzereinstellungen).
Einzeln: `npm test` (Client + Server) · `npm run build` · optional `npm run lint` (Client, noch nicht in `check`)
- **Client:** Vitest für Utils, i18n, Services
- **Server:** Smoke-Tests (`/api/health`, Auth-Guards) mit Supertest — siehe `server/src/api.smoke.test.ts`
## Docker (produktionsnah)
@@ -237,11 +243,12 @@ Gesamten Stack lokal bauen und starten:
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`). Für Feedback `NTFY_*` setzen.
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`), `SESSION_SECRET` und für Docker Compose `POSTGRES_PASSWORD`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`). Für Feedback `NTFY_*` setzen.
## Deployment
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führt vor dem SSH-Deploy automatisch [`predeploy-check.sh`](scripts/predeploy-check.sh) aus (`npm run check`):
```bash
./scripts/update-prod.sh
@@ -249,12 +256,17 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
## Dokumentation
| Dokument | Inhalt |
|----------|--------|
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
+1 -1
View File
@@ -1 +1 @@
0.1.0.79
0.1.0.83
+19 -2
View File
@@ -3,15 +3,32 @@ server {
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate";
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
+202 -2
View File
@@ -1799,6 +1799,52 @@ html.scheme-dark .themed-select-option.is-selected {
gap: 24px;
}
.logbook-card-select {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
border: none;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.logbook-card > .card-icon,
.logbook-card > .card-info {
position: relative;
z-index: 1;
pointer-events: none;
}
.logbook-card > .card-info {
flex: 1;
min-width: 0;
}
.logbook-card-chevron {
position: relative;
z-index: 1;
flex-shrink: 0;
align-self: center;
margin-left: auto;
color: #475569;
pointer-events: none;
}
.logbook-card .logbook-title-editable,
.logbook-card .logbook-title-inline-edit,
.logbook-card .card-title-row {
pointer-events: auto;
}
.logbook-card--editing-title > .card-info {
pointer-events: auto;
}
.logbook-card {
background: var(--app-surface-alt);
backdrop-filter: var(--app-backdrop);
@@ -1809,18 +1855,61 @@ html.scheme-dark .themed-select-option.is-selected {
display: flex;
align-items: flex-start;
gap: 16px;
cursor: pointer;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.logbook-card:hover {
.logbook-card:hover,
.logbook-card:focus-within {
transform: translateY(-2px);
border-color: var(--app-border);
box-shadow: var(--app-card-shadow);
background: var(--app-surface-hover);
}
.sync-conflict-banner {
display: flex;
gap: 12px;
align-items: flex-start;
margin: 0 0 16px;
padding: 16px;
border-radius: var(--app-radius-card);
border: 1px solid var(--app-warning-border, #f59e0b);
background: var(--app-warning-bg, rgba(245, 158, 11, 0.12));
color: var(--app-text);
}
.sync-conflict-banner__body p {
margin: 4px 0 12px;
font-size: 14px;
color: var(--app-text-muted);
}
.sync-conflict-banner__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.storage-persist-hint {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 0 0 16px;
padding: 12px 16px;
border-radius: var(--app-radius-card);
}
.storage-persist-hint p {
margin: 0;
flex: 1;
min-width: 200px;
font-size: 14px;
color: var(--app-text-muted);
}
.logbook-card--shared {
border-left: 3px solid #38bdf8;
}
@@ -1871,6 +1960,8 @@ html.scheme-dark .themed-select-option.is-selected {
display: flex;
align-items: center;
margin-top: -2px;
position: relative;
z-index: 2;
}
.logbook-card-actions .btn-delete {
@@ -2130,9 +2221,65 @@ html.scheme-dark .themed-select-option.is-selected {
align-items: start;
}
.app-bottom-nav {
display: none;
}
@media (max-width: 768px) {
.app-body {
grid-template-columns: minmax(0, 1fr);
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
}
.app-sidebar {
display: none;
}
.app-bottom-nav {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
justify-content: space-around;
align-items: stretch;
gap: 4px;
padding: 8px 8px calc(8px + env(safe-area-inset-bottom, 0px));
background: var(--app-surface-alt);
backdrop-filter: var(--app-backdrop);
border-top: 1px solid var(--app-border-subtle);
box-sizing: border-box;
}
.bottom-nav-btn {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 4px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--app-text-muted);
font-size: 10px;
font-weight: 500;
cursor: pointer;
}
.bottom-nav-btn span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.bottom-nav-btn.active {
background: var(--app-sidebar-active-bg);
color: var(--app-sidebar-active-text);
}
}
@@ -3212,7 +3359,14 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-accent-light, #93c5fd);
}
.logs-journal {
width: 100%;
min-width: 0;
}
.live-log-card {
width: 100%;
min-width: 0;
min-height: 420px;
}
@@ -3222,6 +3376,31 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-text-muted);
}
.live-log-gps-hint {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
line-height: 1.45;
color: var(--app-text-muted);
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.25);
}
.live-log-gps-hint svg {
flex-shrink: 0;
margin-top: 2px;
color: var(--app-accent-light, #93c5fd);
}
.live-log-gps-hint-modal {
font-weight: 500;
color: var(--app-text, inherit);
}
.live-log-layout {
display: grid;
grid-template-columns: minmax(148px, 200px) 1fr;
@@ -5399,3 +5578,24 @@ body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
pointer-events: none;
}
.crew-selection-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.crew-selection-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md, 8px);
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
cursor: pointer;
}
.crew-selection-item input {
flex-shrink: 0;
}
+86 -6
View File
@@ -4,7 +4,9 @@ import AuthOnboarding from './components/AuthOnboarding.tsx'
import UserProfilePage from './components/UserProfilePage.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx'
import VesselForm from './components/VesselForm.tsx'
import CrewForm from './components/CrewForm.tsx'
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
import { syncPersonPool } from './services/personPoolSync.js'
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
// import DeviationForm from './components/DeviationForm.tsx'
import LogEntriesList from './components/LogEntriesList.tsx'
@@ -53,6 +55,8 @@ import {
} from './services/demoLogbook.js'
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
import SyncConflictBanner from './components/SyncConflictBanner.tsx'
import { requestPersistentStorage } from './utils/storagePersist.js'
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
@@ -71,6 +75,7 @@ function App() {
const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
const [showUserProfile, setShowUserProfile] = useState(false)
const [storagePersistHint, setStoragePersistHint] = useState(false)
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
id: activeLogbookId,
@@ -158,6 +163,7 @@ function App() {
const userId = localStorage.getItem('active_userid')
if (!userId) return
void syncAppearancePrefs(userId)
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
}, [isAuthenticated])
useEffect(() => {
@@ -428,10 +434,19 @@ function App() {
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
}, [isAuthenticated, openLogbookById])
useEffect(() => {
if (!isAuthenticated) return
if (sessionStorage.getItem('storage_persist_hint_dismissed')) return
void requestPersistentStorage().then(({ persisted, supported }) => {
if (supported && !persisted) setStoragePersistHint(true)
})
}, [isAuthenticated])
const handleAuthenticated = async () => {
setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
void ensurePushSubscriptionIfEnabled()
void requestPersistentStorage()
try {
const demo = await seedDemoLogbookIfNeeded()
@@ -606,7 +621,7 @@ function App() {
<p className="app-subtitle">
{activeAccessRole && activeAccessRole !== 'OWNER'
? t('dashboard.section_shared_hint')
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
: t('app.tagline')}
</p>
</div>
</div>
@@ -646,10 +661,28 @@ function App() {
</div>
</header>
<SyncConflictBanner logbookId={activeLogbookId} />
{storagePersistHint && (
<div className="storage-persist-hint glass" role="status">
<p>{t('pwa.storage_persist_hint')}</p>
<button
type="button"
className="btn secondary"
onClick={() => {
sessionStorage.setItem('storage_persist_hint_dismissed', '1')
setStoragePersistHint(false)
}}
>
{t('pwa.later')}
</button>
</div>
)}
{/* Active Workspace */}
<div className="app-body">
{/* Navigation Sidebar */}
<aside className="app-sidebar">
<aside className="app-sidebar" aria-label={t('nav.dashboard')}>
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => void handleTabChange('logs')}
@@ -671,7 +704,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => void handleTabChange('crew')}
data-tour="nav-crew"
data-tour="nav-logbook-crew"
>
<Users size={18} />
{t('nav.crew')}
@@ -722,10 +755,10 @@ function App() {
)}
{activeTab === 'crew' && (
<CrewForm
<LogbookCrewPicker
logbookId={activeLogbookId}
readOnly={logbookReadOnly}
skipperReadOnly={!isLogbookOwner}
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
/>
)}
@@ -746,6 +779,53 @@ function App() {
/>
)}
</main>
<nav className="app-bottom-nav" aria-label={t('nav.dashboard')}>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => void handleTabChange('logs')}
data-tour="nav-logs"
>
<FileText size={20} />
<span>{t('nav.logs')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => void handleTabChange('vessel')}
data-tour="nav-vessel"
>
<Ship size={20} />
<span>{t('nav.vessel')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => void handleTabChange('crew')}
data-tour="nav-logbook-crew"
>
<Users size={20} />
<span>{t('nav.crew')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => void handleTabChange('stats')}
data-tour="nav-stats"
>
<BarChart2 size={20} />
<span>{t('nav.stats')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => void handleTabChange('settings')}
>
<Settings size={20} />
<span>{t('nav.settings')}</span>
</button>
</nav>
</div>
</div>
</div>
+2 -1
View File
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import { useDialog } from './ModalDialog.tsx'
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
@@ -603,7 +604,7 @@ export default function CrewForm({
<Users size={24} className="form-icon" />
<h2>{t('crew.crew_section')}</h2>
</div>
{!readOnly && crewList.length < 5 && !showMemberForm && (
{!readOnly && crewList.length < MAX_POOL_CREW_MEMBERS && !showMemberForm && (
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
<Plus size={16} />
{t('crew.add_crew')}
+23 -4
View File
@@ -2,7 +2,9 @@ import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { personToSnapshot } from '../utils/personSnapshots.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
@@ -52,7 +54,19 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
cycleAppLanguage(i18n)
}
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
const { title, yacht, personPool, logbookCrewSelection, entries, gpsTracks, photos, firstEntryId } =
fixture
const demoSelection: LogbookCrewSelectionData = {
activeSkipperId: logbookCrewSelection.activeSkipperId,
activeCrewIds: logbookCrewSelection.activeCrewIds,
snapshotsById: Object.fromEntries(
Object.entries(logbookCrewSelection.snapshotsById).map(([id, snap]) => [
id,
personToSnapshot(id, snap)
])
)
}
return (
<div className="app-layout">
@@ -115,7 +129,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
data-tour="nav-crew"
data-tour="nav-logbook-crew"
>
<Users size={18} />
{t('nav.crew')}
@@ -142,7 +156,12 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
)}
{activeTab === 'crew' && (
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
<LogbookCrewPicker
logbookId="demo"
readOnly={true}
preloadedPool={personPool}
preloadedSelection={demoSelection}
/>
)}
</main>
</div>
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users } from 'lucide-react'
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
import { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
import { buildSnapshotsForSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
export interface EntryCrewSectionProps {
logbookId: string
readOnly?: boolean
value: EntryCrewFields
onChange: (next: EntryCrewFields) => void
/** Demo: fixed pool */
preloadedPool?: Map<string, PersonData>
}
export default function EntryCrewSection({
logbookId,
readOnly = false,
value,
onChange,
preloadedPool
}: EntryCrewSectionProps) {
const { t } = useTranslation()
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => {
if (preloadedPool) {
setPool(preloadedPool)
return
}
let cancelled = false
void (async () => {
try {
const people = await loadPersonPool()
if (cancelled) return
setPool(new Map(people.map((p) => [p.payloadId, p.data])))
} catch {
/* use snapshots only */
}
})()
return () => {
cancelled = true
}
}, [preloadedPool])
const displayPool = useMemo(() => {
const merged = new Map(pool)
for (const snap of Object.values(value.crewSnapshotsById)) {
if (!merged.has(snap.id)) {
merged.set(snap.id, {
name: snap.name,
address: snap.address,
birthDate: snap.birthDate,
phone: snap.phone,
nationality: snap.nationality,
passportNumber: snap.passportNumber,
bloodType: snap.bloodType,
allergies: snap.allergies,
diseases: snap.diseases,
role: snap.role,
photo: snap.photo
})
}
}
return merged
}, [pool, value.crewSnapshotsById])
const skippers = [...displayPool.entries()].filter(([, d]) => d.role === 'skipper')
const crewEntries = [...displayPool.entries()].filter(([, d]) => d.role === 'crew')
const applyChange = (skipperId: string | null, crewIds: string[]) => {
const snapshots = buildSnapshotsForSelection(skipperId, crewIds, displayPool)
onChange({
selectedSkipperId: skipperId,
selectedCrewIds: crewIds,
crewSnapshotsById: snapshots
})
}
const toggleCrew = (id: string) => {
if (readOnly) return
const next = value.selectedCrewIds.includes(id)
? value.selectedCrewIds.filter((x) => x !== id)
: [...value.selectedCrewIds, id]
applyChange(value.selectedSkipperId, next)
}
return (
<div className="form-card" data-tour="entry-crew">
<div className="form-header">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
<div className="input-group mb-3">
<label>{t('entry_crew.day_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('entry_crew.no_skipper')}</p>
) : (
<div className="crew-selection-list">
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
<div className="input-group">
<label>{t('entry_crew.day_crew')}</label>
{crewEntries.length === 0 ? (
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : (
<div className="crew-selection-list">
{crewEntries.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="checkbox"
checked={value.selectedCrewIds.includes(id)}
onChange={() => toggleCrew(id)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
</div>
)
}
export async function loadDefaultEntryCrewForNewDay(
logbookId: string,
previousEntry: Record<string, unknown> | null
): Promise<EntryCrewFields> {
if (previousEntry) {
const selectedSkipperId =
typeof previousEntry.selectedSkipperId === 'string' ? previousEntry.selectedSkipperId : null
const selectedCrewIds = Array.isArray(previousEntry.selectedCrewIds)
? previousEntry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
: []
const crewSnapshotsById =
previousEntry.crewSnapshotsById && typeof previousEntry.crewSnapshotsById === 'object'
? (previousEntry.crewSnapshotsById as Record<string, PersonSnapshot>)
: {}
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
}
const selection = await loadLogbookCrewSelection(logbookId)
return {
selectedSkipperId: selection.activeSkipperId,
selectedCrewIds: [...selection.activeCrewIds],
crewSnapshotsById: { ...selection.snapshotsById }
}
}
+95 -19
View File
@@ -52,7 +52,11 @@ import {
} from '../utils/liveEventCodes.js'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { getCurrentPosition, normalizeGpsCoordinates } from '../utils/geolocation.js'
import {
getCurrentPosition,
normalizeGpsCoordinates,
queryGeolocationPermission
} from '../utils/geolocation.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import {
dedupeSailNames,
@@ -167,11 +171,13 @@ export default function LiveLogView({
const undoPhotoIdRef = useRef<string | null>(null)
const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false)
const busyRef = useRef(busy)
const initSeqRef = useRef(0)
const eventsRef = useRef(events)
const dateRef = useRef(date)
eventsRef.current = events
dateRef.current = date
busyRef.current = busy
const defaultSails = useMemo(
() => (i18n.language === 'de'
@@ -185,6 +191,10 @@ export default function LiveLogView({
)
const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion')
const hasPositionFix = useMemo(
() => (date ? getLatestPositionFix(events, date) != null : false),
[events, date]
)
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
@@ -276,7 +286,9 @@ export default function LiveLogView({
return () => {
initSeqRef.current += 1
}
}, [runInit])
// Only re-init when the logbook changes — not when i18n `t` identity changes.
// eslint-disable-next-line react-hooks/exhaustive-deps -- runInit
}, [logbookId])
useEffect(() => {
if (!loading && entryId) {
@@ -297,15 +309,34 @@ export default function LiveLogView({
useEffect(() => {
if (!entryId || loading) return
let cancelled = false
let startTimer: number | undefined
let intervalRef: number | undefined
const maybeAutoPosition = async () => {
if (document.visibilityState !== 'visible' || autoPositionBusyRef.current || busy) return
if (
cancelled
|| document.visibilityState !== 'visible'
|| autoPositionBusyRef.current
|| busyRef.current
) {
return
}
const permission = await queryGeolocationPermission()
if (cancelled || permission !== 'granted') return
const lastMs = getLastAutoPositionMs(eventsRef.current, dateRef.current)
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
autoPositionBusyRef.current = true
try {
const coords = await getCurrentPosition(8000)
const coords = await getCurrentPosition({
timeoutMs: 8000,
enableHighAccuracy: false,
maximumAge: 120_000
})
if (cancelled || busyRef.current) return
await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat,
gpsLng: coords.lng,
@@ -313,23 +344,26 @@ export default function LiveLogView({
})
await refreshEntry(entryId)
} catch {
// Silent — auto-position is best-effort
// Best-effort; hint banner shows when no position fix exists yet.
} finally {
autoPositionBusyRef.current = false
}
}
let intervalRef: number | undefined
const startTimer = window.setTimeout(() => {
void maybeAutoPosition()
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
}, AUTO_POSITION_START_DELAY_MS)
void queryGeolocationPermission().then((permission) => {
if (cancelled || permission !== 'granted') return
startTimer = window.setTimeout(() => {
void maybeAutoPosition()
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
}, AUTO_POSITION_START_DELAY_MS)
})
return () => {
window.clearTimeout(startTimer)
cancelled = true
if (startTimer !== undefined) window.clearTimeout(startTimer)
if (intervalRef !== undefined) window.clearInterval(intervalRef)
}
}, [entryId, loading, logbookId, refreshEntry, busy])
}, [entryId, loading, logbookId, refreshEntry])
const runQuickAction = async (
action: () => Promise<boolean | void>,
@@ -364,8 +398,15 @@ export default function LiveLogView({
const openSogModal = async () => {
let prefill = ''
try {
const pos = await getCurrentPosition()
if (pos.speedKn != null) prefill = String(pos.speedKn)
const permission = await queryGeolocationPermission()
if (permission === 'granted') {
const pos = await getCurrentPosition({
timeoutMs: 8000,
enableHighAccuracy: false,
maximumAge: 60_000
})
if (pos.speedKn != null) prefill = String(pos.speedKn)
}
} catch {
// Manual entry when GPS speed unavailable
}
@@ -405,7 +446,16 @@ export default function LiveLogView({
setFixGpsLoading(true)
setModal('fix')
try {
const coords = await getCurrentPosition()
const permission = await queryGeolocationPermission()
if (permission !== 'granted') {
setFixGpsUnavailable(true)
return
}
const coords = await getCurrentPosition({
timeoutMs: 10_000,
enableHighAccuracy: false,
maximumAge: 60_000
})
setFixLat(coords.lat)
setFixLng(coords.lng)
} catch {
@@ -419,12 +469,28 @@ export default function LiveLogView({
setFixGpsLoading(true)
setFixGpsUnavailable(false)
try {
const coords = await getCurrentPosition()
const permission = await queryGeolocationPermission()
if (permission !== 'granted') {
setFixGpsUnavailable(true)
await showAlert(
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
t('logs.live_fix')
)
return
}
const coords = await getCurrentPosition({
timeoutMs: 10_000,
enableHighAccuracy: false,
maximumAge: 60_000
})
setFixLat(coords.lat)
setFixLng(coords.lng)
} catch {
setFixGpsUnavailable(true)
await showAlert(t('logs.live_gps_error'), t('logs.live_fix'))
await showAlert(
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
t('logs.live_fix')
)
} finally {
setFixGpsLoading(false)
}
@@ -757,7 +823,7 @@ export default function LiveLogView({
return (
<>
<div className="form-card live-log-card">
<div className="live-log-card">
<div className="section-title-bar mb-4">
<div className="form-header" style={{ margin: 0 }}>
<Radio size={24} className="form-icon" />
@@ -786,6 +852,13 @@ export default function LiveLogView({
{error && <div className="auth-error mb-4">{error}</div>}
{!hasPositionFix && (
<p className="live-log-gps-hint" role="status">
<MapPin size={16} aria-hidden />
{t('logs.live_gps_start_hint')}
</p>
)}
<div className="live-log-layout">
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
<button type="button" className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`} onClick={handleMotorToggle} disabled={busy}>
@@ -974,7 +1047,10 @@ export default function LiveLogView({
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_fix')}</h3>
{fixGpsUnavailable && (
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
<>
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p>
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
</>
)}
<fieldset className="live-log-fix-coords" disabled={busy}>
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
+42 -12
View File
@@ -8,6 +8,8 @@ import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId } from '../services/quickEventLog.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
@@ -142,7 +144,7 @@ export default function LogEntriesList({
setEntries(list)
} catch (err: any) {
console.error('Failed to load log entries:', err)
setError(err.message || 'Decryption failed. Could not load journal list.')
setError(getErrorMessage(err, t('errors.load_failed')))
} finally {
setLoading(false)
}
@@ -176,7 +178,7 @@ export default function LogEntriesList({
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
} catch (err: any) {
console.error('Failed to download CSV:', err)
setError(err.message || 'Failed to generate CSV export.')
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
@@ -204,7 +206,7 @@ export default function LogEntriesList({
setError(t('logs.share_unsupported'))
} else {
console.error('Failed to share CSV:', err)
setError(err.message || 'Failed to share CSV export.')
setError(getErrorMessage(err, t('errors.export_failed')))
}
} finally {
setExporting(false)
@@ -225,7 +227,7 @@ export default function LogEntriesList({
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
} catch (err: any) {
console.error('Failed to download PDF:', err)
setError(err.message || 'Failed to generate PDF export.')
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
@@ -238,6 +240,12 @@ export default function LogEntriesList({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const existingTodayId = await findTodayEntryId(logbookId)
if (existingTodayId) {
setSelectedEntryId(existingTodayId)
return
}
const localEntries = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
@@ -276,6 +284,12 @@ export default function LogEntriesList({
const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10)
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
const entryCrew = await loadDefaultEntryCrewForNewDay(
logbookId,
previousEntry as Record<string, unknown> | null
)
const initialPayload = {
date: todayStr,
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
@@ -284,6 +298,9 @@ export default function LogEntriesList({
freshwater,
fuel,
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
selectedSkipperId: entryCrew.selectedSkipperId,
selectedCrewIds: entryCrew.selectedCrewIds,
crewSnapshotsById: entryCrew.crewSnapshotsById,
signSkipper: '',
signCrew: '',
events: []
@@ -317,7 +334,7 @@ export default function LogEntriesList({
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to create entry:', err)
setError(err.message || 'Failed to create new log entry.')
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -347,7 +364,7 @@ export default function LogEntriesList({
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to delete log entry:', err)
setError(err.message || 'Failed to delete log entry.')
setError(getErrorMessage(err, t('errors.delete_failed')))
}
}
}
@@ -380,7 +397,10 @@ export default function LogEntriesList({
setReturnToLiveAfterEditor(true)
setSelectedEntryId(entryId)
}}
onSwitchToList={() => setViewMode('list')}
onSwitchToList={() => {
setViewMode('list')
void loadEntries()
}}
/>
)
}
@@ -400,7 +420,7 @@ export default function LogEntriesList({
: entries[0]?.id ?? null
return (
<div className="form-card">
<div className="logs-journal">
<div className="section-title-bar mb-6">
<div className="form-header" style={{ margin: 0 }}>
<Calendar size={24} className="form-icon" />
@@ -460,9 +480,19 @@ export default function LogEntriesList({
key={item.id}
className="logbook-card glass"
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
onClick={() => setSelectedEntryId(item.id)}
>
<div className="card-icon">
<button
type="button"
className="logbook-card-select"
onClick={() => setSelectedEntryId(item.id)}
aria-label={
item.departure && item.destination
? `${item.departure}${item.destination}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
: `${t('logs.new_entry')}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
}
/>
<div className="card-icon" aria-hidden>
<FileText size={24} />
</div>
@@ -483,6 +513,8 @@ export default function LogEntriesList({
</div>
</div>
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<Download size={18} />
</button>
@@ -492,8 +524,6 @@ export default function LogEntriesList({
<Trash2 size={18} />
</button>
)}
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
</div>
))}
</div>
+34 -5
View File
@@ -5,10 +5,15 @@ 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 { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
import { getErrorMessage } from '../utils/errors.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx'
import EntryCrewSection from './EntryCrewSection.tsx'
import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js'
import { entryCrewFromPreviousEntry } from '../utils/personSnapshots.js'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
@@ -106,7 +111,8 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw))
: undefined,
events: (decrypted.events as LogEventPayload[]) || []
events: (decrypted.events as LogEventPayload[]) || [],
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
})
return JSON.stringify({
@@ -166,6 +172,8 @@ export default function LogEntryEditor({
const [greywaterLevel, setGreywaterLevel] = useState('0')
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
const [entryCrew, setEntryCrew] = useState<EntryCrewFields>(emptyEntryCrewFields())
// Signatures
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
@@ -277,7 +285,8 @@ export default function LogEntryEditor({
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
events: eventsOverride ?? events
events: eventsOverride ?? events,
entryCrew
})
}, [
date, dayOfTravel, departure, destination,
@@ -285,9 +294,18 @@ export default function LogEntryEditor({
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
greywaterLevel,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
events
events,
entryCrew
])
useEffect(() => {
if (readOnly || loading || !date) return
const timer = window.setTimeout(() => {
void saveEntryDraft(logbookId, entryId, buildPayloadForSigning())
}, 4000)
return () => window.clearTimeout(timer)
}, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date])
const fuelPerMotorHour = useMemo(
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
[fuelConsumption, motorHours]
@@ -696,6 +714,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
loadTrackStatsFromEntry(preloadedEntry)
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
@@ -734,6 +753,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
loadTrackStatsFromEntry(decrypted)
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
@@ -1208,15 +1228,17 @@ export default function LogEntryEditor({
...signaturesForSave
})
await clearEntryDraft(logbookId, entryId)
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
setTimeout(() => {
setSuccess(false)
onBack()
}, 1500)
} catch (err: any) {
} catch (err: unknown) {
console.error('Failed to save entry details:', err)
setError(err.message || 'Failed to save entry details.')
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setSaving(false)
}
@@ -2020,6 +2042,13 @@ export default function LogEntryEditor({
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
<EntryCrewSection
logbookId={logbookId}
readOnly={readOnly}
value={entryCrew}
onChange={setEntryCrew}
/>
<SignatureSection
readOnly={readOnly}
disabled={saving}
+224
View File
@@ -0,0 +1,224 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users, User, Save, Check } from 'lucide-react'
import type { LogbookCrewSelectionData, PersonSnapshot } from '../types/person.js'
import type { DecryptedPerson } from '../services/personPool.js'
import { loadPersonPool, filterSkippers, filterCrew } from '../services/personPool.js'
import { loadLogbookCrewSelection, saveLogbookCrewSelectionFromIds } from '../services/logbookCrewSelection.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export interface LogbookCrewPickerProps {
logbookId: string
readOnly?: boolean
/** Demo / share: in-memory pool */
preloadedPool?: Array<{ payloadId: string; data: DecryptedPerson['data'] }>
preloadedSelection?: LogbookCrewSelectionData
/** Shared logbook: only people from selection snapshots */
selectionOnly?: boolean
}
function snapshotsToPoolList(
selection: LogbookCrewSelectionData
): Array<{ payloadId: string; data: DecryptedPerson['data'] }> {
return Object.values(selection.snapshotsById).map((snap) => ({
payloadId: snap.id,
data: {
name: snap.name,
address: snap.address,
birthDate: snap.birthDate,
phone: snap.phone,
nationality: snap.nationality,
passportNumber: snap.passportNumber,
bloodType: snap.bloodType,
allergies: snap.allergies,
diseases: snap.diseases,
role: snap.role,
photo: snap.photo
}
}))
}
export default function LogbookCrewPicker({
logbookId,
readOnly = false,
preloadedPool,
preloadedSelection,
selectionOnly = false
}: LogbookCrewPickerProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(!preloadedSelection)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pool, setPool] = useState<DecryptedPerson[]>([])
const [activeSkipperId, setActiveSkipperId] = useState<string | null>(null)
const [activeCrewIds, setActiveCrewIds] = useState<string[]>([])
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const selection =
preloadedSelection ??
(logbookId === 'demo' ? null : await loadLogbookCrewSelection(logbookId))
if (selection) {
setActiveSkipperId(selection.activeSkipperId)
setActiveCrewIds([...selection.activeCrewIds])
}
if (preloadedPool) {
setPool(
preloadedPool.map((p) => ({
payloadId: p.payloadId,
data: p.data
}))
)
} else if (selectionOnly && selection) {
setPool(snapshotsToPoolList(selection))
} else {
setPool(await loadPersonPool())
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load crew selection')
} finally {
setLoading(false)
}
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
useEffect(() => {
void loadData()
}, [loadData])
const skippers = useMemo(() => filterSkippers(pool), [pool])
const crewMembers = useMemo(() => filterCrew(pool), [pool])
const toggleCrew = (id: string) => {
if (readOnly) return
setActiveCrewIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
)
}
const handleSave = async () => {
if (readOnly || logbookId === 'demo') return
setSaving(true)
setError(null)
setSaved(false)
try {
await saveLogbookCrewSelectionFromIds(logbookId, activeSkipperId, activeCrewIds)
setSaved(true)
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { context: 'logbook_selection' })
setTimeout(() => setSaved(false), 3000)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="tab-placeholder">
<Users className="header-logo spin" size={48} />
<p>{t('person_pool.loading')}</p>
</div>
)
}
return (
<div className="crew-dashboard-layout" data-tour="logbook-crew-picker">
<div className="form-card">
<div className="form-header">
<Users size={24} className="form-icon" />
<h2>{t('logbook_crew.title')}</h2>
</div>
<p className="help-text mb-4">{t('logbook_crew.subtitle')}</p>
{selectionOnly && <p className="help-text mb-4">{t('logbook_crew.selection_only_hint')}</p>}
{error && <div className="auth-error mb-4">{error}</div>}
<div className="input-group mb-4">
<label>{t('logbook_crew.active_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('logbook_crew.no_skippers_in_pool')}</p>
) : (
<div className="crew-selection-list">
{skippers.map((s) => (
<label key={s.payloadId} className="crew-selection-item">
<input
type="radio"
name={`skipper-${logbookId}`}
checked={activeSkipperId === s.payloadId}
onChange={() => !readOnly && setActiveSkipperId(s.payloadId)}
disabled={readOnly}
/>
<User size={16} aria-hidden="true" />
<span>{s.data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
{!readOnly && (
<label className="crew-selection-item">
<input
type="radio"
name={`skipper-${logbookId}`}
checked={activeSkipperId === null}
onChange={() => setActiveSkipperId(null)}
/>
<span>{t('logbook_crew.no_skipper')}</span>
</label>
)}
</div>
)}
</div>
<div className="input-group mb-4">
<label>{t('logbook_crew.active_crew')}</label>
{crewMembers.length === 0 ? (
<p className="help-text">{t('logbook_crew.no_crew_in_pool')}</p>
) : (
<div className="crew-selection-list">
{crewMembers.map((c) => (
<label key={c.payloadId} className="crew-selection-item">
<input
type="checkbox"
checked={activeCrewIds.includes(c.payloadId)}
onChange={() => toggleCrew(c.payloadId)}
disabled={readOnly}
/>
<span>{c.data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
{!readOnly && logbookId !== 'demo' && (
<div className="form-actions">
{saved && (
<div className="success-toast">
<Check size={16} />
<span>{t('logbook_crew.saved')}</span>
</div>
)}
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
<Save size={18} />
{t('logbook_crew.save')}
</button>
</div>
)}
</div>
</div>
)
}
export function selectionFromSnapshots(
snapshotsById: Record<string, PersonSnapshot>
): LogbookCrewSelectionData {
const snapshots = Object.values(snapshotsById)
const skipper = snapshots.find((s) => s.role === 'skipper')
return {
activeSkipperId: skipper?.id ?? null,
activeCrewIds: snapshots.filter((s) => s.role === 'crew').map((s) => s.id),
snapshotsById
}
}
+18 -10
View File
@@ -6,6 +6,7 @@ import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type D
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
@@ -102,8 +103,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
try {
const data = await fetchLogbooks()
setLogbooks(data)
} catch (err: any) {
setError(err.message || 'Failed to load logbooks')
} catch (err: unknown) {
setError(getErrorMessage(err, t('errors.load_failed')))
} finally {
setLoading(false)
setRefreshing(false)
@@ -121,8 +122,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
setLogbooks((prev) => [created, ...prev])
setNewTitle('')
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
} catch (err: any) {
setError(err.message || 'Failed to create logbook')
} catch (err: unknown) {
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -138,7 +139,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
await deleteLogbook(id)
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
} catch (err: any) {
setError(err.message || 'Failed to delete logbook')
setError(getErrorMessage(err, t('errors.delete_failed')))
} finally {
setLoading(false)
}
@@ -182,7 +183,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
)
)
} catch (err: any) {
setError(err.message || 'Failed to update logbook title')
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -225,10 +226,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
return (
<div
key={lb.id}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
onClick={() => onSelectLogbook(lb.id, lb.title)}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-title' : ''}`}
>
<div className="card-icon">
{!isEditingTitle && (
<button
type="button"
className="logbook-card-select"
onClick={() => onSelectLogbook(lb.id, lb.title)}
aria-label={t('dashboard.open_logbook', { title: lb.title })}
/>
)}
<div className="card-icon" aria-hidden>
<BookOpen size={24} />
</div>
@@ -241,7 +250,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
className="logbook-title-inline-edit input-text"
value={editingTitleDraft}
onChange={(e) => setEditingTitleDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
+80 -21
View File
@@ -1,4 +1,14 @@
import React, { createContext, useContext, useState, useRef, useCallback, useMemo } from 'react'
import React, {
createContext,
useContext,
useState,
useRef,
useCallback,
useMemo,
useEffect,
useId
} from 'react'
import { useTranslation } from 'react-i18next'
interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
@@ -16,6 +26,11 @@ export function useDialog() {
}
export function DialogProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const titleId = useId()
const messageId = useId()
const confirmRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
@@ -23,19 +38,20 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
const [confirmLabel, setConfirmLabel] = useState('OK')
const [cancelLabel, setCancelLabel] = useState('Cancel')
const resolveRef = useRef<((val: any) => void) | null>(null)
const alertResolveRef = useRef<(() => void) | null>(null)
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('alert')
setConfirmLabel(btnText || 'OK')
setConfirmLabel(btnText || t('dialog.ok'))
setIsOpen(true)
return new Promise<void>((resolve) => {
resolveRef.current = resolve
alertResolveRef.current = resolve
})
}, [])
}, [t])
const showConfirm = useCallback((
msg: string,
@@ -46,31 +62,47 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm')
setConfirmLabel(btnConfirm || 'Yes')
setCancelLabel(btnCancel || 'No')
setConfirmLabel(btnConfirm || t('dialog.yes'))
setCancelLabel(btnCancel || t('dialog.no'))
setIsOpen(true)
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
confirmResolveRef.current = resolve
})
}, [])
}, [t])
const handleConfirm = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(type === 'confirm' ? true : undefined)
resolveRef.current = null
if (type === 'confirm' && confirmResolveRef.current) {
confirmResolveRef.current(true)
confirmResolveRef.current = null
} else if (alertResolveRef.current) {
alertResolveRef.current()
alertResolveRef.current = null
}
}, [type])
const handleCancel = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(false)
resolveRef.current = null
if (confirmResolveRef.current) {
confirmResolveRef.current(false)
confirmResolveRef.current = null
}
}, [])
useEffect(() => {
if (!isOpen) return
confirmRef.current?.focus()
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (type === 'confirm') handleCancel()
else handleConfirm()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [isOpen, type, handleCancel, handleConfirm])
const contextValue = useMemo(
() => ({ showAlert, showConfirm }),
[showAlert, showConfirm]
@@ -80,17 +112,44 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
<DialogContext.Provider value={contextValue}>
{children}
{isOpen && (
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}>
{title && <h3 className="custom-dialog-title">{title}</h3>}
<p className="custom-dialog-message">{message}</p>
<div
className="custom-dialog-overlay"
onClick={type === 'confirm' ? handleCancel : handleConfirm}
>
<div
className="custom-dialog-card glass scale-in"
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-describedby={messageId}
onClick={(e) => e.stopPropagation()}
>
{title && (
<h3 id={titleId} className="custom-dialog-title">
{title}
</h3>
)}
<p id={messageId} className="custom-dialog-message">
{message}
</p>
<div className="custom-dialog-actions">
{type === 'confirm' && (
<button type="button" className="btn secondary" onClick={handleCancel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}>
<button
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
)}
<button type="button" className="btn primary" onClick={handleConfirm} style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}>
<button
ref={confirmRef}
type="button"
className="btn primary"
onClick={handleConfirm}
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
>
{confirmLabel}
</button>
</div>
+313
View File
@@ -0,0 +1,313 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users, User, Plus, Trash2, Edit2, X, Camera, Save } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import { resizeImageFile } from '../utils/resizeImageFile.js'
import type { PersonData, PersonRole } from '../types/person.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import {
loadPersonPool,
savePerson,
deletePerson,
filterSkippers,
filterCrew,
type DecryptedPerson
} from '../services/personPool.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const emptyPerson = (role: PersonRole): PersonData => ({
name: '',
address: '',
birthDate: '',
phone: '',
nationality: '',
passportNumber: '',
bloodType: '',
allergies: '',
diseases: '',
role,
photo: null
})
export default function PersonPoolForm() {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [people, setPeople] = useState<DecryptedPerson[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [formRole, setFormRole] = useState<PersonRole>('crew')
const [form, setForm] = useState<PersonData>(emptyPerson('crew'))
const [saving, setSaving] = useState(false)
const [photoError, setPhotoError] = useState<string | null>(null)
const fileRef = React.useRef<HTMLInputElement>(null)
const reload = useCallback(async () => {
setLoading(true)
setError(null)
try {
setPeople(await loadPersonPool())
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
const openAdd = (role: PersonRole) => {
setEditingId(null)
setFormRole(role)
setForm(emptyPerson(role))
setPhotoError(null)
setShowForm(true)
}
const openEdit = (person: DecryptedPerson) => {
setEditingId(person.payloadId)
setFormRole(person.data.role)
setForm({ ...person.data })
setPhotoError(null)
setShowForm(true)
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.name.trim()) return
setSaving(true)
setError(null)
try {
const id = editingId ?? window.crypto.randomUUID()
await savePerson(id, { ...form, role: formRole }, !editingId)
setShowForm(false)
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: formRole, context: 'person_pool' })
await reload()
} catch (err: unknown) {
if (err instanceof Error && err.message === 'MAX_CREW') {
setError(t('crew.max_crew'))
} else {
setError(err instanceof Error ? err.message : 'Failed to save')
}
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
if (
!(await showConfirm(
t('person_pool.delete_confirm'),
t('person_pool.title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
))
) {
return
}
try {
await deletePerson(id)
await reload()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete')
}
}
const skippers = filterSkippers(people)
const crewList = filterCrew(people)
if (loading) {
return (
<div className="tab-placeholder">
<Users className="header-logo spin" size={48} />
<p>{t('person_pool.loading')}</p>
</div>
)
}
const renderCard = (person: DecryptedPerson) => (
<div key={person.payloadId} className="crew-member-card glass">
<div className="crew-card-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{person.data.photo ? (
<img src={person.data.photo} alt="" className="crew-card-avatar" />
) : (
<div className="crew-card-avatar-placeholder">
<User size={18} />
</div>
)}
<h4>{person.data.name}</h4>
</div>
<div className="card-actions">
<button type="button" className="btn-icon" onClick={() => openEdit(person)} title="Edit">
<Edit2 size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => void handleDelete(person.payloadId)}
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</div>
{person.data.phone && (
<p className="help-text">
<strong>{t('crew.phone')}:</strong> {person.data.phone}
</p>
)}
</div>
)
return (
<section className="form-card" data-tour="profile-crew-pool">
<div className="form-header">
<Users size={24} className="form-icon" />
<h2>{t('person_pool.title')}</h2>
</div>
<p className="help-text mb-4">{t('person_pool.subtitle')}</p>
{error && <div className="auth-error mb-4">{error}</div>}
<div className="section-title-bar mb-4">
<h3>{t('person_pool.skippers_section')}</h3>
{!showForm && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('skipper')}>
<Plus size={16} />
{t('person_pool.add_skipper')}
</button>
)}
</div>
{skippers.length === 0 ? (
<p className="help-text mb-4">{t('person_pool.no_skippers')}</p>
) : (
<div className="crew-grid mb-6">{skippers.map(renderCard)}</div>
)}
<div className="section-title-bar mb-4">
<h3>{t('person_pool.crew_section')}</h3>
{!showForm && crewList.length < MAX_POOL_CREW_MEMBERS && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('crew')}>
<Plus size={16} />
{t('person_pool.add_crew')}
</button>
)}
</div>
{crewList.length === 0 ? (
<p className="help-text">{t('person_pool.no_crew')}</p>
) : (
<div className="crew-grid">{crewList.map(renderCard)}</div>
)}
{showForm && (
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass mt-6">
<div className="editor-header mb-4">
<h3>
{editingId
? formRole === 'skipper'
? t('person_pool.edit_skipper')
: t('crew.edit_crew')
: formRole === 'skipper'
? t('person_pool.add_skipper')
: t('crew.add_crew')}
</h3>
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
<X size={16} />
</button>
</div>
<div className="form-grid">
<div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={() => fileRef.current?.click()}>
{form.photo ? (
<img src={form.photo} alt="" className="vessel-photo" />
) : (
<div className="vessel-photo-placeholder">
<User size={48} />
</div>
)}
<div className="vessel-photo-overlay">
<Camera size={24} />
</div>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0]
if (!file) return
void resizeImageFile(file)
.then((photo) => setForm((f) => ({ ...f, photo })))
.catch((err: unknown) => {
setPhotoError(err instanceof Error ? err.message : 'Image error')
})
}}
/>
{photoError && <div className="auth-error mt-2">{photoError}</div>}
</div>
<div className="input-group">
<label>{t('crew.name')} *</label>
<input
className="input-text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</div>
<div className="input-group">
<label>{t('crew.address')}</label>
<input
className="input-text"
value={form.address}
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.birthdate')}</label>
<input
type="date"
className="input-text"
value={form.birthDate}
onChange={(e) => setForm((f) => ({ ...f, birthDate: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.phone')}</label>
<input
className="input-text"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.nationality')}</label>
<input
className="input-text"
value={form.nationality}
onChange={(e) => setForm((f) => ({ ...f, nationality: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.passport')}</label>
<input
className="input-text"
value={form.passportNumber}
onChange={(e) => setForm((f) => ({ ...f, passportNumber: e.target.value }))}
/>
</div>
</div>
<div className="editor-actions mt-4">
<button type="submit" className="btn primary" disabled={saving || !form.name.trim()}>
<Save size={18} />
{t('crew.save_member')}
</button>
</div>
</form>
)}
</section>
)
}
+57 -13
View File
@@ -4,7 +4,11 @@ import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18n
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { personToSnapshot } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
@@ -31,7 +35,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
// Logbook data states
const [logbookTitle, setLogbookTitle] = useState('Logbook')
const [yacht, setYacht] = useState<any>(null)
const [crews, setCrews] = useState<any[]>([])
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
emptyLogbookCrewSelection()
)
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
const [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([])
const [gpsTracks, setGpsTracks] = useState<any[]>([])
@@ -71,18 +78,53 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
setYacht(decYacht)
// Decrypt Crews
const decCrews = []
if (data.crews) {
for (const c of data.crews) {
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
decCrews.push({
payloadId: c.payloadId,
data: dec
if (data.logbookCrewSelection) {
const decSel = await decryptJson(
data.logbookCrewSelection.encryptedData,
data.logbookCrewSelection.iv,
data.logbookCrewSelection.tag,
keyBuffer
)
if (decSel) {
setLogbookCrewSelection({
activeSkipperId: decSel.activeSkipperId ?? null,
activeCrewIds: Array.isArray(decSel.activeCrewIds) ? decSel.activeCrewIds : [],
snapshotsById:
decSel.snapshotsById && typeof decSel.snapshotsById === 'object'
? decSel.snapshotsById
: {}
})
}
}
setCrews(decCrews)
const decCrews: Array<{ payloadId: string; data: PersonData }> = []
if (data.crews) {
for (const c of data.crews) {
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
if (dec) {
decCrews.push({
payloadId: c.payloadId,
data: dec as PersonData
})
}
}
}
setLegacyCrews(decCrews)
if (!data.logbookCrewSelection && decCrews.length > 0) {
const snapshotsById: LogbookCrewSelectionData['snapshotsById'] = {}
let activeSkipperId: string | null = null
const activeCrewIds: string[] = []
for (const c of decCrews) {
snapshotsById[c.payloadId] = personToSnapshot(c.payloadId, c.data)
if (c.payloadId === 'skipper' || c.data.role === 'skipper') {
activeSkipperId = c.payloadId
} else {
activeCrewIds.push(c.payloadId)
}
}
setLogbookCrewSelection({ activeSkipperId, activeCrewIds, snapshotsById })
}
// Decrypt Entries
const decEntries = []
@@ -234,10 +276,12 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
)}
{activeTab === 'crew' && (
<CrewForm
<LogbookCrewPicker
logbookId="shared"
readOnly={true}
preloadedData={crews}
selectionOnly={true}
preloadedPool={legacyCrews.length > 0 ? legacyCrews : undefined}
preloadedSelection={logbookCrewSelection}
/>
)}
</main>
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from 'lucide-react'
import {
getSyncConflicts,
subscribeSyncConflicts,
type SyncConflict
} from '../services/syncConflicts.js'
import {
resolveSyncConflictKeepLocal,
resolveSyncConflictUseServer
} from '../services/sync.js'
interface SyncConflictBannerProps {
logbookId: string | null
}
export default function SyncConflictBanner({ logbookId }: SyncConflictBannerProps) {
const { t } = useTranslation()
const [items, setItems] = useState<SyncConflict[]>([])
useEffect(() => {
const refresh = () => {
setItems(logbookId ? getSyncConflicts(logbookId) : getSyncConflicts())
}
refresh()
return subscribeSyncConflicts(refresh)
}, [logbookId])
if (items.length === 0) return null
const first = items[0]
return (
<div className="sync-conflict-banner" role="alert">
<AlertTriangle size={20} aria-hidden />
<div className="sync-conflict-banner__body">
<strong>{t('sync.conflict_title')}</strong>
<p>
{t('sync.conflict_message', {
count: items.length,
id: first.payloadId.slice(0, 8)
})}
</p>
<div className="sync-conflict-banner__actions">
<button
type="button"
className="btn secondary"
onClick={() => void resolveSyncConflictUseServer(first)}
>
{t('sync.conflict_use_server')}
</button>
<button
type="button"
className="btn primary"
onClick={() => void resolveSyncConflictKeepLocal(first)}
>
{t('sync.conflict_keep_local')}
</button>
</div>
</div>
</div>
)
}
@@ -30,6 +30,7 @@ import {
} from 'lucide-react'
import AccountDangerZone from './AccountDangerZone.tsx'
import UserProfilePreferences from './UserProfilePreferences.tsx'
import PersonPoolForm from './PersonPoolForm.tsx'
import BetaBadge from './BetaBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
@@ -487,6 +488,8 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
<UserProfilePreferences userId={profile.userId} />
</div>
<PersonPoolForm />
<section className="member-editor-card glass">
<div className="profile-section-header">
<Shield size={20} />
+3 -1
View File
@@ -17,7 +17,9 @@ describe('AppTourContext step order', () => {
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
expect(prefsIndex).toBe(profileIndex + 1)
expect(finishIndex).toBe(prefsIndex + 1)
expect(FULL_STEP_ORDER).toHaveLength(12)
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
expect(FULL_STEP_ORDER).toHaveLength(13)
})
it('excludes profile, stats and feedback from demo tour', () => {
+19 -6
View File
@@ -26,7 +26,8 @@ export type TourStepId =
| 'entry_open'
| 'entry_track'
| 'nav_vessel'
| 'nav_crew'
| 'profile_crew_pool'
| 'nav_logbook_crew'
| 'nav_stats'
| 'nav_feedback'
| 'nav_profile'
@@ -71,7 +72,8 @@ export const FULL_STEP_ORDER: TourStepId[] = [
'entry_open',
'entry_track',
'nav_vessel',
'nav_crew',
'profile_crew_pool',
'nav_logbook_crew',
'nav_stats',
'nav_feedback',
'nav_profile',
@@ -81,6 +83,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
/** Public demo has no stats/feedback/profile UI — skip those steps. */
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
'profile_crew_pool',
'nav_stats',
'nav_feedback',
'nav_profile',
@@ -97,7 +100,7 @@ const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
'entry_open',
'entry_track',
'nav_vessel',
'nav_crew',
'nav_logbook_crew',
'nav_stats',
'nav_feedback'
])
@@ -112,7 +115,8 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
entry_open: '[data-tour="entry-first"]',
entry_track: '[data-tour="entry-track"]',
nav_vessel: '[data-tour="nav-vessel"]',
nav_crew: '[data-tour="nav-crew"]',
profile_crew_pool: '[data-tour="profile-crew-pool"]',
nav_logbook_crew: '[data-tour="nav-logbook-crew"]',
nav_stats: '[data-tour="stats-dashboard"]',
nav_feedback: '[data-tour="feedback-form"]',
nav_profile: '[data-tour="nav-profile"]',
@@ -127,7 +131,9 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
export function getTourTargetDelay(stepId: TourStepId): number {
if (stepId === 'entry_track') return 400
if (stepId === 'nav_feedback') return 180
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
if (stepId === 'nav_profile' || stepId === 'profile_preferences' || stepId === 'profile_crew_pool') {
return 250
}
return 0
}
@@ -183,8 +189,15 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setSelectedEntryId(null)
nav.setActiveTab('vessel')
}
if (stepId === 'nav_crew') {
if (stepId === 'profile_crew_pool') {
nav.setSelectedEntryId(null)
nav.setLogbookActive(false)
nav.setProfileOpen(true)
}
if (stepId === 'nav_logbook_crew') {
nav.setSelectedEntryId(null)
nav.setProfileOpen(false)
nav.setLogbookActive(true)
nav.setActiveTab('crew')
}
if (stepId === 'nav_stats') {
+63 -6
View File
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nej"
},
"errors": {
"load_failed": "Data kunne ikke indlæses.",
"save_failed": "Ændringer kunne ikke gemmes.",
"delete_failed": "Sletning mislykkedes.",
"export_failed": "Eksport mislykkedes."
},
"common": {
"unsaved_changes_title": "Ikke gemte ændringer",
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
@@ -92,13 +103,18 @@
"update_title": "Opdatering tilgængelig",
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
"update_now": "Opdater nu",
"update_reloading": "Indlæser..."
"update_reloading": "Indlæser...",
"storage_persist_hint": "Browseren kan slette offline-data. Tillad permanent lagring, så din logbog forbliver beskyttet."
},
"sync": {
"status_synced": "Synkroniseret",
"status_syncing": "Synkroniser...",
"status_offline": "Offline-cache",
"status_unsynced": "Usynkroniserede ændringer"
"status_unsynced": "Usynkroniserede ændringer",
"conflict_title": "Synkroniseringskonflikt",
"conflict_message": "{{count}} ændring(er) kunne ikke synkroniseres (post {{id}}…). Vælg hvilken version der skal gælde.",
"conflict_use_server": "Brug serverversion",
"conflict_keep_local": "Behold min version"
},
"vessel": {
"title": "Skibets stamdata",
@@ -252,6 +268,7 @@
"live_comment_placeholder": "Indtast tekst…",
"live_comment_confirm": "Indtast",
"live_gps_error": "GPS-position kunne ikke bestemmes.",
"live_gps_start_hint": "Begynd altid dagens rejse med en position.",
"live_event_generic": "Hændelse",
"live_weather_btn": "Vejr",
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
@@ -452,6 +469,7 @@
"role_read": "Læs kun",
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
"open_profile": "Åben profil af {{name}}",
"open_logbook": "Åbn logbog „{{title}}“",
"edit_title": "Omdøb logbog",
"edit_placeholder": "Nyt navn på logbogen",
"edit_success": "Logbog omdøbt med succes",
@@ -590,6 +608,41 @@
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
"push_error": "Push-meddelelser kunne ikke aktiveres."
},
"person_pool": {
"title": "Stambesætning og skippere",
"subtitle": "Administrer din personpulje her skippere og besætning til alle logbøger. Vælg aktiv besætning per logbog og rejsedag fra puljen.",
"loading": "Indlæser personpulje…",
"skippers_section": "Skippere",
"crew_section": "Stambesætning",
"add_skipper": "Tilføj skipper",
"add_crew": "Tilføj besætningsmedlem",
"edit_skipper": "Rediger skipper",
"no_skippers": "Ingen skipper i puljen endnu.",
"no_crew": "Ingen besætningsmedlemmer i puljen endnu.",
"delete_confirm": "Fjern denne person fra puljen?"
},
"logbook_crew": {
"title": "Besætning for denne logbog",
"subtitle": "Vælg skipper og besætning for denne logbog. Nye rejsedage arver valget som standard.",
"loading": "Indlæser besætning…",
"active_skipper": "Skipper for denne logbog",
"active_crew": "Besætning for denne logbog",
"no_skippers_in_pool": "Ingen skipper i puljen tilføj i brugerprofilen først.",
"no_crew_in_pool": "Ingen besætning i puljen tilføj i brugerprofilen først.",
"no_skipper": "Ingen skipper valgt",
"unnamed": "Uden navn",
"save": "Gem besætning",
"saved": "Logbogbesætning gemt.",
"selection_only_hint": "Du ser den besætning ejeren har valgt (delt logbog)."
},
"entry_crew": {
"title": "Besætning på denne rejsedag",
"subtitle": "Kan afvige fra logbogstandard. Følgende dage arver fra foregående dag.",
"day_skipper": "Skipper denne dag",
"day_crew": "Besætning denne dag",
"no_skipper": "Ingen skipper valgt",
"no_crew": "Ingen besætning valgt"
},
"crew": {
"title": "Skipper- og besætningsprofiler",
"skipper_section": "Skipper-profil",
@@ -598,7 +651,7 @@
"add_crew": "Tilføj besætningsmedlem",
"edit_crew": "Rediger besætningsmedlem",
"no_crew": "Ingen besætningsmedlemmer tilføjet endnu.",
"max_crew": "Det maksimale antal på 5 besætningsmedlemmer er nået.",
"max_crew": "Det maksimale antal på 12 besætningsmedlemmer i puljen er nået.",
"name": "Navn",
"address": "adresse",
"birthdate": "Fødselsdag",
@@ -852,9 +905,13 @@
"title": "Skibsdata",
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
},
"nav_crew": {
"title": "Besætningsliste",
"body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere."
"profile_crew_pool": {
"title": "Stambesætning og skippere",
"body": "I brugerprofilen vedligeholder du en personpulje flere skippere (f.eks. charter) og besætning til alle logbøger."
},
"nav_logbook_crew": {
"title": "Besætning per logbog",
"body": "Vælg skipper og besætning fra puljen til denne logbog. Rejsedage arver valget som standard."
},
"nav_stats": {
"title": "Statistik-dashboard",
+65 -8
View File
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nein"
},
"errors": {
"load_failed": "Daten konnten nicht geladen werden.",
"save_failed": "Änderungen konnten nicht gespeichert werden.",
"delete_failed": "Löschen fehlgeschlagen.",
"export_failed": "Export fehlgeschlagen."
},
"common": {
"unsaved_changes_title": "Ungespeicherte Änderungen",
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
@@ -22,7 +33,7 @@
"nav": {
"dashboard": "Dashboard",
"vessel": "Schiffsdaten",
"crew": "Crew-Liste",
"crew": "Mannschaft",
"deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge",
"stats": "Statistik",
@@ -92,13 +103,18 @@
"update_title": "Update verfügbar",
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
"update_now": "Jetzt aktualisieren",
"update_reloading": "Wird geladen…"
"update_reloading": "Wird geladen…",
"storage_persist_hint": "Der Browser kann Offline-Daten löschen. Erlaube dauerhafte Speicherung, damit dein Logbuch geschützt bleibt (in den Browser-Einstellungen oder beim nächsten Hinweis)."
},
"sync": {
"status_synced": "Synchronisiert",
"status_syncing": "Synchronisiere…",
"status_offline": "Offline-Cache",
"status_unsynced": "Unsynchronisierte Änderungen"
"status_unsynced": "Unsynchronisierte Änderungen",
"conflict_title": "Synchronisationskonflikt",
"conflict_message": "{{count}} Änderung(en) konnten nicht synchronisiert werden (Eintrag {{id}}…). Bitte wähle, welche Version gelten soll.",
"conflict_use_server": "Server-Version übernehmen",
"conflict_keep_local": "Meine Version behalten"
},
"vessel": {
"title": "Schiffs-Stammdaten",
@@ -252,6 +268,7 @@
"live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
"live_event_generic": "Ereignis",
"live_weather_btn": "Wetter",
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
@@ -452,6 +469,7 @@
"role_read": "Nur Lesen",
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
"open_profile": "Profil von {{name}} öffnen",
"open_logbook": "Logbuch „{{title}}“ öffnen",
"edit_title": "Logbuch umbenennen",
"edit_placeholder": "Neuer Name des Logbuchs",
"edit_success": "Logbuch erfolgreich umbenannt",
@@ -590,6 +608,41 @@
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
},
"person_pool": {
"title": "Stammcrew & Skipper",
"subtitle": "Lege hier deinen Personen-Pool an Skipper und Crew für alle Logbücher. Aus diesem Pool wählst du pro Logbuch und Reisetag die aktive Mannschaft.",
"loading": "Personen-Pool wird geladen…",
"skippers_section": "Stammskipper",
"crew_section": "Stammcrew",
"add_skipper": "Skipper hinzufügen",
"add_crew": "Crew-Mitglied hinzufügen",
"edit_skipper": "Skipper bearbeiten",
"no_skippers": "Noch kein Skipper im Pool.",
"no_crew": "Noch keine Crew-Mitglieder im Pool.",
"delete_confirm": "Diese Person wirklich aus dem Pool entfernen?"
},
"logbook_crew": {
"title": "Mannschaft für dieses Logbuch",
"subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.",
"loading": "Mannschaft wird geladen…",
"active_skipper": "Skipper für dieses Logbuch",
"active_crew": "Crew für dieses Logbuch",
"no_skippers_in_pool": "Kein Skipper im Pool zuerst im Benutzerprofil anlegen.",
"no_crew_in_pool": "Keine Crew im Pool zuerst im Benutzerprofil anlegen.",
"no_skipper": "Kein Skipper gewählt",
"unnamed": "Unbenannt",
"save": "Mannschaft speichern",
"saved": "Mannschaft für das Logbuch gespeichert.",
"selection_only_hint": "Du siehst die vom Eigner festgelegte Mannschaft (geteiltes Logbuch)."
},
"entry_crew": {
"title": "Mannschaft an diesem Reisetag",
"subtitle": "Kann vom Logbuch-Standard abweichen. Folge-Reisetage übernehmen den Vortag.",
"day_skipper": "Skipper an diesem Tag",
"day_crew": "Crew an diesem Tag",
"no_skipper": "Kein Skipper gewählt",
"no_crew": "Keine Crew gewählt"
},
"crew": {
"title": "Skipper- & Crew-Profile",
"skipper_section": "Skipper-Profil",
@@ -598,7 +651,7 @@
"add_crew": "Crew-Mitglied hinzufügen",
"edit_crew": "Crew-Mitglied bearbeiten",
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.",
"max_crew": "Maximale Anzahl von 5 Crew-Mitgliedern erreicht.",
"max_crew": "Maximale Anzahl von 12 Crew-Mitgliedern im Pool erreicht.",
"name": "Name",
"address": "Anschrift",
"birthdate": "Geburtstag",
@@ -830,7 +883,7 @@
},
"welcome_public": {
"title": "Willkommen an Bord!",
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge."
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese Tour zeigt dir Schiffsdaten, Mannschaftsauswahl und Logbucheinträge. Die Stammcrew pflegst du später im Benutzerprofil."
},
"nav_logs": {
"title": "Logbucheinträge",
@@ -852,9 +905,13 @@
"title": "Schiffsdaten",
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht einmal ausfüllen, für alle Reisetage verfügbar."
},
"nav_crew": {
"title": "Crew-Liste",
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
"profile_crew_pool": {
"title": "Stammcrew & Skipper",
"body": "Im Benutzerprofil pflegst du deinen Personen-Pool mehrere Skipper (z. B. Charter) und Crew-Mitglieder für alle Logbücher."
},
"nav_logbook_crew": {
"title": "Mannschaft pro Logbuch",
"body": "Wähle aus dem Pool, wer auf diesem Logbuch als Skipper und Crew gilt. Reisetage übernehmen diese Auswahl standardmäßig."
},
"nav_stats": {
"title": "Statistik-Dashboard",
+64 -7
View File
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Yes",
"no": "No"
},
"errors": {
"load_failed": "Could not load data.",
"save_failed": "Could not save changes.",
"delete_failed": "Could not delete.",
"export_failed": "Export failed."
},
"common": {
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
@@ -22,7 +33,7 @@
"nav": {
"dashboard": "Dashboard",
"vessel": "Vessel Profile",
"crew": "Crew List",
"crew": "Crew",
"deviation": "Deviation Table",
"logs": "Logbook Entries",
"stats": "Statistics",
@@ -92,13 +103,18 @@
"update_title": "Update available",
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
"update_now": "Reload now",
"update_reloading": "Reloading…"
"update_reloading": "Reloading…",
"storage_persist_hint": "Your browser may delete offline data. Allow persistent storage to keep your logbook safe (browser settings or when prompted)."
},
"sync": {
"status_synced": "Synced",
"status_syncing": "Syncing…",
"status_offline": "Offline Cache",
"status_unsynced": "Unsynced changes"
"status_unsynced": "Unsynced changes",
"conflict_title": "Sync conflict",
"conflict_message": "{{count}} change(s) could not be synced (entry {{id}}…). Choose which version to keep.",
"conflict_use_server": "Use server version",
"conflict_keep_local": "Keep my version"
},
"vessel": {
"title": "Vessel Master Data",
@@ -252,6 +268,7 @@
"live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.",
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
"live_event_generic": "Event",
"live_weather_btn": "Weather",
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
@@ -452,6 +469,7 @@
"role_read": "Read only",
"role_read_hint": "Shared logbook — view only, no editing",
"open_profile": "Open profile for {{name}}",
"open_logbook": "Open logbook “{{title}}”",
"edit_title": "Rename Logbook",
"edit_placeholder": "New name of the logbook",
"edit_success": "Logbook renamed successfully",
@@ -590,6 +608,41 @@
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications."
},
"person_pool": {
"title": "Core crew & skippers",
"subtitle": "Maintain your person pool here — skippers and crew for all logbooks. Select active crew per logbook and travel day from this pool.",
"loading": "Loading person pool…",
"skippers_section": "Skippers",
"crew_section": "Core crew",
"add_skipper": "Add skipper",
"add_crew": "Add crew member",
"edit_skipper": "Edit skipper",
"no_skippers": "No skippers in the pool yet.",
"no_crew": "No crew members in the pool yet.",
"delete_confirm": "Remove this person from the pool?"
},
"logbook_crew": {
"title": "Crew for this logbook",
"subtitle": "Choose skipper and crew for this logbook. New travel days inherit this selection by default.",
"loading": "Loading crew…",
"active_skipper": "Skipper for this logbook",
"active_crew": "Crew for this logbook",
"no_skippers_in_pool": "No skipper in the pool — add one in your user profile first.",
"no_crew_in_pool": "No crew in the pool — add members in your user profile first.",
"no_skipper": "No skipper selected",
"unnamed": "Unnamed",
"save": "Save crew",
"saved": "Logbook crew saved.",
"selection_only_hint": "You see the crew set by the owner (shared logbook)."
},
"entry_crew": {
"title": "Crew on this travel day",
"subtitle": "May differ from the logbook default. Following days inherit from the previous day.",
"day_skipper": "Skipper on this day",
"day_crew": "Crew on this day",
"no_skipper": "No skipper selected",
"no_crew": "No crew selected"
},
"crew": {
"title": "Skipper & Crew Profiles",
"skipper_section": "Skipper Profile",
@@ -598,7 +651,7 @@
"add_crew": "Add Crew Member",
"edit_crew": "Edit Crew Member",
"no_crew": "No crew members added yet.",
"max_crew": "Maximum of 5 crew members reached.",
"max_crew": "Maximum of 12 crew members in the pool reached.",
"name": "Full Name",
"address": "Address",
"birthdate": "Date of Birth",
@@ -852,9 +905,13 @@
"title": "Vessel data",
"body": "Enter your yacht's name, dimensions, and technical details fill once, use on every travel day."
},
"nav_crew": {
"title": "Crew list",
"body": "Manage crew members and assign them to travel days later."
"profile_crew_pool": {
"title": "Core crew & skippers",
"body": "In your user profile you maintain a person pool — multiple skippers (e.g. charter) and crew for all logbooks."
},
"nav_logbook_crew": {
"title": "Crew per logbook",
"body": "Pick skipper and crew from the pool for this logbook. Travel days inherit this selection by default."
},
"nav_stats": {
"title": "Statistics dashboard",
+63 -6
View File
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nei"
},
"errors": {
"load_failed": "Data kunne ikke lastes.",
"save_failed": "Endringer kunne ikke lagres.",
"delete_failed": "Sletting mislyktes.",
"export_failed": "Eksport mislyktes."
},
"common": {
"unsaved_changes_title": "Ikke-lagrede endringer",
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
@@ -92,13 +103,18 @@
"update_title": "Oppdatering tilgjengelig",
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
"update_now": "Oppdater nå",
"update_reloading": "Laster..."
"update_reloading": "Laster...",
"storage_persist_hint": "Nettleseren kan slette offlinedata. Tillat permanent lagring slik at loggboken din forblir beskyttet."
},
"sync": {
"status_synced": "Synkronisert",
"status_syncing": "Synkroniser...",
"status_offline": "Frakoblet hurtigbuffer",
"status_unsynced": "Usynkroniserte endringer"
"status_unsynced": "Usynkroniserte endringer",
"conflict_title": "Synkroniseringskonflikt",
"conflict_message": "{{count}} endring(er) kunne ikke synkroniseres (post {{id}}…). Velg hvilken versjon som skal gjelde.",
"conflict_use_server": "Bruk serverversjon",
"conflict_keep_local": "Behold min versjon"
},
"vessel": {
"title": "Stamdata for skip",
@@ -252,6 +268,7 @@
"live_comment_placeholder": "Skriv inn tekst…",
"live_comment_confirm": "Loggfør",
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
"live_event_generic": "Hendelse",
"live_weather_btn": "Vær",
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
@@ -452,6 +469,7 @@
"role_read": "Bare les",
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
"open_profile": "Åpne profilen til {{name}}",
"open_logbook": "Åpne loggbok «{{title}}»",
"edit_title": "Endre navn på loggbok",
"edit_placeholder": "Nytt navn på loggboken",
"edit_success": "Loggboken har fått nytt navn",
@@ -590,6 +608,41 @@
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
"push_error": "Push-varsler kunne ikke aktiveres."
},
"person_pool": {
"title": "Stammmannskap og skippere",
"subtitle": "Hold personpoolen din her skippere og mannskap for alle loggbøker. Velg aktivt mannskap per loggbok og reisedag fra poolen.",
"loading": "Laster personpool…",
"skippers_section": "Skippere",
"crew_section": "Stammmannskap",
"add_skipper": "Legg til skipper",
"add_crew": "Legg til mannskapsmedlem",
"edit_skipper": "Rediger skipper",
"no_skippers": "Ingen skipper i poolen ennå.",
"no_crew": "Ingen mannskapsmedlemmer i poolen ennå.",
"delete_confirm": "Fjerne denne personen fra poolen?"
},
"logbook_crew": {
"title": "Mannskap for denne loggboken",
"subtitle": "Velg skipper og mannskap for denne loggboken. Nye reisedager arver valget som standard.",
"loading": "Laster mannskap…",
"active_skipper": "Skipper for denne loggboken",
"active_crew": "Mannskap for denne loggboken",
"no_skippers_in_pool": "Ingen skipper i poolen legg til i brukerprofilen først.",
"no_crew_in_pool": "Ingen mannskap i poolen legg til i brukerprofilen først.",
"no_skipper": "Ingen skipper valgt",
"unnamed": "Uten navn",
"save": "Lagre mannskap",
"saved": "Loggbokmannskap lagret.",
"selection_only_hint": "Du ser mannskapet eieren har valgt (delt loggbok)."
},
"entry_crew": {
"title": "Mannskap på denne reisedagen",
"subtitle": "Kan avvike fra loggbokstandard. Følgende dager arver fra forrige dag.",
"day_skipper": "Skipper denne dagen",
"day_crew": "Mannskap denne dagen",
"no_skipper": "Ingen skipper valgt",
"no_crew": "Ingen mannskap valgt"
},
"crew": {
"title": "Skipper- og mannskapsprofiler",
"skipper_section": "Skipperprofil",
@@ -598,7 +651,7 @@
"add_crew": "Legg til besetningsmedlem",
"edit_crew": "Rediger besetningsmedlem",
"no_crew": "Ingen besetningsmedlemmer er lagt til ennå.",
"max_crew": "Maksimalt antall på 5 besetningsmedlemmer er nådd.",
"max_crew": "Maksimalt antall på 12 besetningsmedlemmer i poolen er nådd.",
"name": "Navn",
"address": "adresse",
"birthdate": "Bursdag",
@@ -852,9 +905,13 @@
"title": "Skipsdata",
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
},
"nav_crew": {
"title": "Mannskapsliste",
"body": "Administrer mannskapet og tilordne dem til reisedager senere."
"profile_crew_pool": {
"title": "Stammmannskap og skippere",
"body": "I brukerprofilen vedlikeholder du en personpool flere skippere (f.eks. charter) og mannskap for alle loggbøker."
},
"nav_logbook_crew": {
"title": "Mannskap per loggbok",
"body": "Velg skipper og mannskap fra poolen for denne loggboken. Reisedager arver valget som standard."
},
"nav_stats": {
"title": "Dashbord for statistikk",
+63 -6
View File
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nej"
},
"errors": {
"load_failed": "Data kunde inte laddas.",
"save_failed": "Ändringar kunde inte sparas.",
"delete_failed": "Radering misslyckades.",
"export_failed": "Export misslyckades."
},
"common": {
"unsaved_changes_title": "Osparade ändringar",
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
@@ -92,13 +103,18 @@
"update_title": "Uppdatering tillgänglig",
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
"update_now": "Uppdatering nu",
"update_reloading": "Laddar..."
"update_reloading": "Laddar...",
"storage_persist_hint": "Webbläsaren kan radera offlinedata. Tillåt permanent lagring så att din loggbok förblir skyddad."
},
"sync": {
"status_synced": "Synkroniserad",
"status_syncing": "Synkronisera...",
"status_offline": "Offline-cache",
"status_unsynced": "Osynkroniserade förändringar"
"status_unsynced": "Osynkroniserade förändringar",
"conflict_title": "Synkroniseringskonflikt",
"conflict_message": "{{count}} ändring(ar) kunde inte synkas (post {{id}}…). Välj vilken version som ska gälla.",
"conflict_use_server": "Använd serverversion",
"conflict_keep_local": "Behåll min version"
},
"vessel": {
"title": "Masterdata för fartyg",
@@ -252,6 +268,7 @@
"live_comment_placeholder": "Ange text…",
"live_comment_confirm": "Logga",
"live_gps_error": "GPS-position kunde inte bestämmas.",
"live_gps_start_hint": "Börja alltid dagsresan med en position.",
"live_event_generic": "Händelse",
"live_weather_btn": "Väder",
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
@@ -452,6 +469,7 @@
"role_read": "Endast läsning",
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
"open_profile": "Öppna profil för {{name}}",
"open_logbook": "Öppna loggbok ”{{title}}”",
"edit_title": "Byt namn på loggbok",
"edit_placeholder": "Nytt namn på loggboken",
"edit_success": "Loggboken har framgångsrikt bytt namn",
@@ -590,6 +608,41 @@
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
"push_error": "Push-meddelanden kunde inte aktiveras."
},
"person_pool": {
"title": "Stambesättning och skeppare",
"subtitle": "Underhåll din personpool här skeppare och besättning för alla loggböcker. Välj aktiv besättning per loggbok och resdag från poolen.",
"loading": "Laddar personpool…",
"skippers_section": "Skeppare",
"crew_section": "Stambesättning",
"add_skipper": "Lägg till skeppare",
"add_crew": "Lägg till besättningsmedlem",
"edit_skipper": "Redigera skeppare",
"no_skippers": "Ingen skeppare i poolen ännu.",
"no_crew": "Inga besättningsmedlemmar i poolen ännu.",
"delete_confirm": "Ta bort denna person från poolen?"
},
"logbook_crew": {
"title": "Besättning för denna loggbok",
"subtitle": "Välj skeppare och besättning för denna loggbok. Nya resdagar ärver valet som standard.",
"loading": "Laddar besättning…",
"active_skipper": "Skeppare för denna loggbok",
"active_crew": "Besättning för denna loggbok",
"no_skippers_in_pool": "Ingen skeppare i poolen lägg till i användarprofilen först.",
"no_crew_in_pool": "Ingen besättning i poolen lägg till i användarprofilen först.",
"no_skipper": "Ingen skeppare vald",
"unnamed": "Namnlös",
"save": "Spara besättning",
"saved": "Loggbokbesättning sparad.",
"selection_only_hint": "Du ser den besättning ägaren valt (delad loggbok)."
},
"entry_crew": {
"title": "Besättning denna resdag",
"subtitle": "Kan skilja sig från loggboksstandard. Följande dagar ärver från föregående dag.",
"day_skipper": "Skeppare denna dag",
"day_crew": "Besättning denna dag",
"no_skipper": "Ingen skeppare vald",
"no_crew": "Ingen besättning vald"
},
"crew": {
"title": "Profiler för skeppare och besättning",
"skipper_section": "Skepparens profil",
@@ -598,7 +651,7 @@
"add_crew": "Lägg till besättningsmedlem",
"edit_crew": "Redigera besättningsmedlem",
"no_crew": "Inga besättningsmedlemmar har lagts till ännu.",
"max_crew": "Maximalt antal på 5 besättningsmedlemmar uppnås.",
"max_crew": "Maximalt antal på 12 besättningsmedlemmar i poolen uppnått.",
"name": "Namn",
"address": "adress",
"birthdate": "Födelsedag",
@@ -852,9 +905,13 @@
"title": "Fartygsdata",
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
},
"nav_crew": {
"title": "Besättningslista",
"body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare."
"profile_crew_pool": {
"title": "Stambesättning och skeppare",
"body": "I användarprofilen underhåller du en personpool flera skeppare (t.ex. charter) och besättning för alla loggböcker."
},
"nav_logbook_crew": {
"title": "Besättning per loggbok",
"body": "Välj skeppare och besättning från poolen för denna loggbok. Resdagar ärver valet som standard."
},
"nav_stats": {
"title": "Kontrollpanel för statistik",
+121
View File
@@ -0,0 +1,121 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import { getLogbookKey } from './logbookKeys.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import { entryCrewFromLogbookSelection } from '../utils/personSnapshots.js'
import { saveLogbookCrewSelection } from './logbookCrewSelection.js'
const MIGRATION_FLAG = 'crew_pool_migration_v1_done'
export async function migrateLegacyCrewToPoolIfNeeded(): Promise<void> {
const userId = localStorage.getItem('active_userid')
if (!userId || localStorage.getItem(MIGRATION_FLAG) === userId) return
const masterKey = getActiveMasterKey()
if (!masterKey) return
try {
const ownedLogbooks = await db.logbooks.filter((lb) => lb.isShared !== 1).toArray()
const poolByLegacyKey = new Map<string, string>()
const poolData = new Map<string, PersonData>()
for (const logbook of ownedLogbooks) {
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
const legacyCrews = await db.crews.where({ logbookId: logbook.id }).toArray()
const legacyIds: { skipperId: string | null; crewIds: string[] } = {
skipperId: null,
crewIds: []
}
for (const record of legacyCrews) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, logbookKey)) as
| PersonData
| null
if (!data) continue
const role = record.payloadId === 'skipper' ? 'skipper' : 'crew'
const personData: PersonData = { ...data, role }
const dedupeKey = `${role}:${personData.name}:${personData.passportNumber}`
let poolId = poolByLegacyKey.get(dedupeKey)
if (!poolId) {
poolId = record.payloadId === 'skipper' ? 'skipper' : record.payloadId
const existing = await db.personPool.get(poolId)
if (!existing) {
const encrypted = await encryptJson(personData, masterKey)
const now = new Date().toISOString()
await db.personPool.put({
payloadId: poolId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'person',
payloadId: poolId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
poolByLegacyKey.set(dedupeKey, poolId)
poolData.set(poolId, personData)
}
if (role === 'skipper') legacyIds.skipperId = poolId
else legacyIds.crewIds.push(poolId)
}
const existingSelection = await db.logbookCrewSelections.get(logbook.id)
if (!existingSelection && (legacyIds.skipperId || legacyIds.crewIds.length > 0)) {
const selection = buildLogbookCrewSelection(
legacyIds.skipperId,
legacyIds.crewIds,
poolData
)
await saveLogbookCrewSelection(logbook.id, selection)
const entryCrew = entryCrewFromLogbookSelection(selection)
const entries = await db.entries.where({ logbookId: logbook.id }).toArray()
for (const entry of entries) {
const dec = (await decryptJson(entry.encryptedData, entry.iv, entry.tag, logbookKey)) as Record<
string,
unknown
> | null
if (!dec) continue
if (dec.selectedSkipperId != null || (Array.isArray(dec.selectedCrewIds) && dec.selectedCrewIds.length > 0)) {
continue
}
const updated = {
...dec,
...entryCrew
}
const encrypted = await encryptJson(updated, logbookKey)
const now = new Date().toISOString()
await db.entries.put({
...entry,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entry.payloadId,
logbookId: logbook.id,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
}
}
localStorage.setItem(MIGRATION_FLAG, userId)
} catch (err) {
console.warn('Crew pool migration failed:', err)
}
}
+68 -1
View File
@@ -80,16 +80,50 @@ export interface LocalLogbookKey {
tag: string
}
export interface LocalPerson {
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookCrewSelection {
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' | 'photo' | 'gpsTrack'
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' | 'logbookCrew'
payloadId: string // payloadId or logbookId depending on the type
logbookId: string
data: string // JSON representation of the local record
updatedAt: string
}
export interface UserSyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'person'
payloadId: string
data: string
updatedAt: string
}
export interface EntryDraftRecord {
logbookId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
class DaagboxDatabase extends Dexie {
logbooks!: Table<LocalLogbook>
yachts!: Table<LocalYacht>
@@ -100,7 +134,11 @@ class DaagboxDatabase extends Dexie {
gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
personPool!: Table<LocalPerson>
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
syncQueue!: Table<SyncQueueItem>
userSyncQueue!: Table<UserSyncQueueItem>
entryDrafts!: Table<EntryDraftRecord, [string, string]>
constructor() {
super('DaagboxDatabase')
@@ -167,6 +205,35 @@ class DaagboxDatabase extends Dexie {
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(7).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
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',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(8).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
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',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
personPool: 'payloadId, updatedAt',
logbookCrewSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
}
}
+47 -16
View File
@@ -4,9 +4,12 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { syncPersonPool } from './personPoolSync.js'
import i18n from '../i18n/index.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import {
buildDemoCrewRecords,
buildDemoPersonPool,
buildDemoEntryPayloads,
buildDemoYachtData
} from './demoLogbookData.js'
@@ -24,7 +27,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
async function putEncryptedRecord(
logbookId: string,
key: ArrayBuffer,
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
payloadId: string,
data: unknown,
now: string
@@ -40,15 +43,6 @@ async function putEncryptedRecord(
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'crew') {
await db.crews.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'yacht') {
await db.yachts.put({
logbookId,
@@ -66,25 +60,62 @@ async function putEncryptedRecord(
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'logbookCrew') {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
}
await db.syncQueue.put({
action: type === 'yacht' ? 'update' : 'create',
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
type,
payloadId: type === 'yacht' ? logbookId : payloadId,
payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
async function seedPersonPool(masterKey: ArrayBuffer, now: string): Promise<Map<string, PersonData>> {
const poolMap = new Map<string, PersonData>()
for (const person of buildDemoPersonPool()) {
poolMap.set(person.payloadId, person.data)
const encrypted = await encryptJson(person.data, masterKey)
await db.personPool.put({
payloadId: person.payloadId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'person',
payloadId: person.payloadId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
syncPersonPool().catch((err) => console.warn('Demo person pool sync failed:', err))
return poolMap
}
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not available')
const yachtData = buildDemoYachtData()
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
for (const crew of buildDemoCrewRecords()) {
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
}
const poolMap = await seedPersonPool(masterKey, now)
const skipperId = [...poolMap.entries()].find(([, d]) => d.role === 'skipper')?.[0] ?? null
const crewIds = [...poolMap.entries()].filter(([, d]) => d.role === 'crew').map(([id]) => id)
const selection = buildLogbookCrewSelection(skipperId, crewIds, poolMap)
await putEncryptedRecord(logbookId, key, 'logbookCrew', logbookId, selection, now)
}
export interface DemoSeedResult {
+39 -2
View File
@@ -16,6 +16,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [
'a0000001-0000-4000-8000-000000000003'
] as const
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec {
@@ -52,7 +53,14 @@ export interface DemoCrewRecord {
export interface PublicDemoFixture {
title: string
yacht: Record<string, unknown>
/** @deprecated legacy share payload */
crews: DemoCrewRecord[]
personPool: DemoCrewRecord[]
logbookCrewSelection: {
activeSkipperId: string
activeCrewIds: string[]
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
}
entries: Array<Record<string, unknown> & { payloadId: string }>
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
photos: never[]
@@ -188,11 +196,15 @@ export function buildDemoYachtData(): Record<string, unknown> {
}
}
export function buildDemoPersonPool(): DemoCrewRecord[] {
return buildDemoCrewRecords()
}
export function buildDemoCrewRecords(): DemoCrewRecord[] {
const isDe = isGermanLocale(i18n.language)
return [
{
payloadId: 'skipper',
payloadId: PUBLIC_DEMO_SKIPPER_ID,
data: {
name: 'Demo Skipper',
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
@@ -226,10 +238,26 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
]
}
function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
const skipper = pool.find((p) => p.data.role === 'skipper')
const crew = pool.filter((p) => p.data.role === 'crew')
const snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }> = {}
for (const p of pool) {
snapshotsById[p.payloadId] = { id: p.payloadId, ...p.data }
}
return {
activeSkipperId: skipper?.payloadId ?? PUBLIC_DEMO_SKIPPER_ID,
activeCrewIds: crew.map((c) => c.payloadId),
snapshotsById
}
}
export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData()
const crews = buildDemoCrewRecords()
const personPool = buildDemoPersonPool()
const crews = personPool
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
const days = buildDemoDays()
const entries: PublicDemoFixture['entries'] = []
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
@@ -247,6 +275,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '',
signCrew: '',
events: day.events
@@ -280,6 +311,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
title,
yacht,
crews,
personPool,
logbookCrewSelection,
entries,
gpsTracks,
photos: [],
@@ -297,6 +330,7 @@ export function buildDemoEntryPayloads(): Array<{
entryPayload: Record<string, unknown>
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
}> {
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
const days = buildDemoDays()
return days.map((day) => {
const entryId = crypto.randomUUID()
@@ -310,6 +344,9 @@ export function buildDemoEntryPayloads(): Array<{
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '',
signCrew: '',
events: day.events
+53
View File
@@ -0,0 +1,53 @@
import { db } from './db.js'
import { encryptJson, decryptJson } from './crypto.js'
import { getActiveMasterKey } from './auth.js'
export interface EntryDraftRecord {
logbookId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export async function saveEntryDraft(
logbookId: string,
entryId: string,
payload: unknown
): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) return
const { ciphertext, iv, tag } = await encryptJson(payload, masterKey)
await db.entryDrafts.put({
logbookId,
entryId,
encryptedData: ciphertext,
iv,
tag,
updatedAt: new Date().toISOString()
})
}
export async function loadEntryDraft<T = unknown>(
logbookId: string,
entryId: string
): Promise<T | null> {
const masterKey = getActiveMasterKey()
if (!masterKey) return null
const row = await db.entryDrafts.get([logbookId, entryId])
if (!row) return null
try {
return (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as T
} catch {
await db.entryDrafts.delete([logbookId, entryId])
return null
}
}
export async function clearEntryDraft(logbookId: string, entryId: string): Promise<void> {
await db.entryDrafts.delete([logbookId, entryId])
}
@@ -0,0 +1,75 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import { loadPersonPoolMap } from './personPool.js'
async function resolveLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function loadLogbookCrewSelection(
logbookId: string
): Promise<LogbookCrewSelectionData> {
const record = await db.logbookCrewSelections.get(logbookId)
if (!record) return emptyLogbookCrewSelection()
const key = await resolveLogbookKey(logbookId)
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
| LogbookCrewSelectionData
| null
if (!data) return emptyLogbookCrewSelection()
return {
activeSkipperId: data.activeSkipperId ?? null,
activeCrewIds: Array.isArray(data.activeCrewIds) ? data.activeCrewIds : [],
snapshotsById: data.snapshotsById && typeof data.snapshotsById === 'object' ? data.snapshotsById : {}
}
}
export async function saveLogbookCrewSelection(
logbookId: string,
selection: LogbookCrewSelectionData
): Promise<void> {
const key = await resolveLogbookKey(logbookId)
const encrypted = await encryptJson(selection, key)
const now = new Date().toISOString()
await db.logbookCrewSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'logbookCrew',
payloadId: logbookId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
export async function saveLogbookCrewSelectionFromIds(
logbookId: string,
activeSkipperId: string | null,
activeCrewIds: string[],
poolOverride?: Map<string, PersonData>
): Promise<LogbookCrewSelectionData> {
const pool = poolOverride ?? (await loadPersonPoolMap())
const selection = buildLogbookCrewSelection(activeSkipperId, activeCrewIds, pool)
await saveLogbookCrewSelection(logbookId, selection)
return selection
}
+110
View File
@@ -0,0 +1,110 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import type { PersonData } from '../types/person.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import { syncPersonPool } from './personPoolSync.js'
export interface DecryptedPerson {
payloadId: string
data: PersonData
}
function requireMasterKey(): ArrayBuffer {
const key = getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function loadPersonPool(): Promise<DecryptedPerson[]> {
const masterKey = requireMasterKey()
const records = await db.personPool.toArray()
const result: DecryptedPerson[] = []
for (const record of records) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)) as
| PersonData
| null
if (data) {
result.push({ payloadId: record.payloadId, data })
}
}
result.sort((a, b) => {
if (a.data.role !== b.data.role) return a.data.role === 'skipper' ? -1 : 1
return a.data.name.localeCompare(b.data.name, undefined, { sensitivity: 'base' })
})
return result
}
export async function loadPersonPoolMap(): Promise<Map<string, PersonData>> {
const people = await loadPersonPool()
return new Map(people.map((p) => [p.payloadId, p.data]))
}
export async function savePerson(
payloadId: string,
data: PersonData,
isNew: boolean
): Promise<void> {
if (data.role === 'crew' && isNew) {
const crewCount = await db.personPool
.toArray()
.then(async (rows) => {
let count = 0
const masterKey = requireMasterKey()
for (const row of rows) {
const dec = (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as PersonData | null
if (dec?.role === 'crew') count++
}
return count
})
if (crewCount >= MAX_POOL_CREW_MEMBERS) {
throw new Error('MAX_CREW')
}
}
const masterKey = requireMasterKey()
const encrypted = await encryptJson(data, masterKey)
const now = new Date().toISOString()
await db.personPool.put({
payloadId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: isNew ? 'create' : 'update',
type: 'person',
payloadId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
}
export async function deletePerson(payloadId: string): Promise<void> {
const now = new Date().toISOString()
await db.personPool.delete(payloadId)
await db.userSyncQueue.put({
action: 'delete',
type: 'person',
payloadId,
data: '',
updatedAt: now
})
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
}
export function filterSkippers(people: DecryptedPerson[]): DecryptedPerson[] {
return people.filter((p) => p.data.role === 'skipper')
}
export function filterCrew(people: DecryptedPerson[]): DecryptedPerson[] {
return people.filter((p) => p.data.role === 'crew')
}
+83
View File
@@ -0,0 +1,83 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { apiFetch } from './api.js'
const API_BASE = '/api/auth/person-pool'
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
return new Date(timeA).getTime() > new Date(timeB).getTime()
}
export async function syncPersonPool(): Promise<void> {
if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return
await pushPersonPool()
await pullPersonPool()
}
async function pushPersonPool(): Promise<void> {
const pending = await db.userSyncQueue.toArray()
if (pending.length === 0) return
try {
const response = await apiFetch(`${API_BASE}/push`, {
method: 'POST',
body: JSON.stringify({ items: pending })
})
if (!response.ok) {
console.warn('Person pool push rejected')
return
}
const { results } = await response.json()
for (let i = 0; i < results.length; i++) {
const res = results[i]
const item = pending[i]
if (!item) continue
if (res.status === 'success' && item.id !== undefined) {
await db.userSyncQueue.delete(item.id)
}
}
} catch (err) {
console.warn('Person pool push failed:', err)
}
}
async function pullPersonPool(): Promise<void> {
try {
const response = await apiFetch(API_BASE, { method: 'GET' })
if (!response.ok) return
const { persons } = await response.json()
if (!Array.isArray(persons)) return
const serverMap = new Map<string, (typeof persons)[0]>()
for (const p of persons) {
serverMap.set(p.payloadId, p)
const local = await db.personPool.get(p.payloadId)
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
await db.personPool.put({
payloadId: p.payloadId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
updatedAt: p.updatedAt
})
}
}
const localAll = await db.personPool.toArray()
for (const local of localAll) {
if (!serverMap.has(local.payloadId)) {
const pendingCreate = await db.userSyncQueue
.where({ payloadId: local.payloadId, action: 'create' })
.first()
if (!pendingCreate) {
await db.personPool.delete(local.payloadId)
}
}
}
} catch (err) {
console.warn('Person pool pull failed:', err)
}
}
+96 -5
View File
@@ -2,6 +2,12 @@ import { db, type SyncQueueItem } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { apiFetch } from './api.js'
import { getLogbookAccess } from './logbookAccess.js'
import {
clearSyncConflict,
reportSyncConflict,
type SyncConflict
} from './syncConflicts.js'
import { syncPersonPool } from './personPoolSync.js'
const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>()
@@ -56,6 +62,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
return !!(await db.photos.get(item.payloadId))
case 'gpsTrack':
return !!(await db.gpsTracks.get(item.payloadId))
case 'logbookCrew':
return !!(await db.logbookCrewSelections.get(item.logbookId))
default:
return false
}
@@ -177,10 +185,19 @@ async function pushChanges(logbookId: string): Promise<boolean> {
const queueItem = pending[i]
if (!queueItem) continue
if (res.status === 'success' || res.status === 'conflict') {
if (res.status === 'success') {
if (queueItem.id !== undefined) {
await db.syncQueue.delete(queueItem.id)
}
clearSyncConflict(logbookId, res.payloadId ?? queueItem.payloadId, queueItem.type)
} else if (res.status === 'conflict') {
reportSyncConflict({
logbookId,
payloadId: res.payloadId ?? queueItem.payloadId,
type: queueItem.type,
reason: typeof res.reason === 'string' ? res.reason : 'Server version is newer',
queueItemId: queueItem.id
})
} else {
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
}
@@ -210,6 +227,7 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
type PulledServerPayload = {
yacht?: { updatedAt: string } | null
deviation?: { updatedAt: string } | null
logbookCrewSelection?: { updatedAt: string } | null
crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }>
@@ -227,6 +245,9 @@ async function pruneAcknowledgedQueueItems(
const serverTimes = new Map<string, string>()
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
if (server.logbookCrewSelection) {
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.updatedAt)
}
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
@@ -243,7 +264,12 @@ async function pruneAcknowledgedQueueItems(
continue
}
const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
const key =
item.type === 'yacht'
? 'yacht:' + logbookId
: item.type === 'logbookCrew'
? 'logbookCrew:' + logbookId
: `${item.type}:${item.payloadId}`
const serverUpdatedAt = serverTimes.get(key)
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
if (item.id !== undefined) staleIds.push(item.id)
@@ -269,8 +295,17 @@ async function pullChanges(logbookId: string): Promise<boolean> {
return false
}
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } =
await response.json()
const serverSnapshot: PulledServerPayload = {
yacht,
deviation,
logbookCrewSelection,
crews,
entries,
photos,
gpsTracks
}
// 1. Sync Yacht Payload
if (yacht) {
@@ -300,7 +335,21 @@ async function pullChanges(logbookId: string): Promise<boolean> {
}
}
// 3. Sync Crew List Payloads
// 2b. Sync Logbook Crew Selection
if (logbookCrewSelection) {
const local = await db.logbookCrewSelections.get(logbookId)
if (!local || isNewer(logbookCrewSelection.updatedAt, local.updatedAt)) {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: logbookCrewSelection.encryptedData,
iv: logbookCrewSelection.iv,
tag: logbookCrewSelection.tag,
updatedAt: logbookCrewSelection.updatedAt
})
}
}
// 3. Sync Crew List Payloads (legacy)
const serverCrewMap = new Map<string, any>()
if (crews && Array.isArray(crews)) {
for (const c of crews) {
@@ -476,6 +525,8 @@ export async function syncAllLogbooks(): Promise<void> {
syncAllInFlight++
recomputeSyncingState()
try {
await syncPersonPool()
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
const logbooks = await db.logbooks.toArray()
@@ -525,3 +576,43 @@ export function stopBackgroundSync() {
syncIntervalId = null
}
}
/** Accept server version: pull latest and drop the conflicting queue item. */
export async function resolveSyncConflictUseServer(conflict: SyncConflict): Promise<void> {
if (conflict.queueItemId !== undefined) {
await db.syncQueue.delete(conflict.queueItemId)
} else {
const pending = await db.syncQueue
.where({ logbookId: conflict.logbookId })
.filter(
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
)
.toArray()
const ids = pending.map((p) => p.id).filter((id): id is number => id !== undefined)
if (ids.length > 0) await db.syncQueue.bulkDelete(ids)
}
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
await pullChanges(conflict.logbookId)
}
/** Keep local version: bump queue timestamp and retry push. */
export async function resolveSyncConflictKeepLocal(conflict: SyncConflict): Promise<void> {
const bump = new Date(Date.now() + 1000).toISOString()
if (conflict.queueItemId !== undefined) {
await db.syncQueue.update(conflict.queueItemId, { updatedAt: bump })
} else {
const pending = await db.syncQueue
.where({ logbookId: conflict.logbookId })
.filter(
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
)
.toArray()
for (const item of pending) {
if (item.id !== undefined) {
await db.syncQueue.update(item.id, { updatedAt: bump })
}
}
}
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
await flushPushQueue(conflict.logbookId)
}
+48
View File
@@ -0,0 +1,48 @@
export interface SyncConflict {
logbookId: string
payloadId: string
type: string
reason: string
queueItemId?: number
detectedAt: string
}
const conflicts = new Map<string, SyncConflict>()
const listeners = new Set<() => void>()
function conflictKey(logbookId: string, payloadId: string, type: string): string {
return `${logbookId}:${type}:${payloadId}`
}
export function getSyncConflicts(logbookId?: string): SyncConflict[] {
const all = Array.from(conflicts.values())
if (!logbookId) return all
return all.filter((c) => c.logbookId === logbookId)
}
export function hasSyncConflicts(logbookId?: string): boolean {
return getSyncConflicts(logbookId).length > 0
}
export function reportSyncConflict(conflict: Omit<SyncConflict, 'detectedAt'>): void {
const key = conflictKey(conflict.logbookId, conflict.payloadId, conflict.type)
conflicts.set(key, { ...conflict, detectedAt: new Date().toISOString() })
listeners.forEach((l) => l())
}
export function clearSyncConflict(logbookId: string, payloadId: string, type: string): void {
conflicts.delete(conflictKey(logbookId, payloadId, type))
listeners.forEach((l) => l())
}
export function clearSyncConflictsForLogbook(logbookId: string): void {
for (const key of conflicts.keys()) {
if (key.startsWith(`${logbookId}:`)) conflicts.delete(key)
}
listeners.forEach((l) => l())
}
export function subscribeSyncConflicts(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
+61
View File
@@ -0,0 +1,61 @@
export type PersonRole = 'skipper' | 'crew'
export interface PersonData {
name: string
address: string
birthDate: string
phone: string
nationality: string
passportNumber: string
bloodType: string
allergies: string
diseases: string
role: PersonRole
photo?: string | null
}
export interface PersonSnapshot {
id: string
role: PersonRole
name: string
address: string
birthDate: string
phone: string
nationality: string
passportNumber: string
bloodType: string
allergies: string
diseases: string
photo?: string | null
}
export interface LogbookCrewSelectionData {
activeSkipperId: string | null
activeCrewIds: string[]
/** Denormalized for collaborators / offline display without account pool access */
snapshotsById: Record<string, PersonSnapshot>
}
export interface EntryCrewFields {
selectedSkipperId: string | null
selectedCrewIds: string[]
crewSnapshotsById: Record<string, PersonSnapshot>
}
export const MAX_POOL_CREW_MEMBERS = 12
export function emptyLogbookCrewSelection(): LogbookCrewSelectionData {
return {
activeSkipperId: null,
activeCrewIds: [],
snapshotsById: {}
}
}
export function emptyEntryCrewFields(): EntryCrewFields {
return {
selectedSkipperId: null,
selectedCrewIds: [],
crewSnapshotsById: {}
}
}
+10
View File
@@ -0,0 +1,10 @@
/** Map unknown errors to a user-facing message (i18n key or fallback). */
export function getErrorMessage(err: unknown, fallback: string): string {
if (err instanceof Error && err.message.trim()) {
return err.message
}
if (typeof err === 'string' && err.trim()) {
return err
}
return fallback
}
+62 -2
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { normalizeGpsCoordinates, parseGpsCoordinate } from './geolocation.js'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getCurrentPosition,
normalizeGpsCoordinates,
parseGpsCoordinate,
queryGeolocationPermission
} from './geolocation.js'
describe('geolocation helpers', () => {
it('parses coordinates with comma decimals', () => {
@@ -17,4 +22,59 @@ describe('geolocation helpers', () => {
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
expect(normalizeGpsCoordinates('0', '181')).toBeNull()
})
it('reports unsupported when geolocation API is missing', async () => {
vi.stubGlobal('navigator', { geolocation: undefined })
await expect(getCurrentPosition({ timeoutMs: 100 })).rejects.toThrow('geolocation_unavailable')
})
it('rejects when the browser never calls back (watchdog)', async () => {
vi.useFakeTimers()
vi.stubGlobal('navigator', {
geolocation: {
getCurrentPosition: () => {
// Simulate a hung desktop location service.
}
}
})
const promise = getCurrentPosition({ timeoutMs: 50, enableHighAccuracy: false })
const assertion = expect(promise).rejects.toThrow('geolocation_timeout')
await vi.advanceTimersByTimeAsync(900)
await assertion
vi.useRealTimers()
})
it('resolves coordinates from getCurrentPosition', async () => {
vi.stubGlobal('navigator', {
geolocation: {
getCurrentPosition: (success: PositionCallback) => {
success({
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 }
} as GeolocationPosition)
}
}
})
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
lat: '59.910000',
lng: '10.750000',
speedKn: 4.9
})
})
it('reads permission state when supported', async () => {
vi.stubGlobal('navigator', {
geolocation: {},
permissions: {
query: vi.fn().mockResolvedValue({ state: 'denied' })
}
})
await expect(queryGeolocationPermission()).resolves.toBe('denied')
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
+72 -11
View File
@@ -1,5 +1,8 @@
const MPS_TO_KNOTS = 1.9438444924406
/** Extra ms beyond the native timeout so hung browsers still reject. */
const TIMEOUT_GRACE_MS = 750
export interface GeoCoordinates {
lat: string
lng: string
@@ -7,6 +10,15 @@ export interface GeoCoordinates {
speedKn: number | null
}
export type GeolocationPermissionState = PermissionState | 'unsupported'
export interface GetPositionOptions {
timeoutMs?: number
/** Manual fixes may use high accuracy; background auto-position should not. */
enableHighAccuracy?: boolean
maximumAge?: number
}
export function parseGpsCoordinate(value: string): number | null {
const trimmed = value.trim()
if (!trimmed) return null
@@ -26,26 +38,75 @@ export function normalizeGpsCoordinates(
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
}
export function getCurrentPosition(timeoutMs = 15000): Promise<GeoCoordinates> {
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
if (!navigator.geolocation) return 'unsupported'
if (!navigator.permissions?.query) return 'prompt'
try {
const status = await navigator.permissions.query({ name: 'geolocation' })
return status.state
} catch {
return 'prompt'
}
}
function normalizeGetPositionOptions(
options: number | GetPositionOptions | undefined
): Required<GetPositionOptions> {
const opts = typeof options === 'number' ? { timeoutMs: options } : (options ?? {})
const enableHighAccuracy = opts.enableHighAccuracy ?? true
return {
timeoutMs: opts.timeoutMs ?? 15000,
enableHighAccuracy,
maximumAge: opts.maximumAge ?? (enableHighAccuracy ? 0 : 120_000)
}
}
function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinates {
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null
return {
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6),
speedKn
}
}
/**
* Resolves with coordinates or rejects. Uses both the native timeout and an outer
* watchdog so desktop browsers without GPS cannot hang indefinitely.
*/
export function getCurrentPosition(
options?: number | GetPositionOptions
): Promise<GeoCoordinates> {
const { timeoutMs, enableHighAccuracy, maximumAge } = normalizeGetPositionOptions(options)
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('geolocation_unavailable'))
return
}
let settled = false
const finish = (fn: () => void) => {
if (settled) return
settled = true
window.clearTimeout(watchdog)
fn()
}
const watchdog = window.setTimeout(() => {
finish(() => reject(new Error('geolocation_timeout')))
}, timeoutMs + TIMEOUT_GRACE_MS)
navigator.geolocation.getCurrentPosition(
(pos) => {
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null
resolve({
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6),
speedKn
})
finish(() => resolve(positionFromGeolocationPosition(pos)))
},
(err) => reject(err),
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
(err) => {
finish(() => reject(err))
},
{ enableHighAccuracy, timeout: timeoutMs, maximumAge }
)
})
}
+8
View File
@@ -2,6 +2,7 @@ import {
normalizeCourseAngleString,
normalizeWindDirectionString
} from './courseAngle.js'
import type { EntryCrewFields } from '../types/person.js'
export interface LogEventPayload {
time: string
@@ -150,6 +151,7 @@ export interface LogEntryPayloadInput {
trackSpeedAvgKn?: number
motorHours?: number
events: LogEventPayload[]
entryCrew?: EntryCrewFields
}
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
@@ -177,5 +179,11 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
}
}
if (input.entryCrew) {
payload.selectedSkipperId = input.entryCrew.selectedSkipperId
payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds]
payload.crewSnapshotsById = { ...input.entryCrew.crewSnapshotsById }
}
return payload
}
+78
View File
@@ -0,0 +1,78 @@
import type { LogbookCrewSelectionData, PersonData, PersonSnapshot } from '../types/person.js'
export function personToSnapshot(id: string, data: PersonData): PersonSnapshot {
return {
id,
role: data.role,
name: data.name,
address: data.address,
birthDate: data.birthDate,
phone: data.phone,
nationality: data.nationality,
passportNumber: data.passportNumber,
bloodType: data.bloodType,
allergies: data.allergies,
diseases: data.diseases,
photo: data.photo ?? null
}
}
export function buildSnapshotsForSelection(
activeSkipperId: string | null,
activeCrewIds: string[],
pool: Map<string, PersonData>
): Record<string, PersonSnapshot> {
const snapshotsById: Record<string, PersonSnapshot> = {}
if (activeSkipperId) {
const skipper = pool.get(activeSkipperId)
if (skipper) snapshotsById[activeSkipperId] = personToSnapshot(activeSkipperId, skipper)
}
for (const crewId of activeCrewIds) {
const crew = pool.get(crewId)
if (crew) snapshotsById[crewId] = personToSnapshot(crewId, crew)
}
return snapshotsById
}
export function buildLogbookCrewSelection(
activeSkipperId: string | null,
activeCrewIds: string[],
pool: Map<string, PersonData>
): LogbookCrewSelectionData {
return {
activeSkipperId,
activeCrewIds: [...activeCrewIds],
snapshotsById: buildSnapshotsForSelection(activeSkipperId, activeCrewIds, pool)
}
}
export function entryCrewFromLogbookSelection(
selection: LogbookCrewSelectionData
): {
selectedSkipperId: string | null
selectedCrewIds: string[]
crewSnapshotsById: Record<string, PersonSnapshot>
} {
return {
selectedSkipperId: selection.activeSkipperId,
selectedCrewIds: [...selection.activeCrewIds],
crewSnapshotsById: { ...selection.snapshotsById }
}
}
export function entryCrewFromPreviousEntry(entry: Record<string, unknown>): {
selectedSkipperId: string | null
selectedCrewIds: string[]
crewSnapshotsById: Record<string, PersonSnapshot>
} {
const selectedSkipperId =
typeof entry.selectedSkipperId === 'string' ? entry.selectedSkipperId : null
const selectedCrewIds = Array.isArray(entry.selectedCrewIds)
? entry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
: []
const crewSnapshotsById =
entry.crewSnapshotsById && typeof entry.crewSnapshotsById === 'object'
? (entry.crewSnapshotsById as Record<string, PersonSnapshot>)
: {}
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
}
+39
View File
@@ -0,0 +1,39 @@
/** Resize and compress an image file to a JPEG data URL (max 800×600). */
export function resizeImageFile(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
const MAX_WIDTH = 800
const MAX_HEIGHT = 600
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
resolve(canvas.toDataURL('image/jpeg', 0.7))
} catch (err) {
reject(err)
}
}
img.onerror = () => reject(new Error('Invalid image file'))
img.src = event.target?.result as string
}
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
}
+17
View File
@@ -0,0 +1,17 @@
/** Request durable IndexedDB storage (important on iOS Safari). */
export async function requestPersistentStorage(): Promise<{
persisted: boolean
supported: boolean
}> {
if (!('storage' in navigator) || !navigator.storage.persist) {
return { persisted: false, supported: false }
}
try {
const persisted = await navigator.storage.persisted()
if (persisted) return { persisted: true, supported: true }
const granted = await navigator.storage.persist()
return { persisted: granted, supported: true }
} catch {
return { persisted: false, supported: true }
}
}
+7 -5
View File
@@ -4,13 +4,14 @@ services:
container_name: daagbox-prod-db
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: daagbox
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:-daagbox}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d daagbox"]
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
# Not published to the host — reachable only on the Compose network (do not add ports: here)
interval: 5s
timeout: 5s
retries: 5
@@ -23,9 +24,10 @@ services:
restart: always
environment:
PORT: 5000
DATABASE_URL: "postgresql://postgres:postgres@db:5432/daagbox?schema=public"
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox}?schema=public"
RP_ID: ${RP_ID:-localhost}
ORIGIN: ${ORIGIN:-http://localhost}
TRUST_PROXY: ${TRUST_PROXY:-1}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
+60
View File
@@ -0,0 +1,60 @@
# Deployment: Nginx Proxy Manager & Security (Sprint 1)
Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf den App-Stack (`172.16.10.110`).
## NPM Proxy Host
| Einstellung | Wert |
|-------------|------|
| Domain | `kapteins-daagbok.eu` |
| Scheme | `https` |
| Forward Hostname / IP | `172.16.10.110` (oder Container-Port auf dem Host) |
| Forward Port | `80` (Frontend-Nginx) |
| Websockets | an, falls genutzt |
| Block Common Exploits | an |
| SSL | Let's Encrypt o. ä. |
### Custom Nginx (Advanced) — empfohlen
NPM setzt `X-Forwarded-*` in der Regel automatisch. Falls nicht, im Proxy-Host unter **Advanced**:
```nginx
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
```
## Backend-Umgebung (`.env` auf dem Server)
```env
ORIGIN=https://kapteins-daagbok.eu
RP_ID=kapteins-daagbok.eu
SESSION_SECRET=<min. 32 Zeichen, openssl rand -base64 48>
TRUST_PROXY=172.16.10.10
# oder TRUST_PROXY=1 für genau einen Proxy-Hop
```
`ORIGIN` muss **exakt** der Browser-URL entsprechen (ohne trailing slash).
## Security-Header
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden.
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf`](../../client/nginx.conf) (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP inkl. Plausible).
### Plausible Analytics
Script-Host: `https://plausible.elpatron.me` — in CSP als `script-src` und `connect-src` erlaubt. Gemessene Site: `data-domain="kapteins-daagbok.eu"`.
Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz.
## Nach Deploy prüfen
1. https://kapteins-daagbok.eu/api/health — `status: ok`
2. Passkey Login / Registrierung
3. DevTools → Application → Cookie `daagbok_session`: `Secure`, `HttpOnly`, `SameSite=Lax`
4. Response-Header auf `index.html`: CSP, `X-Frame-Options`
5. Zwei Geräte hinter NAT: unabhängige Rate-Limits (nicht alle als eine IP)
## Docker Compose
Keine Default-Passwörter in Produktion: starkes `POSTGRES_PASSWORD` (siehe [postgres-password.md](postgres-password.md)) und `SESSION_SECRET` in `.env` setzen (siehe [`.env.example`](../../.env.example)).
+42
View File
@@ -0,0 +1,42 @@
# PostgreSQL absichern (Produktion)
## Ist-Zustand
- Die Datenbank läuft im Container `daagbox-prod-db` **ohne** Host-Port (nur Docker-Netz `db:5432`) — gut.
- Das Passwort wird beim **ersten** Start des Volumes gesetzt; ein späteres Ändern nur von `POSTGRES_PASSWORD` in `.env` **ändert nicht** das laufende Passwort.
- Nach Sprint 1 war auf dem Server noch das Legacy-Passwort `postgres` möglich → per Skript rotieren.
## Empfohlene Schritte
1. **Backup/Snapshot** (hast du laut Vorgabe).
2. Auf dem Server im Repo:
```bash
cd /opt/kapteins-daagbok
git pull
chmod +x scripts/rotate-postgres-password.sh
./scripts/rotate-postgres-password.sh
```
3. Inhalt von `.postgres-credentials.<timestamp>` in den Passwort-Manager übernehmen, Datei auf dem Server löschen:
```bash
shred -u .postgres-credentials.* # oder rm nach manuellem Notieren
```
### Optional: eigener App-Benutzer (statt `postgres` für Prisma)
```bash
./scripts/rotate-postgres-password.sh --app-user daagbok
```
- **`daagbok`**: Login für Backend/Prisma (kein Superuser)
- **`postgres`**: nur noch Admin (Passwort in `POSTGRES_ADMIN_PASSWORD` in `.env`)
## Lokale Entwicklung
`scripts/start-dev.sh` nutzt weiterhin `postgres/postgres` auf localhost — nur für Dev. Produktion nie dieses Passwort wiederverwenden.
## Verifikation
```bash
docker exec daagbox-prod-backend wget -qO- http://127.0.0.1:5000/api/health
curl -sf https://kapteins-daagbok.eu/api/health
```
+42
View File
@@ -0,0 +1,42 @@
# Pre-Deploy-Checks (ohne CI)
Vor jedem Update auf **https://kapteins-daagbok.eu/** lokal ausführen:
```bash
npm run check
```
Das Skript [`scripts/predeploy-check.sh`](../../scripts/predeploy-check.sh) führt aus:
1. i18n-Key-Validierung (`validate:i18n`)
2. Client: `test``build` (TypeScript via `tsc -b`)
3. Server: `test``build`
## Einzelbefehle (Repo-Root)
| Befehl | Inhalt |
|--------|--------|
| `npm run lint` | ESLint (Client) — optional, noch nicht Teil von `check` |
| `npm run test` | Vitest Client + Server |
| `npm run build` | Production-Build beider Pakete |
| `npm run predeploy` | Alias für `npm run check` |
## Server-Tests
Smoke-Tests in `server/src/api.smoke.test.ts` — keine echte Datenbank (Prisma gemockt). Prüfen u. a. Health, 401 ohne Session, öffentliche Collaboration-Validierung.
```bash
cd server && npm test
```
## Nach erfolgreichem Check
[`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy).
```bash
./scripts/update-prod.sh
```
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh`
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
+6 -1
View File
@@ -7,6 +7,11 @@
"translate:flyer": "node scripts/translate-flyer.mjs",
"validate:i18n": "node scripts/validate-i18n-keys.mjs",
"generate:flyer": "node scripts/generate-beta-flyer.mjs",
"generate:flyer:all": "node scripts/generate-beta-flyer.mjs --all"
"generate:flyer:all": "node scripts/generate-beta-flyer.mjs --all",
"lint": "npm run lint --prefix client",
"test": "npm run test --prefix client && npm run test --prefix server",
"build": "npm run build --prefix client && npm run build --prefix server",
"check": "bash scripts/predeploy-check.sh",
"predeploy": "npm run check"
}
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Local quality gates before deploying to kapteins-daagbok.eu (no external CI).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
echo "=================================================="
echo " Kapteins Daagbok — pre-deploy checks"
echo "=================================================="
run() {
echo ""
echo "==> $*"
"$@"
}
run npm run validate:i18n
pushd client >/dev/null
if [ ! -d node_modules ]; then
run npm ci
fi
# Lint: run separately with `npm run lint` (client ESLint; cleanup tracked separately)
run npm run test
run npm run build
popd >/dev/null
pushd server >/dev/null
if [ ! -d node_modules ]; then
run npm ci
fi
run npm run test
run npm run build
popd >/dev/null
echo ""
echo "=================================================="
echo " All pre-deploy checks passed."
echo "=================================================="
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# Rotate PostgreSQL password on a running Docker Compose stack (existing volume safe).
#
# The Postgres image only applies POSTGRES_PASSWORD on first init; for existing data
# you must ALTER USER inside the running database, then update .env and restart backend.
#
# Usage (on server in repo root, with backup/snapshot taken):
# ./scripts/rotate-postgres-password.sh
# ./scripts/rotate-postgres-password.sh --app-user daagbok # optional: dedicated app role
#
# Writes the new credentials once to .postgres-credentials.<timestamp> (mode 600).
set -euo pipefail
ENV_FILE="${ENV_FILE:-.env}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
DB_CONTAINER="${DB_CONTAINER:-daagbox-prod-db}"
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
CREATE_APP_USER=""
APP_USER_NAME="daagbok"
while [ $# -gt 0 ]; do
case "$1" in
--app-user)
CREATE_APP_USER=1
APP_USER_NAME="${2:-daagbok}"
shift 2
;;
-h|--help)
sed -n '2,12p' "$0"
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
OLD_PASSWORD="${POSTGRES_PASSWORD:-}"
if [ -z "$OLD_PASSWORD" ]; then
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
NEW_PASSWORD="$(openssl rand -hex 24)"
NEW_APP_PASSWORD=""
if [ -n "$CREATE_APP_USER" ]; then
NEW_APP_PASSWORD="$(openssl rand -hex 24)"
fi
BACKUP_ENV="${ENV_FILE}.bak.pg-rotate.$(date +%Y%m%d-%H%M%S)"
cp "$ENV_FILE" "$BACKUP_ENV"
echo "Backed up $ENV_FILE$BACKUP_ENV"
echo "Rotating password for PostgreSQL role: $POSTGRES_USER (database: $POSTGRES_DB)"
# Escape single quotes for SQL string literals
sql_escape() {
printf "%s" "$1" | sed "s/'/''/g"
}
NEW_PW_SQL="$(sql_escape "$NEW_PASSWORD")"
export PGPASSWORD="$OLD_PASSWORD"
if ! docker exec -e PGPASSWORD="$OLD_PASSWORD" "$DB_CONTAINER" \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 \
-c "ALTER USER \"${POSTGRES_USER}\" WITH PASSWORD '${NEW_PW_SQL}';" >/dev/null; then
echo "Error: ALTER USER failed. Is POSTGRES_PASSWORD in .env still correct?" >&2
exit 1
fi
unset PGPASSWORD
TARGET_USER="$POSTGRES_USER"
TARGET_PASSWORD="$NEW_PASSWORD"
if [ -n "$CREATE_APP_USER" ]; then
APP_PW_SQL="$(sql_escape "$NEW_APP_PASSWORD")"
export PGPASSWORD="$NEW_PASSWORD"
docker exec -e PGPASSWORD="$NEW_PASSWORD" "$DB_CONTAINER" psql -U postgres -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${APP_USER_NAME}') THEN
CREATE ROLE ${APP_USER_NAME} LOGIN PASSWORD '${APP_PW_SQL}';
ELSE
ALTER ROLE ${APP_USER_NAME} WITH LOGIN PASSWORD '${APP_PW_SQL}';
END IF;
END
\$\$;
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO ${APP_USER_NAME};
GRANT USAGE, CREATE ON SCHEMA public TO ${APP_USER_NAME};
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${APP_USER_NAME};
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${APP_USER_NAME};
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO ${APP_USER_NAME};
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO ${APP_USER_NAME};
SQL
unset PGPASSWORD
TARGET_USER="$APP_USER_NAME"
TARGET_PASSWORD="$NEW_APP_PASSWORD"
echo "Created/updated application role: $APP_USER_NAME (postgres superuser password also rotated)"
fi
# Update .env without exposing values in process list longer than necessary
python3 - "$ENV_FILE" "$TARGET_USER" "$TARGET_PASSWORD" "$NEW_PASSWORD" "$CREATE_APP_USER" <<'PY'
import re
import sys
from pathlib import Path
path = Path(sys.argv[1])
target_user = sys.argv[2]
target_password = sys.argv[3]
postgres_password = sys.argv[4]
use_app_user = sys.argv[5] == "1"
text = path.read_text(encoding="utf-8")
def set_var(name: str, value: str, content: str) -> str:
pattern = rf"^{re.escape(name)}=.*$"
line = f"{name}={value}"
if re.search(pattern, content, flags=re.M):
return re.sub(pattern, line, content, count=1, flags=re.M)
return content.rstrip() + "\n" + line + "\n"
text = set_var("POSTGRES_USER", target_user, text)
text = set_var("POSTGRES_PASSWORD", target_password, text)
text = set_var("POSTGRES_DB", "daagbox", text) if "POSTGRES_DB=" not in text else text
if use_app_user:
text = set_var("POSTGRES_ADMIN_PASSWORD", postgres_password, text)
path.write_text(text, encoding="utf-8")
PY
CREDS_FILE=".postgres-credentials.$(date +%Y%m%d-%H%M%S)"
umask 077
{
echo "# Generated $(date -Iseconds) — store in password manager, then delete this file."
echo "POSTGRES_USER=$TARGET_USER"
echo "POSTGRES_PASSWORD=$TARGET_PASSWORD"
echo "POSTGRES_DB=$POSTGRES_DB"
if [ -n "$CREATE_APP_USER" ]; then
echo "POSTGRES_ADMIN_USER=postgres"
echo "POSTGRES_ADMIN_PASSWORD=$NEW_PASSWORD"
fi
} > "$CREDS_FILE"
chmod 600 "$CREDS_FILE"
echo "Credentials written to $CREDS_FILE (chmod 600)"
echo "Recreating backend (and db if compose env changed) to pick up DATABASE_URL..."
docker compose -f "$COMPOSE_FILE" up -d --force-recreate backend
echo "Waiting for backend health..."
for _ in $(seq 1 45); do
status="$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null || echo missing)"
if [ "$status" = healthy ]; then
break
fi
sleep 1
done
export PGPASSWORD="$TARGET_PASSWORD"
docker exec -e PGPASSWORD="$TARGET_PASSWORD" "$DB_CONTAINER" \
psql -U "$TARGET_USER" -d "$POSTGRES_DB" -tAc 'SELECT count(*) FROM "User";' >/dev/null
unset PGPASSWORD
if curl -sf http://127.0.0.1/api/health | grep -q '"status":"ok"'; then
echo "OK: /api/health and DB connection verified."
else
echo "Warning: health check failed — see: docker compose logs backend" >&2
exit 1
fi
echo "Done. Remove $CREDS_FILE after saving credentials securely."
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Patch production .env for Sprint 1 docker-compose (POSTGRES_* + TRUST_PROXY).
# Safe: does not overwrite existing keys. Run on the server in /opt/kapteins-daagbok.
set -euo pipefail
ENV_FILE="${1:-.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found"
exit 1
fi
backup="${ENV_FILE}.bak.$(date +%Y%m%d-%H%M%S)"
cp "$ENV_FILE" "$backup"
echo "Backup: $backup"
ensure_var() {
local key="$1"
local value="$2"
if grep -q "^${key}=" "$ENV_FILE"; then
echo " keep ${key} (already set)"
else
echo "${key}=${value}" >> "$ENV_FILE"
echo " add ${key}"
fi
}
echo "Patching $ENV_FILE for Sprint 1..."
# Match running container (docker exec daagbox-prod-db: USER=postgres DB=daagbox)
ensure_var POSTGRES_USER "postgres"
ensure_var POSTGRES_DB "daagbox"
if ! grep -q "^POSTGRES_PASSWORD=" "$ENV_FILE" || grep -q "^POSTGRES_PASSWORD=$" "$ENV_FILE"; then
echo " skip POSTGRES_PASSWORD (set manually or run scripts/rotate-postgres-password.sh)"
else
echo " keep POSTGRES_PASSWORD (already set)"
fi
# NPM on 172.16.10.10 → app on this host
ensure_var TRUST_PROXY "172.16.10.10"
echo "Done. Verify with: docker exec daagbox-prod-db psql -U postgres -d daagbox -c 'SELECT 1'"
+9
View File
@@ -125,6 +125,15 @@ prepare_release() {
prepare_release
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
else
echo "=================================================="
echo " Pre-deploy checks (local)"
echo "=================================================="
"$SCRIPT_DIR/predeploy-check.sh"
fi
echo "=================================================="
echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "=================================================="
+1885 -1
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -5,9 +5,13 @@
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"build": "prisma generate && tsc",
"postinstall": "prisma generate",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
"dev": "prisma generate && tsx watch src/index.ts",
"db:push": "prisma db push",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@prisma/client": "^5.10.2",
@@ -26,8 +30,11 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.11.24",
"@types/supertest": "^6.0.3",
"@types/web-push": "^3.6.4",
"supertest": "^7.1.0",
"tsx": "^4.7.1",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"vitest": "^3.0.9"
}
}
+26
View File
@@ -23,6 +23,7 @@ model User {
pushSubscriptions PushSubscription[]
notificationPrefs UserNotificationPrefs?
appearancePrefs UserAppearancePrefs?
personPool PersonPayload[]
}
model PushSubscription {
@@ -86,6 +87,7 @@ model Logbook {
yachts YachtPayload[]
crews CrewPayload[]
logbookCrewSelection LogbookCrewSelectionPayload?
deviations DeviationPayload[]
entries EntryPayload[]
photos PhotoPayload[]
@@ -148,6 +150,30 @@ model CrewPayload {
@@unique([logbookId, payloadId])
}
model PersonPayload {
id String @id @default(uuid())
userId String
payloadId String
encryptedData String
iv String
tag String
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, payloadId])
@@index([userId])
}
model LogbookCrewSelectionPayload {
id String @id @default(uuid())
logbookId String @unique
encryptedData String
iv String
tag String
updatedAt DateTime @updatedAt
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
}
model DeviationPayload {
id String @id @default(uuid())
logbookId String @unique
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeAll } from 'vitest'
import request from 'supertest'
vi.mock('./db.js', () => ({
prisma: {
$queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1 }])
}
}))
const { createApp } = await import('./app.js')
describe('API smoke', () => {
const app = createApp()
beforeAll(() => {
process.env.SESSION_SECRET =
process.env.SESSION_SECRET ?? 'test-session-secret-minimum-32-characters-long'
process.env.ORIGIN = process.env.ORIGIN ?? 'http://localhost:5173'
process.env.RP_ID = process.env.RP_ID ?? 'localhost'
})
it('GET /api/health returns ok when database is reachable', async () => {
const res = await request(app).get('/api/health')
expect(res.status).toBe(200)
expect(res.body.status).toBe('ok')
expect(res.body.database).toBe('connected')
})
it('GET /api/logbooks requires session', async () => {
const res = await request(app).get('/api/logbooks')
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
it('POST /api/sync/push requires session', async () => {
const res = await request(app)
.post('/api/sync/push')
.send({ items: [] })
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
it('GET /api/collaboration/invite-details requires token query', async () => {
const res = await request(app).get('/api/collaboration/invite-details')
expect(res.status).toBe(400)
expect(res.body.error).toMatch(/Token/i)
})
})
+103
View File
@@ -0,0 +1,103 @@
import express from 'express'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
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 signRouter from './routes/sign.js'
import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js'
import feedbackRouter from './routes/feedback.js'
import { prisma } from './db.js'
import { buildCorsOptions } from './cors.js'
/** Behind Nginx Proxy Manager. See docs/deployment/npm-security.md */
function configureTrustProxy(app: express.Express): void {
const raw = process.env.TRUST_PROXY?.trim()
if (raw === '1' || raw === 'true') {
app.set('trust proxy', 1)
return
}
if (raw) {
app.set('trust proxy', raw)
return
}
if (process.env.NODE_ENV === 'production') {
app.set('trust proxy', 1)
}
}
export function createApp(): express.Express {
const app = express()
configureTrustProxy(app)
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
})
)
app.use(cors(buildCorsOptions()))
app.use(cookieParser())
app.use(express.json({ limit: '50mb' }))
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false
})
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false
})
const publicCollaborationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false
})
app.use('/api/auth', authLimiter)
app.use('/api/collaboration/invite-details', publicCollaborationLimiter)
app.use('/api/collaboration/share-pull', publicCollaborationLimiter)
app.use('/api', apiLimiter)
app.use('/api/auth', authRouter)
app.use('/api/logbooks', logbooksRouter)
app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter)
app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter)
app.use('/api/feedback', feedbackRouter)
app.get('/api/health', async (_req, res) => {
try {
await prisma.$queryRaw`SELECT 1`
res.json({
status: 'ok',
database: 'connected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
} catch {
res.status(500).json({
status: 'error',
database: 'disconnected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
}
})
return app
}
+2 -74
View File
@@ -1,88 +1,16 @@
import express from 'express'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import dotenv from 'dotenv'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
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 signRouter from './routes/sign.js'
import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js'
import feedbackRouter from './routes/feedback.js'
import { prisma } from './db.js'
import { buildCorsOptions } from './cors.js'
import { createApp } from './app.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
dotenv.config({ path: resolve(__dirname, '../../.env') })
dotenv.config({ path: resolve(__dirname, '../.env') })
const app = express()
const app = createApp()
const PORT = process.env.PORT || 5000
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
})
)
app.use(cors(buildCorsOptions()))
app.use(cookieParser())
// Encrypted sync payloads (photos, GPS tracks) can be large — align with nginx client_max_body_size
app.use(express.json({ limit: '50mb' }))
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false
})
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false
})
app.use('/api/auth', authLimiter)
app.use('/api', apiLimiter)
// Mount routes
app.use('/api/auth', authRouter)
app.use('/api/logbooks', logbooksRouter)
app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter)
app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter)
app.use('/api/feedback', feedbackRouter)
// Health check endpoint
app.get('/api/health', async (req, res) => {
try {
await prisma.$queryRaw`SELECT 1`
res.json({
status: 'ok',
database: 'connected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
} catch {
res.status(500).json({
status: 'error',
database: 'disconnected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
}
})
app.listen(PORT, () => {
console.log(`[server] Server running on http://localhost:${PORT}`)
})
+6 -2
View File
@@ -1,4 +1,4 @@
import rateLimit from 'express-rate-limit'
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
import type { AuthedRequest } from './auth.js'
const MIN_SUBMIT_MS = 2_000
@@ -69,7 +69,11 @@ export const feedbackLimiter = rateLimit({
max: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => (req as AuthedRequest).userId ?? req.ip ?? 'unknown',
keyGenerator: (req) => {
const authed = req as AuthedRequest
if (authed.userId) return authed.userId
return ipKeyGenerator(req.ip ?? 'unknown')
},
handler: (_req, res) => {
res.status(429).json({
error: 'Too many feedback submissions. Please try again later.',
+126 -53
View File
@@ -14,6 +14,8 @@ import {
setSessionCookie,
setSessionTokenCookie
} from '../session.js'
import { ChallengeMap, ChallengeSet } from '../utils/challengeStore.js'
import { sendInternalError } from '../utils/httpErrors.js'
const router = Router()
@@ -21,10 +23,10 @@ const rpName = 'Kapteins Daagbok'
const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173'
const registrationChallenges = new Map<string, string>()
const registrationChallenges = new ChallengeMap()
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */
const addCredentialChallenges = new Map<string, string>()
const activeChallenges = new Set<string>()
const addCredentialChallenges = new ChallengeSet<string>()
const activeChallenges = new ChallengeSet()
function previewCredentialId(credentialId: string): string {
if (credentialId.length <= 16) return credentialId
@@ -76,7 +78,7 @@ router.post('/register-options', async (req, res) => {
})
if (existingUser) {
return res.status(400).json({ error: 'User already exists' })
return res.status(400).json({ error: 'Could not start registration' })
}
const userID = Buffer.from(username, 'utf8').toString('base64url')
@@ -98,9 +100,8 @@ router.post('/register-options', async (req, res) => {
registrationChallenges.set(username, options.challenge)
return res.json(options)
} catch (error: any) {
console.error('Error generating registration options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/register-options')
}
})
@@ -163,9 +164,8 @@ router.post('/register-verify', async (req, res) => {
setSessionCookie(res, user.id, true)
return res.json({ verified: true, userId: user.id })
} catch (error: any) {
console.error('Error verifying registration response:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/register-verify')
}
})
@@ -197,9 +197,8 @@ router.post('/login-options', async (req, res) => {
activeChallenges.add(options.challenge)
return res.json(options)
} catch (error: any) {
console.error('Error generating authentication options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/login-options')
}
})
@@ -260,9 +259,8 @@ router.post('/login-verify', async (req, res) => {
encryptedMasterKeyRecIv: user.encryptedMasterKeyRecIv,
encryptedMasterKeyRecTag: user.encryptedMasterKeyRecTag
})
} catch (error: any) {
console.error('Error verifying authentication response:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/login-verify')
}
})
@@ -305,9 +303,8 @@ router.post('/reauth-options', requireUser, async (req: any, res) => {
activeChallenges.add(options.challenge)
return res.json(options)
} catch (error: any) {
console.error('Error generating reauth options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/reauth-options')
}
})
@@ -362,9 +359,8 @@ router.post('/reauth-verify', requireUser, async (req: any, res) => {
}
return res.json({ verified: true })
} catch (error: any) {
console.error('Error verifying reauth:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/reauth-verify')
}
})
@@ -384,9 +380,8 @@ router.delete('/delete-account', requireReauth, async (req: any, res) => {
clearSessionCookie(res)
return res.json({ success: true })
} catch (error: any) {
console.error('Error deleting account:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/delete-account')
}
})
@@ -415,9 +410,8 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => {
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error enrolling PRF key:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/enroll-prf')
}
})
@@ -446,9 +440,8 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error rotating recovery key:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/rotate-recovery')
}
})
@@ -468,9 +461,7 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
return res.json({ ...DEFAULT_APPEARANCE_PREFS })
}
console.error('Error reading appearance prefs:', error)
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
return sendInternalError(res, error, 'auth/appearance-prefs-get')
}
})
@@ -509,9 +500,96 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.'
})
}
console.error('Error updating appearance prefs:', error)
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
return sendInternalError(res, error, 'auth/appearance-prefs-put')
}
})
router.get('/person-pool', requireUser, async (req: any, res) => {
try {
const { hasCrewPoolPrismaModels, isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } =
await import('../utils/crewPoolSchema.js')
if (!hasCrewPoolPrismaModels()) {
console.warn('Person pool Prisma models missing — run prisma generate')
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT, persons: [] })
}
const persons = await prisma.personPayload.findMany({
where: { userId: req.userId }
})
return res.json({ persons })
} catch (error: unknown) {
const { isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
if (isMissingPrismaTable(error)) {
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT, persons: [] })
}
return sendInternalError(res, error, 'auth/person-pool-get')
}
})
router.post('/person-pool/push', requireUser, async (req: any, res) => {
try {
const { hasCrewPoolPrismaModels, isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } =
await import('../utils/crewPoolSchema.js')
if (!hasCrewPoolPrismaModels()) {
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT })
}
const { items } = req.body
if (!items || !Array.isArray(items)) {
return res.status(400).json({ error: 'items array is required' })
}
const results: Array<{ payloadId: string; status: string; error?: string; reason?: string }> = []
for (const item of items) {
const { action, payloadId, data, updatedAt } = item
const itemUpdatedAt = new Date(updatedAt)
try {
if (action === 'delete') {
await prisma.personPayload.deleteMany({
where: { userId: req.userId, payloadId }
})
results.push({ payloadId, status: 'success' })
continue
}
const parsed = JSON.parse(data)
const encryptedData = parsed.encryptedData || parsed.ciphertext
const { iv, tag } = parsed
const existing = await prisma.personPayload.findUnique({
where: { userId_payloadId: { userId: req.userId, payloadId } }
})
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
await prisma.personPayload.upsert({
where: { userId_payloadId: { userId: req.userId, payloadId } },
create: {
userId: req.userId,
payloadId,
encryptedData,
iv,
tag,
updatedAt: itemUpdatedAt
},
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
results.push({ payloadId, status: 'success' })
} catch (err: any) {
results.push({ payloadId, status: 'error', error: err.message || 'Operation failed' })
}
}
return res.json({ results })
} catch (error: unknown) {
const { isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
if (isMissingPrismaTable(error)) {
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT })
}
return sendInternalError(res, error, 'auth/person-pool-push')
}
})
@@ -552,9 +630,8 @@ router.get('/profile', requireUser, async (req: any, res) => {
collaborationCount: user._count.collaborations
}
})
} catch (error: any) {
console.error('Error fetching user profile:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/profile')
}
})
@@ -591,12 +668,11 @@ router.post('/add-credential-options', requireReauth, async (req: any, res) => {
excludeCredentials
})
addCredentialChallenges.set(options.challenge, req.userId)
addCredentialChallenges.add(options.challenge, req.userId)
return res.json(options)
} catch (error: any) {
console.error('Error generating add-credential options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/add-credential-options')
}
})
@@ -670,9 +746,8 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
transports: credential.transports
}
})
} catch (error: any) {
console.error('Error verifying add-credential response:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/add-credential-verify')
}
})
@@ -702,9 +777,8 @@ router.patch('/credentials/:id', requireReauth, async (req: any, res) => {
transports: updated.transports
}
})
} catch (error: any) {
console.error('Error updating credential label:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/credentials-patch')
}
})
@@ -733,9 +807,8 @@ router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error deleting credential:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/credentials-delete')
}
})
+21 -24
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js'
import { sendInternalError } from '../utils/httpErrors.js'
const router = Router()
@@ -39,9 +40,8 @@ router.get('/invite-details', async (req: any, res) => {
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' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/invite-details')
}
})
@@ -77,6 +77,9 @@ router.get('/share-pull', async (req: any, res) => {
const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
const logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
where: { logbookId }
})
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
@@ -86,13 +89,13 @@ router.get('/share-pull', async (req: any, res) => {
yacht,
deviation,
crews,
logbookCrewSelection,
entries,
photos,
gpsTracks
})
} catch (error: any) {
console.error('Error in share-pull:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/share-pull')
}
})
@@ -159,9 +162,8 @@ router.post('/accept', requireUser, async (req: any, res) => {
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' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/accept')
}
})
@@ -205,9 +207,8 @@ router.post('/invite', async (req: any, res) => {
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' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/invite')
}
})
@@ -247,9 +248,8 @@ router.get('/collaborators', async (req: any, res) => {
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' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/collaborators')
}
})
@@ -277,9 +277,8 @@ router.delete('/collaborators/:id', async (req: any, res) => {
})
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' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/revoke')
}
})
@@ -317,9 +316,8 @@ router.get('/share-link', async (req: any, res) => {
token: invitation ? invitation.token : null,
expiresAt: invitation ? invitation.expiresAt : null
})
} catch (error: any) {
console.error('Error fetching share link:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/share-link-get')
}
})
@@ -384,9 +382,8 @@ router.post('/share-link', async (req: any, res) => {
return res.json({ success: true })
}
} catch (error: any) {
console.error('Error toggling share link:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/share-link-post')
}
})
+33
View File
@@ -145,6 +145,8 @@ router.post('/push', async (req: any, res) => {
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'gpsTrack') {
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
} else if (type === 'logbookCrew') {
await prisma.logbookCrewSelectionPayload.deleteMany({ where: { logbookId } })
} else {
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
continue
@@ -245,6 +247,29 @@ router.post('/push', async (req: any, res) => {
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'logbookCrew') {
const { hasCrewPoolPrismaModels, CREW_POOL_MIGRATION_HINT } =
await import('../utils/crewPoolSchema.js')
if (!hasCrewPoolPrismaModels()) {
results.push({
payloadId,
status: 'error',
error: CREW_POOL_MIGRATION_HINT
})
continue
}
{
const existing = await prisma.logbookCrewSelectionPayload.findUnique({ where: { logbookId } })
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
await prisma.logbookCrewSelectionPayload.upsert({
where: { logbookId },
create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
}
recordCollaboratorChange(
@@ -310,11 +335,19 @@ router.get('/pull', async (req: any, res) => {
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
let logbookCrewSelection = null
const { hasCrewPoolPrismaModels } = await import('../utils/crewPoolSchema.js')
if (hasCrewPoolPrismaModels()) {
logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
where: { logbookId }
})
}
return res.json({
yacht,
deviation,
crews,
logbookCrewSelection,
entries,
photos,
gpsTracks
+76
View File
@@ -0,0 +1,76 @@
/** WebAuthn challenge TTL — align with sign route. */
export const CHALLENGE_TTL_MS = 5 * 60 * 1000
interface TimedValue<T> {
value: T
expiresAt: number
}
/** Challenge keyed by arbitrary string (e.g. username) with a string payload. */
export class ChallengeMap {
private readonly entries = new Map<string, TimedValue<string>>()
prune(): void {
const now = Date.now()
for (const [key, entry] of this.entries) {
if (entry.expiresAt <= now) this.entries.delete(key)
}
}
set(key: string, value: string): void {
this.prune()
this.entries.set(key, { value, expiresAt: Date.now() + CHALLENGE_TTL_MS })
}
get(key: string): string | undefined {
this.prune()
const entry = this.entries.get(key)
if (!entry) return undefined
if (entry.expiresAt <= Date.now()) {
this.entries.delete(key)
return undefined
}
return entry.value
}
delete(key: string): void {
this.entries.delete(key)
}
}
/** Challenge keyed by challenge id (login/reauth) with optional metadata. */
export class ChallengeSet<T = undefined> {
private readonly entries = new Map<string, TimedValue<T | undefined>>()
prune(): void {
const now = Date.now()
for (const [key, entry] of this.entries) {
if (entry.expiresAt <= now) this.entries.delete(key)
}
}
add(key: string, value?: T): void {
this.prune()
this.entries.set(key, { value, expiresAt: Date.now() + CHALLENGE_TTL_MS })
}
has(key: string): boolean {
this.prune()
const entry = this.entries.get(key)
if (!entry) return false
if (entry.expiresAt <= Date.now()) {
this.entries.delete(key)
return false
}
return true
}
get(key: string): T | undefined {
if (!this.has(key)) return undefined
return this.entries.get(key)?.value as T | undefined
}
delete(key: string): void {
this.entries.delete(key)
}
}
+25
View File
@@ -0,0 +1,25 @@
import { prisma } from '../db.js'
/** Prisma client includes delegates only after `npx prisma generate` on the current schema. */
export function hasCrewPoolPrismaModels(): boolean {
const client = prisma as unknown as {
personPayload?: { findMany: unknown }
logbookCrewSelectionPayload?: { findUnique: unknown }
}
return (
typeof client.personPayload?.findMany === 'function' &&
typeof client.logbookCrewSelectionPayload?.findUnique === 'function'
)
}
export const CREW_POOL_MIGRATION_HINT =
'Crew-Pool-Datenbank fehlt. Im Ordner server ausführen: npx prisma generate && npx prisma db push — danach Server neu starten.'
export function isMissingPrismaTable(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: string }).code === 'P2021'
)
}
+9
View File
@@ -0,0 +1,9 @@
import type { Response } from 'express'
const PUBLIC_ERROR = 'Internal server error'
/** Log full error server-side; never expose stack or Prisma internals to clients. */
export function sendInternalError(res: Response, error: unknown, context: string): Response {
console.error(`[${context}]`, error)
return res.status(500).json({ error: PUBLIC_ERROR })
}
+2 -1
View File
@@ -12,5 +12,6 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
env: {
NODE_ENV: 'test',
SESSION_SECRET: 'test-session-secret-minimum-32-characters-long',
ORIGIN: 'http://localhost:5173',
RP_ID: 'localhost'
}
}
})