Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c6c2779f2 | |||
| b6c4e9e7d9 | |||
| 04c6be2b5b | |||
| 9089d017b6 | |||
| f8dc6ace3c | |||
| 18f14d7e0b | |||
| 0edf4a789c | |||
| 4ef56aeb8f | |||
| 3263fbcec3 | |||
| b9ce853059 | |||
| 3d8a505bd9 | |||
| e138752dd3 | |||
| b9c908169b | |||
| e6bde5c525 | |||
| eab7b86c0b | |||
| b86789ae4c |
+14
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
+19
-2
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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)).
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+40
@@ -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 "=================================================="
|
||||||
Executable
+183
@@ -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."
|
||||||
Executable
+40
@@ -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'"
|
||||||
@@ -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 "=================================================="
|
||||||
|
|||||||
Generated
+1885
-1
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -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}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -12,5 +12,6 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user