Compare commits

...

16 Commits

Author SHA1 Message Date
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
elpatron b9c908169b chore: release v0.1.0.78 2026-06-01 13:44:36 +02:00
elpatron e6bde5c525 fix: shorten travel day badge to "Reisetag x"
Use travel_day_number i18n key in journal cards and live log subtitle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:41:27 +02:00
elpatron eab7b86c0b chore: release v0.1.0.77 2026-06-01 13:28:24 +02:00
elpatron b86789ae4c fix: show profile button while a logbook is open
Extract ProfileHeaderButton and open UserProfilePage from any screen
without leaving the active logbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:28:03 +02:00
45 changed files with 3457 additions and 263 deletions
+14 -1
View File
@@ -6,10 +6,23 @@ DeepLAPIKey=
# Passkey configuration (WebAuthn Relying Party ID and Origin) # 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 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 RP_ID=localhost
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost) # Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173 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) # 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 # 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 | | Health Check | http://localhost:5000/api/health |
| Public Demo | http://localhost:5173/demo | | 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 ```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) ## Docker (produktionsnah)
@@ -237,11 +243,12 @@ Gesamten Stack lokal bauen und starten:
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo 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 ## 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 ```bash
./scripts/update-prod.sh ./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. 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 ## Dokumentation
| Dokument | Inhalt | | 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/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/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 | | [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.77 0.1.0.82
+19 -2
View File
@@ -3,15 +3,32 @@ server {
server_name localhost; server_name localhost;
client_max_body_size 50M; 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 # Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ { location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html; 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 { location = /index.html {
root /usr/share/nginx/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 / { location / {
+139 -2
View File
@@ -1799,6 +1799,42 @@ html.scheme-dark .themed-select-option.is-selected {
gap: 24px; 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 .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 { .logbook-card {
background: var(--app-surface-alt); background: var(--app-surface-alt);
backdrop-filter: var(--app-backdrop); backdrop-filter: var(--app-backdrop);
@@ -1809,18 +1845,61 @@ html.scheme-dark .themed-select-option.is-selected {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 16px; gap: 16px;
cursor: pointer;
position: relative; position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 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); transform: translateY(-2px);
border-color: var(--app-border); border-color: var(--app-border);
box-shadow: var(--app-card-shadow); box-shadow: var(--app-card-shadow);
background: var(--app-surface-hover); 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 { .logbook-card--shared {
border-left: 3px solid #38bdf8; border-left: 3px solid #38bdf8;
} }
@@ -1871,6 +1950,8 @@ html.scheme-dark .themed-select-option.is-selected {
display: flex; display: flex;
align-items: center; align-items: center;
margin-top: -2px; margin-top: -2px;
position: relative;
z-index: 2;
} }
.logbook-card-actions .btn-delete { .logbook-card-actions .btn-delete {
@@ -2130,9 +2211,65 @@ html.scheme-dark .themed-select-option.is-selected {
align-items: start; align-items: start;
} }
.app-bottom-nav {
display: none;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.app-body { .app-body {
grid-template-columns: minmax(0, 1fr); 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);
} }
} }
+99 -14
View File
@@ -44,6 +44,7 @@ import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react' import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx' import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx' import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js' import { cycleAppLanguage } from './utils/i18nLanguages.js'
import { import {
@@ -52,6 +53,8 @@ import {
} from './services/demoLogbook.js' } from './services/demoLogbook.js'
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js' import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.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' const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
@@ -70,6 +73,7 @@ function App() {
const [isSyncing, setIsSyncing] = useState(false) const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false) const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
const [showUserProfile, setShowUserProfile] = useState(false) const [showUserProfile, setShowUserProfile] = useState(false)
const [storagePersistHint, setStoragePersistHint] = useState(false)
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null) const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({ const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
id: activeLogbookId, id: activeLogbookId,
@@ -427,10 +431,19 @@ function App() {
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage) return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
}, [isAuthenticated, openLogbookById]) }, [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 () => { const handleAuthenticated = async () => {
setIsAuthenticated(true) setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN) trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
void ensurePushSubscriptionIfEnabled() void ensurePushSubscriptionIfEnabled()
void requestPersistentStorage()
try { try {
const demo = await seedDemoLogbookIfNeeded() const demo = await seedDemoLogbookIfNeeded()
@@ -557,22 +570,27 @@ function App() {
const isLogbookOwner = const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1 activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
if (showUserProfile) {
return (
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
<UserProfilePage
onBack={() => setShowUserProfile(false)}
onLogout={handleLogout}
/>
</div>
)
}
if (!activeLogbookId) { if (!activeLogbookId) {
return ( return (
<div style={{ display: 'contents' }}> <div style={{ display: 'contents' }}>
{pwaInstallBanner} {pwaInstallBanner}
{showUserProfile ? ( <LogbookDashboard
<UserProfilePage onSelectLogbook={selectLogbook}
onBack={() => setShowUserProfile(false)} onLogout={handleLogout}
onLogout={handleLogout} onOpenProfile={() => setShowUserProfile(true)}
/> />
) : (
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
)}
</div> </div>
) )
} }
@@ -600,7 +618,7 @@ function App() {
<p className="app-subtitle"> <p className="app-subtitle">
{activeAccessRole && activeAccessRole !== 'OWNER' {activeAccessRole && activeAccessRole !== 'OWNER'
? t('dashboard.section_shared_hint') ? t('dashboard.section_shared_hint')
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`} : t('app.tagline')}
</p> </p>
</div> </div>
</div> </div>
@@ -622,6 +640,8 @@ function App() {
<Languages size={18} /> <Languages size={18} />
</button> </button>
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
<DisclaimerHeaderButton /> <DisclaimerHeaderButton />
<FeedbackHeaderButton <FeedbackHeaderButton
@@ -638,10 +658,28 @@ function App() {
</div> </div>
</header> </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 */} {/* Active Workspace */}
<div className="app-body"> <div className="app-body">
{/* Navigation Sidebar */} {/* Navigation Sidebar */}
<aside className="app-sidebar"> <aside className="app-sidebar" aria-label={t('nav.dashboard')}>
<button <button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`} className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => void handleTabChange('logs')} onClick={() => void handleTabChange('logs')}
@@ -738,6 +776,53 @@ function App() {
/> />
)} )}
</main> </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-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> </div>
</div> </div>
+1 -1
View File
@@ -765,7 +765,7 @@ export default function LiveLogView({
<h2>{t('logs.live_title')}</h2> <h2>{t('logs.live_title')}</h2>
{date && ( {date && (
<p className="live-log-subtitle"> <p className="live-log-subtitle">
{t('logs.day_of_travel')} {dayOfTravel} · {new Date(date).toLocaleDateString()} {t('logs.travel_day_number', { number: dayOfTravel })} · {new Date(date).toLocaleDateString()}
</p> </p>
)} )}
</div> </div>
+16 -10
View File
@@ -8,6 +8,7 @@ import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js' import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import LogEntryEditor from './LogEntryEditor.tsx' import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx' import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx' import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
@@ -142,7 +143,7 @@ export default function LogEntriesList({
setEntries(list) setEntries(list)
} catch (err: any) { } catch (err: any) {
console.error('Failed to load log entries:', err) 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 { } finally {
setLoading(false) setLoading(false)
} }
@@ -176,7 +177,7 @@ export default function LogEntriesList({
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED) trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
} catch (err: any) { } catch (err: any) {
console.error('Failed to download CSV:', err) console.error('Failed to download CSV:', err)
setError(err.message || 'Failed to generate CSV export.') setError(getErrorMessage(err, t('errors.export_failed')))
} finally { } finally {
setExporting(false) setExporting(false)
} }
@@ -204,7 +205,7 @@ export default function LogEntriesList({
setError(t('logs.share_unsupported')) setError(t('logs.share_unsupported'))
} else { } else {
console.error('Failed to share CSV:', err) console.error('Failed to share CSV:', err)
setError(err.message || 'Failed to share CSV export.') setError(getErrorMessage(err, t('errors.export_failed')))
} }
} finally { } finally {
setExporting(false) setExporting(false)
@@ -225,7 +226,7 @@ export default function LogEntriesList({
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' }) trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
} catch (err: any) { } catch (err: any) {
console.error('Failed to download PDF:', err) console.error('Failed to download PDF:', err)
setError(err.message || 'Failed to generate PDF export.') setError(getErrorMessage(err, t('errors.export_failed')))
} finally { } finally {
setExporting(false) setExporting(false)
} }
@@ -317,7 +318,7 @@ export default function LogEntriesList({
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) { } catch (err: any) {
console.error('Failed to create entry:', err) console.error('Failed to create entry:', err)
setError(err.message || 'Failed to create new log entry.') setError(getErrorMessage(err, t('errors.save_failed')))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -347,7 +348,7 @@ export default function LogEntriesList({
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) { } catch (err: any) {
console.error('Failed to delete log entry:', err) console.error('Failed to delete log entry:', err)
setError(err.message || 'Failed to delete log entry.') setError(getErrorMessage(err, t('errors.delete_failed')))
} }
} }
} }
@@ -460,8 +461,12 @@ export default function LogEntriesList({
key={item.id} key={item.id}
className="logbook-card glass" className="logbook-card glass"
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined} data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
onClick={() => setSelectedEntryId(item.id)}
> >
<button
type="button"
className="logbook-card-select"
onClick={() => setSelectedEntryId(item.id)}
>
<div className="card-icon"> <div className="card-icon">
<FileText size={24} /> <FileText size={24} />
</div> </div>
@@ -474,7 +479,7 @@ export default function LogEntriesList({
</h3> </h3>
<div className="card-meta"> <div className="card-meta">
<span className="sync-badge synced"> <span className="sync-badge synced">
{t('logs.day_of_travel')} {item.dayOfTravel} {t('logs.travel_day_number', { number: item.dayOfTravel })}
</span> </span>
<EntrySkipperSignBadge status={item.skipperSignStatus} /> <EntrySkipperSignBadge status={item.skipperSignStatus} />
<span className="date-badge"> <span className="date-badge">
@@ -483,6 +488,9 @@ export default function LogEntriesList({
</div> </div>
</div> </div>
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} aria-hidden />
</button>
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}> <button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<Download size={18} /> <Download size={18} />
</button> </button>
@@ -492,8 +500,6 @@ export default function LogEntriesList({
<Trash2 size={18} /> <Trash2 size={18} />
</button> </button>
)} )}
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
</div> </div>
))} ))}
</div> </div>
+14 -2
View File
@@ -5,6 +5,8 @@ import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js' import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js' import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.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 { 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 { 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 PhotoCapture from './PhotoCapture.tsx'
@@ -288,6 +290,14 @@ export default function LogEntryEditor({
events events
]) ])
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( const fuelPerMotorHour = useMemo(
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0), () => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
[fuelConsumption, motorHours] [fuelConsumption, motorHours]
@@ -1208,15 +1218,17 @@ export default function LogEntryEditor({
...signaturesForSave ...signaturesForSave
}) })
await clearEntryDraft(logbookId, entryId)
setSuccess(true) setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
setTimeout(() => { setTimeout(() => {
setSuccess(false) setSuccess(false)
onBack() onBack()
}, 1500) }, 1500)
} catch (err: any) { } catch (err: unknown) {
console.error('Failed to save entry details:', err) console.error('Failed to save entry details:', err)
setError(err.message || 'Failed to save entry details.') setError(getErrorMessage(err, t('errors.save_failed')))
} finally { } finally {
setSaving(false) setSaving(false)
} }
+21 -24
View File
@@ -6,11 +6,13 @@ import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type D
import LogbookRoleBadge from './LogbookRoleBadge.tsx' import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx' import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { logoutUser } from '../services/auth.js' import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react' import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx' import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx' import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
interface LogbookDashboardProps { interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void onSelectLogbook: (id: string, title: string) => void
@@ -74,7 +76,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc') const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null) const filterInputRef = useRef<HTMLInputElement>(null)
const [online, setOnline] = useState(navigator.onLine) const [online, setOnline] = useState(navigator.onLine)
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator() const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
@@ -102,8 +103,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
try { try {
const data = await fetchLogbooks() const data = await fetchLogbooks()
setLogbooks(data) setLogbooks(data)
} catch (err: any) { } catch (err: unknown) {
setError(err.message || 'Failed to load logbooks') setError(getErrorMessage(err, t('errors.load_failed')))
} finally { } finally {
setLoading(false) setLoading(false)
setRefreshing(false) setRefreshing(false)
@@ -121,8 +122,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
setLogbooks((prev) => [created, ...prev]) setLogbooks((prev) => [created, ...prev])
setNewTitle('') setNewTitle('')
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED) trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
} catch (err: any) { } catch (err: unknown) {
setError(err.message || 'Failed to create logbook') setError(getErrorMessage(err, t('errors.save_failed')))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -138,7 +139,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
await deleteLogbook(id) await deleteLogbook(id)
setLogbooks((prev) => prev.filter((lb) => lb.id !== id)) setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Failed to delete logbook') setError(getErrorMessage(err, t('errors.delete_failed')))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -182,7 +183,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
) )
) )
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Failed to update logbook title') setError(getErrorMessage(err, t('errors.save_failed')))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -225,10 +226,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
return ( return (
<div <div
key={lb.id} key={lb.id}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`} className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-title' : ''}`}
onClick={() => onSelectLogbook(lb.id, lb.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} /> <BookOpen size={24} />
</div> </div>
@@ -241,7 +250,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
className="logbook-title-inline-edit input-text" className="logbook-title-inline-edit input-text"
value={editingTitleDraft} value={editingTitleDraft}
onChange={(e) => setEditingTitleDraft(e.target.value)} onChange={(e) => setEditingTitleDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
@@ -370,18 +378,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
)} )}
</div> </div>
{/* Skipper profile */} <ProfileHeaderButton onClick={onOpenProfile} />
<button
type="button"
className="btn-icon skipper-badge"
onClick={onOpenProfile}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
>
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</button>
{/* Lang toggle */} {/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language"> <button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
+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 { interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void> showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
@@ -16,6 +26,11 @@ export function useDialog() {
} }
export function DialogProvider({ children }: { children: React.ReactNode }) { 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 [isOpen, setIsOpen] = useState(false)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
@@ -23,19 +38,20 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
const [confirmLabel, setConfirmLabel] = useState('OK') const [confirmLabel, setConfirmLabel] = useState('OK')
const [cancelLabel, setCancelLabel] = useState('Cancel') 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> => { const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg) setMessage(msg)
setTitle(headerTitle || '') setTitle(headerTitle || '')
setType('alert') setType('alert')
setConfirmLabel(btnText || 'OK') setConfirmLabel(btnText || t('dialog.ok'))
setIsOpen(true) setIsOpen(true)
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
resolveRef.current = resolve alertResolveRef.current = resolve
}) })
}, []) }, [t])
const showConfirm = useCallback(( const showConfirm = useCallback((
msg: string, msg: string,
@@ -46,31 +62,47 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
setMessage(msg) setMessage(msg)
setTitle(headerTitle || '') setTitle(headerTitle || '')
setType('confirm') setType('confirm')
setConfirmLabel(btnConfirm || 'Yes') setConfirmLabel(btnConfirm || t('dialog.yes'))
setCancelLabel(btnCancel || 'No') setCancelLabel(btnCancel || t('dialog.no'))
setIsOpen(true) setIsOpen(true)
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
resolveRef.current = resolve confirmResolveRef.current = resolve
}) })
}, []) }, [t])
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
setIsOpen(false) setIsOpen(false)
if (resolveRef.current) { if (type === 'confirm' && confirmResolveRef.current) {
resolveRef.current(type === 'confirm' ? true : undefined) confirmResolveRef.current(true)
resolveRef.current = null confirmResolveRef.current = null
} else if (alertResolveRef.current) {
alertResolveRef.current()
alertResolveRef.current = null
} }
}, [type]) }, [type])
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
setIsOpen(false) setIsOpen(false)
if (resolveRef.current) { if (confirmResolveRef.current) {
resolveRef.current(false) confirmResolveRef.current(false)
resolveRef.current = null 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( const contextValue = useMemo(
() => ({ showAlert, showConfirm }), () => ({ showAlert, showConfirm }),
[showAlert, showConfirm] [showAlert, showConfirm]
@@ -80,17 +112,44 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
<DialogContext.Provider value={contextValue}> <DialogContext.Provider value={contextValue}>
{children} {children}
{isOpen && ( {isOpen && (
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}> <div
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}> className="custom-dialog-overlay"
{title && <h3 className="custom-dialog-title">{title}</h3>} onClick={type === 'confirm' ? handleCancel : handleConfirm}
<p className="custom-dialog-message">{message}</p> >
<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"> <div className="custom-dialog-actions">
{type === 'confirm' && ( {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} {cancelLabel}
</button> </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} {confirmLabel}
</button> </button>
</div> </div>
@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next'
import { User } from 'lucide-react'
interface ProfileHeaderButtonProps {
onClick: () => void
}
export default function ProfileHeaderButton({ onClick }: ProfileHeaderButtonProps) {
const { t } = useTranslation()
const username = localStorage.getItem('active_username') || 'Skipper'
return (
<button
type="button"
className="btn-icon skipper-badge"
onClick={onClick}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
>
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</button>
)
}
@@ -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>
)
}
+21 -3
View File
@@ -13,6 +13,17 @@
"sv": "Svenska", "sv": "Svenska",
"nb": "Norsk" "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": { "common": {
"unsaved_changes_title": "Ikke gemte ændringer", "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.", "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_title": "Opdatering tilgængelig",
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.", "update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
"update_now": "Opdater nu", "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": { "sync": {
"status_synced": "Synkroniseret", "status_synced": "Synkroniseret",
"status_syncing": "Synkroniser...", "status_syncing": "Synkroniser...",
"status_offline": "Offline-cache", "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": { "vessel": {
"title": "Skibets stamdata", "title": "Skibets stamdata",
@@ -150,7 +166,8 @@
"sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet", "sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet",
"sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.", "sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.",
"date": "dato", "date": "dato",
"day_of_travel": "Rejsedag / rejsedag", "day_of_travel": "Rejsedag",
"travel_day_number": "Rejsedag {{number}}",
"departure": "Starthavn (rejse fra)", "departure": "Starthavn (rejse fra)",
"destination": "Destinationsport (til)", "destination": "Destinationsport (til)",
"route": "Rejse fra/til", "route": "Rejse fra/til",
@@ -451,6 +468,7 @@
"role_read": "Læs kun", "role_read": "Læs kun",
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering", "role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
"open_profile": "Åben profil af {{name}}", "open_profile": "Åben profil af {{name}}",
"open_logbook": "Åbn logbog „{{title}}“",
"edit_title": "Omdøb logbog", "edit_title": "Omdøb logbog",
"edit_placeholder": "Nyt navn på logbogen", "edit_placeholder": "Nyt navn på logbogen",
"edit_success": "Logbog omdøbt med succes", "edit_success": "Logbog omdøbt med succes",
+21 -3
View File
@@ -13,6 +13,17 @@
"sv": "Svenska", "sv": "Svenska",
"nb": "Norsk" "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": { "common": {
"unsaved_changes_title": "Ungespeicherte Änderungen", "unsaved_changes_title": "Ungespeicherte Änderungen",
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.", "unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
@@ -92,13 +103,18 @@
"update_title": "Update verfügbar", "update_title": "Update verfügbar",
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.", "update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
"update_now": "Jetzt aktualisieren", "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": { "sync": {
"status_synced": "Synchronisiert", "status_synced": "Synchronisiert",
"status_syncing": "Synchronisiere…", "status_syncing": "Synchronisiere…",
"status_offline": "Offline-Cache", "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": { "vessel": {
"title": "Schiffs-Stammdaten", "title": "Schiffs-Stammdaten",
@@ -150,7 +166,8 @@
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt", "sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.", "sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
"date": "Datum", "date": "Datum",
"day_of_travel": "Tag der Reise / Reisetag", "day_of_travel": "Reisetag",
"travel_day_number": "Reisetag {{number}}",
"departure": "Start-Hafen (Reise von)", "departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)", "destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach", "route": "Reise von/nach",
@@ -451,6 +468,7 @@
"role_read": "Nur Lesen", "role_read": "Nur Lesen",
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung", "role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
"open_profile": "Profil von {{name}} öffnen", "open_profile": "Profil von {{name}} öffnen",
"open_logbook": "Logbuch „{{title}}“ öffnen",
"edit_title": "Logbuch umbenennen", "edit_title": "Logbuch umbenennen",
"edit_placeholder": "Neuer Name des Logbuchs", "edit_placeholder": "Neuer Name des Logbuchs",
"edit_success": "Logbuch erfolgreich umbenannt", "edit_success": "Logbuch erfolgreich umbenannt",
+21 -3
View File
@@ -13,6 +13,17 @@
"sv": "Svenska", "sv": "Svenska",
"nb": "Norsk" "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": { "common": {
"unsaved_changes_title": "Unsaved changes", "unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.", "unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
@@ -92,13 +103,18 @@
"update_title": "Update available", "update_title": "Update available",
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.", "update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
"update_now": "Reload now", "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": { "sync": {
"status_synced": "Synced", "status_synced": "Synced",
"status_syncing": "Syncing…", "status_syncing": "Syncing…",
"status_offline": "Offline Cache", "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": { "vessel": {
"title": "Vessel Master Data", "title": "Vessel Master Data",
@@ -150,7 +166,8 @@
"sign_cleared_skipper_re_sign_title": "Skipper signature removed", "sign_cleared_skipper_re_sign_title": "Skipper signature removed",
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.", "sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
"date": "Date", "date": "Date",
"day_of_travel": "Day of Travel", "day_of_travel": "Travel day",
"travel_day_number": "Travel day {{number}}",
"departure": "Departure Port (von)", "departure": "Departure Port (von)",
"destination": "Destination Port (nach)", "destination": "Destination Port (nach)",
"route": "Route / Journey", "route": "Route / Journey",
@@ -451,6 +468,7 @@
"role_read": "Read only", "role_read": "Read only",
"role_read_hint": "Shared logbook — view only, no editing", "role_read_hint": "Shared logbook — view only, no editing",
"open_profile": "Open profile for {{name}}", "open_profile": "Open profile for {{name}}",
"open_logbook": "Open logbook “{{title}}”",
"edit_title": "Rename Logbook", "edit_title": "Rename Logbook",
"edit_placeholder": "New name of the logbook", "edit_placeholder": "New name of the logbook",
"edit_success": "Logbook renamed successfully", "edit_success": "Logbook renamed successfully",
+21 -3
View File
@@ -13,6 +13,17 @@
"sv": "Svenska", "sv": "Svenska",
"nb": "Norsk" "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": { "common": {
"unsaved_changes_title": "Ikke-lagrede endringer", "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.", "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_title": "Oppdatering tilgjengelig",
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.", "update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
"update_now": "Oppdater nå", "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": { "sync": {
"status_synced": "Synkronisert", "status_synced": "Synkronisert",
"status_syncing": "Synkroniser...", "status_syncing": "Synkroniser...",
"status_offline": "Frakoblet hurtigbuffer", "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": { "vessel": {
"title": "Stamdata for skip", "title": "Stamdata for skip",
@@ -150,7 +166,8 @@
"sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet", "sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet",
"sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.", "sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.",
"date": "dato", "date": "dato",
"day_of_travel": "Reisens dag / reisedag", "day_of_travel": "Reisedag",
"travel_day_number": "Reisedag {{number}}",
"departure": "Starthavn (reise fra)", "departure": "Starthavn (reise fra)",
"destination": "Destinasjonsport (til)", "destination": "Destinasjonsport (til)",
"route": "Reise fra/til", "route": "Reise fra/til",
@@ -451,6 +468,7 @@
"role_read": "Bare les", "role_read": "Bare les",
"role_read_hint": "Delt loggbok - kun visning, ingen redigering", "role_read_hint": "Delt loggbok - kun visning, ingen redigering",
"open_profile": "Åpne profilen til {{name}}", "open_profile": "Åpne profilen til {{name}}",
"open_logbook": "Åpne loggbok «{{title}}»",
"edit_title": "Endre navn på loggbok", "edit_title": "Endre navn på loggbok",
"edit_placeholder": "Nytt navn på loggboken", "edit_placeholder": "Nytt navn på loggboken",
"edit_success": "Loggboken har fått nytt navn", "edit_success": "Loggboken har fått nytt navn",
+21 -3
View File
@@ -13,6 +13,17 @@
"sv": "Svenska", "sv": "Svenska",
"nb": "Norsk" "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": { "common": {
"unsaved_changes_title": "Osparade ändringar", "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.", "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_title": "Uppdatering tillgänglig",
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.", "update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
"update_now": "Uppdatering nu", "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": { "sync": {
"status_synced": "Synkroniserad", "status_synced": "Synkroniserad",
"status_syncing": "Synkronisera...", "status_syncing": "Synkronisera...",
"status_offline": "Offline-cache", "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": { "vessel": {
"title": "Masterdata för fartyg", "title": "Masterdata för fartyg",
@@ -150,7 +166,8 @@
"sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen", "sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen",
"sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.", "sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.",
"date": "datum", "date": "datum",
"day_of_travel": "Resedag / resedag", "day_of_travel": "Resedag",
"travel_day_number": "Resedag {{number}}",
"departure": "Starthamn (resa från)", "departure": "Starthamn (resa från)",
"destination": "Destinationsport (till)", "destination": "Destinationsport (till)",
"route": "Resa från/till", "route": "Resa från/till",
@@ -451,6 +468,7 @@
"role_read": "Endast läsning", "role_read": "Endast läsning",
"role_read_hint": "Delad loggbok - endast visning, ingen redigering", "role_read_hint": "Delad loggbok - endast visning, ingen redigering",
"open_profile": "Öppna profil för {{name}}", "open_profile": "Öppna profil för {{name}}",
"open_logbook": "Öppna loggbok ”{{title}}”",
"edit_title": "Byt namn på loggbok", "edit_title": "Byt namn på loggbok",
"edit_placeholder": "Nytt namn på loggboken", "edit_placeholder": "Nytt namn på loggboken",
"edit_success": "Loggboken har framgångsrikt bytt namn", "edit_success": "Loggboken har framgångsrikt bytt namn",
+23
View File
@@ -90,6 +90,15 @@ export interface SyncQueueItem {
updatedAt: string updatedAt: string
} }
export interface EntryDraftRecord {
logbookId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
class DaagboxDatabase extends Dexie { class DaagboxDatabase extends Dexie {
logbooks!: Table<LocalLogbook> logbooks!: Table<LocalLogbook>
yachts!: Table<LocalYacht> yachts!: Table<LocalYacht>
@@ -101,6 +110,7 @@ class DaagboxDatabase extends Dexie {
nmeaArchives!: Table<LocalNmeaArchive> nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey> logbookKeys!: Table<LocalLogbookKey>
syncQueue!: Table<SyncQueueItem> syncQueue!: Table<SyncQueueItem>
entryDrafts!: Table<EntryDraftRecord, [string, string]>
constructor() { constructor() {
super('DaagboxDatabase') super('DaagboxDatabase')
@@ -167,6 +177,19 @@ class DaagboxDatabase extends Dexie {
nmeaArchives: 'entryId, logbookId, updatedAt', nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId' 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'
})
} }
} }
+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])
}
+55 -1
View File
@@ -2,6 +2,11 @@ import { db, type SyncQueueItem } from './db.js'
import { getActiveMasterKey } from './auth.js' import { getActiveMasterKey } from './auth.js'
import { apiFetch } from './api.js' import { apiFetch } from './api.js'
import { getLogbookAccess } from './logbookAccess.js' import { getLogbookAccess } from './logbookAccess.js'
import {
clearSyncConflict,
reportSyncConflict,
type SyncConflict
} from './syncConflicts.js'
const API_BASE = '/api/sync' const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>() const syncingLogbooks = new Set<string>()
@@ -177,10 +182,19 @@ async function pushChanges(logbookId: string): Promise<boolean> {
const queueItem = pending[i] const queueItem = pending[i]
if (!queueItem) continue if (!queueItem) continue
if (res.status === 'success' || res.status === 'conflict') { if (res.status === 'success') {
if (queueItem.id !== undefined) { if (queueItem.id !== undefined) {
await db.syncQueue.delete(queueItem.id) 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 { } else {
console.error(`Sync failed for item ${res.payloadId}:`, res.error) console.error(`Sync failed for item ${res.payloadId}:`, res.error)
} }
@@ -525,3 +539,43 @@ export function stopBackgroundSync() {
syncIntervalId = null 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)
}
+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
}
+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 container_name: daagbox-prod-db
restart: always restart: always
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: daagbox POSTGRES_DB: ${POSTGRES_DB:-daagbox}
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: 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 interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -23,9 +24,10 @@ services:
restart: always restart: always
environment: environment:
PORT: 5000 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} RP_ID: ${RP_ID:-localhost}
ORIGIN: ${ORIGIN:-http://localhost} ORIGIN: ${ORIGIN:-http://localhost}
TRUST_PROXY: ${TRUST_PROXY:-1}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu} 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", "translate:flyer": "node scripts/translate-flyer.mjs",
"validate:i18n": "node scripts/validate-i18n-keys.mjs", "validate:i18n": "node scripts/validate-i18n-keys.mjs",
"generate:flyer": "node scripts/generate-beta-flyer.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 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 "=================================================="
echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}" echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "==================================================" echo "=================================================="
+1885 -1
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -7,7 +7,9 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "tsx watch src/index.ts" "dev": "tsx watch src/index.ts",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.10.2", "@prisma/client": "^5.10.2",
@@ -26,8 +28,11 @@
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.11.24", "@types/node": "^20.11.24",
"@types/supertest": "^6.0.3",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"supertest": "^7.1.0",
"tsx": "^4.7.1", "tsx": "^4.7.1",
"typescript": "^5.3.3" "typescript": "^5.3.3",
"vitest": "^3.0.9"
} }
} }
+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 dotenv from 'dotenv'
import { dirname, resolve } from 'node:path' import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import authRouter from './routes/auth.js' import { createApp } from './app.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'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
dotenv.config({ path: resolve(__dirname, '../../.env') }) dotenv.config({ path: resolve(__dirname, '../../.env') })
dotenv.config({ path: resolve(__dirname, '../.env') }) dotenv.config({ path: resolve(__dirname, '../.env') })
const app = express() const app = createApp()
const PORT = process.env.PORT || 5000 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, () => { app.listen(PORT, () => {
console.log(`[server] Server running on http://localhost:${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' import type { AuthedRequest } from './auth.js'
const MIN_SUBMIT_MS = 2_000 const MIN_SUBMIT_MS = 2_000
@@ -69,7 +69,11 @@ export const feedbackLimiter = rateLimit({
max: 5, max: 5,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, 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) => { handler: (_req, res) => {
res.status(429).json({ res.status(429).json({
error: 'Too many feedback submissions. Please try again later.', error: 'Too many feedback submissions. Please try again later.',
+37 -53
View File
@@ -14,6 +14,8 @@ import {
setSessionCookie, setSessionCookie,
setSessionTokenCookie setSessionTokenCookie
} from '../session.js' } from '../session.js'
import { ChallengeMap, ChallengeSet } from '../utils/challengeStore.js'
import { sendInternalError } from '../utils/httpErrors.js'
const router = Router() const router = Router()
@@ -21,10 +23,10 @@ const rpName = 'Kapteins Daagbok'
const rpID = process.env.RP_ID || 'localhost' const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173' 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 */ /** WebAuthn registration challenges for add-credential flow: challenge -> userId */
const addCredentialChallenges = new Map<string, string>() const addCredentialChallenges = new ChallengeSet<string>()
const activeChallenges = new Set<string>() const activeChallenges = new ChallengeSet()
function previewCredentialId(credentialId: string): string { function previewCredentialId(credentialId: string): string {
if (credentialId.length <= 16) return credentialId if (credentialId.length <= 16) return credentialId
@@ -76,7 +78,7 @@ router.post('/register-options', async (req, res) => {
}) })
if (existingUser) { 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') const userID = Buffer.from(username, 'utf8').toString('base64url')
@@ -98,9 +100,8 @@ router.post('/register-options', async (req, res) => {
registrationChallenges.set(username, options.challenge) registrationChallenges.set(username, options.challenge)
return res.json(options) return res.json(options)
} catch (error: any) { } catch (error: unknown) {
console.error('Error generating registration options:', error) return sendInternalError(res, error, 'auth/register-options')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -163,9 +164,8 @@ router.post('/register-verify', async (req, res) => {
setSessionCookie(res, user.id, true) setSessionCookie(res, user.id, true)
return res.json({ verified: true, userId: user.id }) return res.json({ verified: true, userId: user.id })
} catch (error: any) { } catch (error: unknown) {
console.error('Error verifying registration response:', error) return sendInternalError(res, error, 'auth/register-verify')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -197,9 +197,8 @@ router.post('/login-options', async (req, res) => {
activeChallenges.add(options.challenge) activeChallenges.add(options.challenge)
return res.json(options) return res.json(options)
} catch (error: any) { } catch (error: unknown) {
console.error('Error generating authentication options:', error) return sendInternalError(res, error, 'auth/login-options')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -260,9 +259,8 @@ router.post('/login-verify', async (req, res) => {
encryptedMasterKeyRecIv: user.encryptedMasterKeyRecIv, encryptedMasterKeyRecIv: user.encryptedMasterKeyRecIv,
encryptedMasterKeyRecTag: user.encryptedMasterKeyRecTag encryptedMasterKeyRecTag: user.encryptedMasterKeyRecTag
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error verifying authentication response:', error) return sendInternalError(res, error, 'auth/login-verify')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -305,9 +303,8 @@ router.post('/reauth-options', requireUser, async (req: any, res) => {
activeChallenges.add(options.challenge) activeChallenges.add(options.challenge)
return res.json(options) return res.json(options)
} catch (error: any) { } catch (error: unknown) {
console.error('Error generating reauth options:', error) return sendInternalError(res, error, 'auth/reauth-options')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -362,9 +359,8 @@ router.post('/reauth-verify', requireUser, async (req: any, res) => {
} }
return res.json({ verified: true }) return res.json({ verified: true })
} catch (error: any) { } catch (error: unknown) {
console.error('Error verifying reauth:', error) return sendInternalError(res, error, 'auth/reauth-verify')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -384,9 +380,8 @@ router.delete('/delete-account', requireReauth, async (req: any, res) => {
clearSessionCookie(res) clearSessionCookie(res)
return res.json({ success: true }) return res.json({ success: true })
} catch (error: any) { } catch (error: unknown) {
console.error('Error deleting account:', error) return sendInternalError(res, error, 'auth/delete-account')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -415,9 +410,8 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => {
}) })
return res.json({ success: true }) return res.json({ success: true })
} catch (error: any) { } catch (error: unknown) {
console.error('Error enrolling PRF key:', error) return sendInternalError(res, error, 'auth/enroll-prf')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -446,9 +440,8 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
}) })
return res.json({ success: true }) return res.json({ success: true })
} catch (error: any) { } catch (error: unknown) {
console.error('Error rotating recovery key:', error) return sendInternalError(res, error, 'auth/rotate-recovery')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -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/)') console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
return res.json({ ...DEFAULT_APPEARANCE_PREFS }) return res.json({ ...DEFAULT_APPEARANCE_PREFS })
} }
console.error('Error reading appearance prefs:', error) return sendInternalError(res, error, 'auth/appearance-prefs-get')
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
} }
}) })
@@ -509,9 +500,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.' error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.'
}) })
} }
console.error('Error updating appearance prefs:', error) return sendInternalError(res, error, 'auth/appearance-prefs-put')
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
} }
}) })
@@ -552,9 +541,8 @@ router.get('/profile', requireUser, async (req: any, res) => {
collaborationCount: user._count.collaborations collaborationCount: user._count.collaborations
} }
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error fetching user profile:', error) return sendInternalError(res, error, 'auth/profile')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -591,12 +579,11 @@ router.post('/add-credential-options', requireReauth, async (req: any, res) => {
excludeCredentials excludeCredentials
}) })
addCredentialChallenges.set(options.challenge, req.userId) addCredentialChallenges.add(options.challenge, req.userId)
return res.json(options) return res.json(options)
} catch (error: any) { } catch (error: unknown) {
console.error('Error generating add-credential options:', error) return sendInternalError(res, error, 'auth/add-credential-options')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -670,9 +657,8 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
transports: credential.transports transports: credential.transports
} }
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error verifying add-credential response:', error) return sendInternalError(res, error, 'auth/add-credential-verify')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -702,9 +688,8 @@ router.patch('/credentials/:id', requireReauth, async (req: any, res) => {
transports: updated.transports transports: updated.transports
} }
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error updating credential label:', error) return sendInternalError(res, error, 'auth/credentials-patch')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -733,9 +718,8 @@ router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
}) })
return res.json({ success: true }) return res.json({ success: true })
} catch (error: any) { } catch (error: unknown) {
console.error('Error deleting credential:', error) return sendInternalError(res, error, 'auth/credentials-delete')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
+17 -24
View File
@@ -1,6 +1,7 @@
import { Router } from 'express' import { Router } from 'express'
import { prisma } from '../db.js' import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js' import { requireUser } from '../middleware/auth.js'
import { sendInternalError } from '../utils/httpErrors.js'
const router = Router() const router = Router()
@@ -39,9 +40,8 @@ router.get('/invite-details', async (req: any, res) => {
encryptedTitle: invitation.logbook.encryptedTitle, encryptedTitle: invitation.logbook.encryptedTitle,
role: invitation.role role: invitation.role
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error fetching invite details:', error) return sendInternalError(res, error, 'collaboration/invite-details')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -90,9 +90,8 @@ router.get('/share-pull', async (req: any, res) => {
photos, photos,
gpsTracks gpsTracks
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error in share-pull:', error) return sendInternalError(res, error, 'collaboration/share-pull')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -159,9 +158,8 @@ router.post('/accept', requireUser, async (req: any, res) => {
logbookId: invitation.logbookId, logbookId: invitation.logbookId,
role: invitation.role role: invitation.role
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error accepting invitation:', error) return sendInternalError(res, error, 'collaboration/accept')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -205,9 +203,8 @@ router.post('/invite', async (req: any, res) => {
token: invitation.token, token: invitation.token,
expiresAt: invitation.expiresAt expiresAt: invitation.expiresAt
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error creating invitation:', error) return sendInternalError(res, error, 'collaboration/invite')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -247,9 +244,8 @@ router.get('/collaborators', async (req: any, res) => {
role: c.role, role: c.role,
createdAt: c.createdAt createdAt: c.createdAt
}))) })))
} catch (error: any) { } catch (error: unknown) {
console.error('Error fetching collaborators:', error) return sendInternalError(res, error, 'collaboration/collaborators')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -277,9 +273,8 @@ router.delete('/collaborators/:id', async (req: any, res) => {
}) })
return res.json({ success: true }) return res.json({ success: true })
} catch (error: any) { } catch (error: unknown) {
console.error('Error revoking collaboration:', error) return sendInternalError(res, error, 'collaboration/revoke')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -317,9 +312,8 @@ router.get('/share-link', async (req: any, res) => {
token: invitation ? invitation.token : null, token: invitation ? invitation.token : null,
expiresAt: invitation ? invitation.expiresAt : null expiresAt: invitation ? invitation.expiresAt : null
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error fetching share link:', error) return sendInternalError(res, error, 'collaboration/share-link-get')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -384,9 +378,8 @@ router.post('/share-link', async (req: any, res) => {
return res.json({ success: true }) return res.json({ success: true })
} }
} catch (error: any) { } catch (error: unknown) {
console.error('Error toggling share link:', error) return sendInternalError(res, error, 'collaboration/share-link-post')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
+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)
}
}
+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, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": 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'
}
}
})