Compare commits

...

39 Commits

Author SHA1 Message Date
elpatron 41acbaebac chore: release v0.1.1.22 2026-06-05 19:58:21 +02:00
elpatron 6c83cd7d36 feat: differentiate weather fetch errors by cause 2026-06-05 19:52:33 +02:00
elpatron 9089e1c6f9 feat: resolve user profile photos in chronological event log 2026-06-05 19:46:18 +02:00
elpatron 1504960d85 chore: release v0.1.1.21 2026-06-05 19:13:20 +02:00
elpatron 599f090895 fix: resolve unbound variable error on remote deploy to prod 2026-06-05 19:13:03 +02:00
elpatron 4eb2b4c517 chore: release v0.1.1.20 2026-06-05 19:07:30 +02:00
elpatron be3b23ed8c docs: update marketing sticker image 2026-06-05 19:07:21 +02:00
elpatron 697c5781b7 feat(deploy): Unterstützung für Staging-Backups und -Wiederherstellungen
Erweitert die Backup- und Restore-Skripte um die Möglichkeit, Staging-Umgebungen zu unterstützen. Fügt die Option `-dest stage` hinzu, um spezifische Konfigurationen für Staging zu verwenden, einschließlich separater Docker-Compose-Dateien und Datenbankcontainer. Dokumentation aktualisiert, um manuelle Tests und Umgebungsvariablen für Staging zu reflektieren.
2026-06-05 18:45:52 +02:00
elpatron 4c36c9160a feat(deploy): Server-Backup und Restore für Produktion
Automatisiert pg_dump, .env, Compose und Git-Archiv mit Tag-Zuordnung, Retention (5) und Pre-Deploy-Hook nur für Prod.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:40:47 +02:00
elpatron d559a762d2 fix(deploy): Vor Deploy sauberen und synchronen Git-Stand erzwingen
update-remotes.sh bricht ab, wenn uncommitted Änderungen bestehen oder
HEAD nicht mit origin übereinstimmt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:24:21 +02:00
elpatron a2180a302c refactor(tour): interne z-index-Schichtung im Overlay vereinfachen
Ersetzt irreführende 10001/10002-Werte durch relative Layer 1–3 innerhalb
von .app-tour-root und dokumentiert den Stacking-Context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:15:17 +02:00
elpatron cd29115233 fix(tour): Tour-Tooltip über hervorgehobenen Profil-Schritten anzeigen
Erhöht den z-index des Tour-Overlays über app-tour-target-active, damit
das Modal in Schritt 8 (Stammcrew & Skipper) nicht von der Spotlight-Karte verdeckt wird.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:12:28 +02:00
elpatron e4b07ca896 refactor(deploy): update-prod.sh zu update-remotes.sh umbenennen
Ein Skript für Prod und Staging; update-staging.sh entfällt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:12:08 +02:00
elpatron f0c3cacb06 feat(analytics): Plausible über PLAUSIBLE_ENABLED und PLAUSIBLE_HOST steuerbar
Runtime-Konfiguration im Frontend-Container trennt Prod und Staging;
Staging deaktiviert Analytics standardmäßig.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:04:31 +02:00
elpatron 5821e20086 fix(deploy): Backend-Healthcheck und Staging-Wrapper absichern
Expliziter Compose-Healthcheck für das Backend, curl-Fallback und längeres
MAX_WAIT im Deploy-Skript; update-staging.sh lehnt -dest ab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 17:52:05 +02:00
elpatron aff8d1517d feat(deploy): Staging-Umgebung und einheitliches Deploy-Skript
Fügt docker-compose.staging.yml, Staging-Dokumentation und -dest prod|stage
in update-prod.sh hinzu, damit Prod und Staging über ein Skript deploybar sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 17:47:12 +02:00
elpatron f4d6b11414 chore: release v0.1.1.19 2026-06-05 11:47:07 +02:00
elpatron 968e81f4fb feat(auth): Session-Wiederherstellung nach Reload ohne vollen Login
Nach gültigem Server-Cookie wird automatisch Passkey oder PIN zum Entsperren angeboten, statt die komplette Anmelde-Maske zu zeigen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:42:06 +02:00
elpatron 10835c9def chore: release v0.1.1.18 2026-06-05 11:30:44 +02:00
elpatron cdbc618521 fix(admin): kompakteres Mobile-Layout für Admin-Dashboard
KPI-Karten bleiben auf schmalen Viewports in zwei Spalten, Header und Filter nutzen weniger vertikalen Platz.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:30:25 +02:00
elpatron f75fe42910 chore: release v0.1.1.17 2026-06-05 11:15:46 +02:00
elpatron 212775ffdc fix(deploy): pass ADMIN_USER_IDS into backend container
Docker Compose did not forward the admin whitelist from .env, so production always treated every user as non-admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:14:56 +02:00
elpatron c80760db02 chore: release v0.1.1.16 2026-06-05 11:05:03 +02:00
elpatron cd1dd12c15 fix: require auth before rendering admin dashboard
Show login instead of AdminDashboard on /admin when unauthenticated to avoid pointless admin API calls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:31:54 +02:00
elpatron 43cf589613 feat: add in-app admin navigation for whitelisted users
Detect admin access after login and expose a header button that opens /admin via client-side routing so the session stays unlocked when returning to the app.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:03:46 +02:00
elpatron e1cb2754c4 fix: keep session when leaving admin
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 09:32:30 +02:00
elpatron 5dedb8fac0 feat: add admin dashboard with usage stats
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 09:26:55 +02:00
elpatron 78f1659db4 chore: release v0.1.1.15 2026-06-04 19:30:53 +02:00
elpatron 935c263648 style: link KnorrLabs to dashy with compass icon badge 2026-06-04 19:29:59 +02:00
elpatron 29ac96f892 chore: release v0.1.1.14 2026-06-04 19:27:12 +02:00
elpatron 4d3b7210b3 style: replace name with email address in footer mail link badge 2026-06-04 19:27:00 +02:00
elpatron 369bca2ef1 chore: release v0.1.1.13 2026-06-04 19:23:13 +02:00
elpatron 2fcc741f5e style: style email link in footer as icon badge similar to Ko-Fi badge 2026-06-04 19:22:43 +02:00
elpatron 27722186d1 AI Video updated 2026-06-04 19:18:10 +02:00
elpatron 5710c74706 feat: add square sticker/sharepic image 2026-06-04 19:09:27 +02:00
elpatron cd27dfa27d Add AI generated marketing video 2026-06-04 19:05:36 +02:00
elpatron c4c7d42de4 feat: add portrait sharepic and update generation script 2026-06-04 18:39:07 +02:00
elpatron 71025b3d61 style: add QR code to sharepic layout 2026-06-04 18:36:51 +02:00
elpatron f790a6adcc feat: add sharepic HTML template, generation script, and client package task 2026-06-04 18:36:12 +02:00
56 changed files with 3541 additions and 379 deletions
+15
View File
@@ -15,6 +15,11 @@ DeepLAPIKey=
# Production (kapteins-daagbok.eu):
# RP_ID=kapteins-daagbok.eu
# ORIGIN=https://kapteins-daagbok.eu
# Staging (staging.kapteins-daagbok.eu):
# RP_ID=staging.kapteins-daagbok.eu
# ORIGIN=https://staging.kapteins-daagbok.eu
# POSTGRES_DB=daagbox_staging
# NTFY_TOPIC=kapteins-daagbok-staging-feedback
RP_ID=localhost
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173
@@ -36,6 +41,10 @@ ORIGIN=http://localhost:5173
# Generate: openssl rand -base64 48
SESSION_SECRET=
# Admin dashboard access — comma-separated list of User IDs (UUIDs)
# Example: ADMIN_USER_IDS=11111111-2222-3333-4444-555555555555,22222222-3333-4444-5555-666666666666
ADMIN_USER_IDS=
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
VAPID_PUBLIC_KEY=
@@ -47,3 +56,9 @@ VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-feedback
NTFY_TOKEN=tk_example_ntfy_access_token
# Plausible Analytics (frontend container — see docs/plausible-events.md)
# Production: PLAUSIBLE_ENABLED=true, data-domain = current hostname (kapteins-daagbok.eu)
# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
PLAUSIBLE_ENABLED=true
PLAUSIBLE_HOST=https://plausible.elpatron.me
+16 -2
View File
@@ -251,15 +251,27 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ
```bash
./scripts/update-prod.sh
./scripts/update-remotes.sh -dest prod
```
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Standard-Ziel Prod: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
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.
Prod-Deploy legt vor dem Update automatisch ein Server-Backup an (DB, `.env`, Compose, App-Code). Tägliches Cron-Backup und Restore: [docs/deployment/backup.md](docs/deployment/backup.md).
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
### Staging
Testumgebung unter [staging.kapteins-daagbok.eu](https://staging.kapteins-daagbok.eu) — Deploy ohne Release-Tag:
```bash
./scripts/update-remotes.sh -dest stage
```
Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `REMOTE_HOST`, `REMOTE_DIR`, `DEPLOY_BRANCH` überschreibbar. Details: [docs/deployment/staging.md](docs/deployment/staging.md).
## Dokumentation
| Dokument | Inhalt |
@@ -267,6 +279,8 @@ Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deploymen
| [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/deployment/backup.md](docs/deployment/backup.md) | Server-Backup, Crontab, Restore (Prod) |
| [docs/deployment/staging.md](docs/deployment/staging.md) | Staging-VM, Deploy, `.env` |
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
+1 -1
View File
@@ -1 +1 @@
0.1.1.13
0.1.1.23
+7 -4
View File
@@ -18,15 +18,18 @@ RUN npm run build
FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html
# Copy custom Nginx configuration
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
RUN apk add --no-cache gettext
COPY client/nginx.conf.template /etc/nginx/templates/default.conf.template
COPY client/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Copy built assets from builder
COPY --from=builder /app/dist .
# Expose HTTP port
EXPOSE 80
# Health check to verify Nginx is actively running
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
ENTRYPOINT ["/docker-entrypoint.sh"]
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
PLAUSIBLE_ENABLED="${PLAUSIBLE_ENABLED:-true}"
PLAUSIBLE_HOST="${PLAUSIBLE_HOST:-https://plausible.elpatron.me}"
PLAUSIBLE_HOST="${PLAUSIBLE_HOST%/}"
case "$(printf '%s' "$PLAUSIBLE_ENABLED" | tr '[:upper:]' '[:lower:]')" in
true|1|yes)
PLAUSIBLE_ENABLED_JSON=true
PLAUSIBLE_CSP=" ${PLAUSIBLE_HOST}"
;;
*)
PLAUSIBLE_ENABLED_JSON=false
PLAUSIBLE_CSP=""
;;
esac
export PLAUSIBLE_CSP
envsubst '${PLAUSIBLE_CSP}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
cat > /usr/share/nginx/html/runtime-config.json <<EOF
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
EOF
exec nginx -g 'daemon off;'
+1 -1
View File
@@ -22,6 +22,7 @@
<meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script>
<script src="/plausible-bootstrap.js"></script>
<script src="/bootstrap-watchdog.js"></script>
<link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" />
@@ -38,7 +39,6 @@
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." />
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
<title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
</head>
<body>
+2 -51
View File
@@ -1,51 +1,2 @@
server {
listen 80;
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" 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; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate" 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=(self)" 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; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate" 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=(self)" 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; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:5000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
# Generated at container start from PLAUSIBLE_* — see client/nginx.conf.template and docker-entrypoint.sh
# Local Docker Compose uses the template via client/Dockerfile entrypoint.
+51
View File
@@ -0,0 +1,51 @@
server {
listen 80;
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate" 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=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate" 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=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:5000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
+1
View File
@@ -13,6 +13,7 @@
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
"generate:flyer:setup": "playwright install chromium",
"generate:sharepic": "node ../scripts/generate-sharepic.mjs",
"translate:locales": "node ../scripts/translate-locales.mjs",
"translate:flyer": "node ../scripts/translate-flyer.mjs",
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
+25
View File
@@ -0,0 +1,25 @@
/**
* Loads Plausible when enabled via /runtime-config.json (from .env in Docker / Vite dev).
* data-domain is always the current hostname (prod vs staging).
*/
(function () {
function load(cfg) {
if (!cfg || !cfg.plausibleEnabled || !cfg.plausibleHost) return
var host = String(cfg.plausibleHost).replace(/\/$/, '')
if (!host) return
var s = document.createElement('script')
s.defer = true
s.dataset.domain = window.location.hostname
s.src = host + '/js/script.tagged-events.js'
document.head.appendChild(s)
}
fetch('/runtime-config.json', { cache: 'no-store' })
.then(function (r) {
return r.ok ? r.json() : null
})
.then(load)
.catch(function () {
/* analytics optional */
})
})()
+249 -3
View File
@@ -4919,6 +4919,177 @@ html.theme-cupertino .events-scroll-container {
padding-bottom: 8px;
}
.admin-page {
width: 100%;
max-width: 1200px;
min-height: 100vh;
margin: 0 auto;
padding: 24px;
box-sizing: border-box;
color: var(--app-text);
display: flex;
flex-direction: column;
gap: 24px;
}
.admin-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--app-header-border);
}
.admin-header-left {
display: flex;
align-items: flex-start;
gap: 20px;
min-width: 0;
}
.admin-title {
margin: 0;
font-size: 24px;
font-weight: 700;
background: var(--app-accent-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.admin-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: var(--app-text-muted);
}
.admin-main {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
}
.admin-kpi-grid {
margin-top: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-controls {
display: flex;
flex-wrap: wrap;
gap: 24px;
padding: 16px 20px;
border-radius: var(--app-radius-card);
border: 1px solid var(--app-border-subtle);
background: var(--app-surface);
}
.admin-control-group {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.admin-control-label {
font-size: 13px;
font-weight: 600;
color: var(--app-text-muted);
}
.admin-control-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.admin-control-buttons .btn {
width: auto;
padding: 8px 16px;
font-size: 14px;
}
.admin-charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
gap: 20px;
}
.dashboard-subtitle {
margin-top: 4px;
font-size: 14px;
color: var(--app-text-muted);
}
@media (max-width: 640px) {
.admin-page {
padding: 12px 12px 20px;
gap: 16px;
min-height: auto;
}
.admin-header {
padding-bottom: 12px;
}
.admin-header-left {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
align-items: center;
column-gap: 10px;
row-gap: 2px;
}
.admin-header-left .btn-back {
grid-row: 1 / -1;
align-self: center;
padding: 6px 10px;
font-size: 13px;
}
.admin-title {
font-size: 18px;
line-height: 1.2;
}
.admin-subtitle {
font-size: 11px;
margin: 0;
line-height: 1.35;
}
.admin-main {
gap: 16px;
}
.admin-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px;
}
.admin-control-buttons {
gap: 6px;
}
.admin-control-label {
font-size: 11px;
}
.admin-control-buttons .btn {
padding: 6px 8px;
font-size: 12px;
}
.admin-charts-grid {
gap: 14px;
}
}
.stats-consumption-chart .stats-bar-column--grouped {
display: inline-flex;
white-space: normal;
@@ -5018,6 +5189,36 @@ html.theme-cupertino .events-scroll-container {
grid-template-columns: 1fr;
}
/* Admin dashboard: keep 2-column KPI grid on mobile (overrides rule above) */
.stats-kpi-grid.admin-kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.admin-kpi-grid .stats-kpi-card {
padding: 10px 12px;
gap: 8px;
}
.admin-kpi-grid .stats-kpi-icon {
margin-top: 0;
}
.admin-kpi-grid .stats-kpi-icon svg {
width: 18px;
height: 18px;
}
.admin-kpi-grid .stats-kpi-label {
font-size: 11px;
line-height: 1.25;
margin-bottom: 2px;
}
.admin-kpi-grid .stats-kpi-value {
font-size: 18px;
}
.stats-kpi-value {
font-size: 20px;
}
@@ -5642,6 +5843,48 @@ html.theme-cupertino .events-scroll-container {
border-color: rgba(255, 94, 91, 0.32);
}
.mail-footer-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: #94a3b8;
text-decoration: none;
background: rgba(56, 189, 248, 0.08);
border: 1px solid rgba(56, 189, 248, 0.18);
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.mail-footer-badge:hover {
color: #bae6fd;
background: rgba(56, 189, 248, 0.14);
border-color: rgba(56, 189, 248, 0.32);
}
.knorrlabs-footer-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: #94a3b8;
text-decoration: none;
background: rgba(139, 92, 246, 0.08);
border: 1px solid rgba(139, 92, 246, 0.18);
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.knorrlabs-footer-badge:hover {
color: #ddd6fe;
background: rgba(139, 92, 246, 0.14);
border-color: rgba(139, 92, 246, 0.32);
}
.demo-badge {
display: inline-flex;
align-items: center;
@@ -5791,13 +6034,15 @@ html.theme-cupertino .events-scroll-container {
.app-tour-root {
position: fixed;
inset: 0;
z-index: 10000;
/* Above .app-tour-target-active (10001) so tooltip/backdrop stay topmost */
z-index: 10010;
pointer-events: none;
}
.app-tour-backdrop {
position: absolute;
inset: 0;
z-index: 1;
background: rgba(2, 6, 23, 0.62);
pointer-events: auto;
}
@@ -5815,7 +6060,7 @@ html.theme-cupertino .events-scroll-container {
0 0 32px rgba(56, 189, 248, 0.5),
0 12px 40px rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 10001;
z-index: 2;
}
body.app-tour-active .app-tour-target-active {
@@ -5826,7 +6071,8 @@ body.app-tour-active .app-tour-target-active {
.app-tour-tooltip {
position: fixed;
z-index: 10002;
/* Layer above backdrop/spotlight inside .app-tour-root (not vs. root's 10010) */
z-index: 3;
box-sizing: border-box;
width: min(420px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
+79 -4
View File
@@ -36,6 +36,7 @@ import { syncAppearancePrefs } from './services/appearancePrefs.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
import DemoViewer from './components/DemoViewer.tsx'
import AdminDashboard from './admin/AdminDashboard.tsx'
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx'
@@ -49,6 +50,8 @@ import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, La
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
import { checkAdminAccess } from './services/adminApi.js'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js'
import {
@@ -92,6 +95,10 @@ function App() {
// Public demo mode (no account required)
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
const [isAdminUser, setIsAdminUser] = useState(false)
const [sessionChecked, setSessionChecked] = useState(false)
const [serverSessionActive, setServerSessionActive] = useState(false)
const syncQueueCount = useLiveQuery(
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
@@ -160,14 +167,23 @@ function App() {
})
}, [])
const refreshAdminAccess = useCallback(async () => {
const isAdmin = await checkAdminAccess()
setIsAdminUser(isAdmin)
}, [])
useEffect(() => {
if (!isAuthenticated) return
if (!isAuthenticated) {
setIsAdminUser(false)
return
}
const userId = localStorage.getItem('active_userid')
if (!userId) return
void syncAppearancePrefs(userId)
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
}, [isAuthenticated])
void refreshAdminAccess()
}, [isAuthenticated, refreshAdminAccess])
useEffect(() => {
const handleOnline = () => {
@@ -199,6 +215,13 @@ function App() {
const hashParams = new URLSearchParams(window.location.hash.substring(1))
const path = window.location.pathname
if (path.startsWith('/admin')) {
setIsAdminRoute(true)
return
}
setIsAdminRoute(false)
if (path === '/demo') {
setIsDemoMode(true)
setIsViewerMode(false)
@@ -240,6 +263,7 @@ function App() {
const clearAuthenticatedAppState = useCallback(() => {
setIsAuthenticated(false)
setIsAdminUser(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setShowUserProfile(false)
@@ -249,7 +273,7 @@ function App() {
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
const enforceUnlockedSession = useCallback(() => {
if (isViewerMode || isDemoMode || isAcceptingInvite) return
if (isViewerMode || isDemoMode || isAcceptingInvite || isAdminRoute) return
// Require full local session (incl. userId) so API calls are not left headless.
if (isAuthenticated && !hasUnlockedLocalSession()) {
clearAuthenticatedAppState()
@@ -259,6 +283,7 @@ function App() {
isViewerMode,
isDemoMode,
isAcceptingInvite,
isAdminRoute,
clearAuthenticatedAppState
])
@@ -293,6 +318,8 @@ function App() {
const session = await checkServerSession()
if (cancelled) return
setServerSessionActive(session.authenticated)
if (session.authenticated) {
persistSessionUserId(session.userId)
}
@@ -312,6 +339,10 @@ function App() {
if (!cancelled) {
console.warn('Session restore failed:', err)
}
} finally {
if (!cancelled) {
setSessionChecked(true)
}
}
})()
@@ -333,6 +364,14 @@ function App() {
setIsAcceptingInvite(false)
}, [])
const openAdmin = useCallback(() => {
window.history.pushState({}, document.title, '/admin')
setIsAdminRoute(true)
setIsDemoMode(false)
setIsViewerMode(false)
setIsAcceptingInvite(false)
}, [])
const selectLogbook = useCallback((id: string, title: string) => {
setActiveLogbookId(id)
setActiveLogbookTitle(title)
@@ -497,6 +536,7 @@ function App() {
if (!(await confirmLeave())) return
void logoutUser()
setIsAuthenticated(false)
setIsAdminUser(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setShowUserProfile(false)
@@ -524,6 +564,28 @@ function App() {
syncRouteFromLocation()
}
const handleBackFromAdmin = () => {
window.history.replaceState({}, document.title, '/')
setIsAdminRoute(false)
syncRouteFromLocation()
}
if (isAdminRoute) {
if (!isAuthenticated) {
return (
<div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
</div>
)
}
return (
<div style={{ display: 'contents' }}>
<AdminDashboard onBack={handleBackFromAdmin} />
</div>
)
}
if (isDemoMode) {
return (
<div style={{ display: 'contents' }}>
@@ -564,7 +626,17 @@ function App() {
if (!isAuthenticated) {
return (
<div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
{!sessionChecked ? (
<div className="auth-card glass">
<p className="dashboard-status-msg">{t('auth.restore_checking')}</p>
</div>
) : (
<AuthOnboarding
restoreSession={serverSessionActive}
onAuthenticated={handleAuthenticated}
onOpenDemo={openDemo}
/>
)}
</div>
)
}
@@ -597,6 +669,7 @@ function App() {
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
onOpenAdmin={isAdminUser ? openAdmin : undefined}
/>
</div>
)
@@ -647,6 +720,8 @@ function App() {
<Languages size={18} />
</button>
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
<DisclaimerHeaderButton />
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useState, type ReactNode } from 'react'
import {
fetchAdminMe,
fetchAdminSummary,
fetchAdminTimeSeries,
type AdminSummary,
type AdminTimeSeriesResponse,
type AdminTimeBucket
} from '../services/adminApi.js'
import { BarChart2, Bookmark, ChevronLeft, Image, MapPin, Mic, Users } from 'lucide-react'
function formatNumber(value: number): string {
return value.toLocaleString()
}
function KpiCard({
icon,
label,
value
}: {
icon: ReactNode
label: string
value: number
}) {
return (
<div className="stats-kpi-card glass">
<div className="stats-kpi-icon">{icon}</div>
<div className="stats-kpi-body">
<span className="stats-kpi-label">{label}</span>
<span className="stats-kpi-value">{formatNumber(value)}</span>
</div>
</div>
)
}
function TimeSeriesChart({
title,
seriesKey,
data
}: {
title: string
seriesKey: string
data: AdminTimeSeriesResponse | null
}) {
if (!data) {
return null
}
const metric = data.series.find((s) => s.metric === seriesKey)
if (!metric || metric.points.length === 0) {
return (
<div className="form-card glass">
<div className="form-header">
<BarChart2 className="form-icon" />
<h2>{title}</h2>
</div>
<p className="dashboard-status-msg">Keine Daten im gewählten Zeitraum.</p>
</div>
)
}
const max = metric.points.reduce((acc, p) => (p.count > acc ? p.count : acc), 0) || 1
return (
<div className="form-card glass">
<div className="form-header">
<BarChart2 className="form-icon" />
<h2>{title}</h2>
</div>
<div className="stats-bar-chart" role="img" aria-label={title}>
{metric.points.map((point) => {
const heightPct = Math.max(2, (point.count / max) * 100)
return (
<div key={point.date} className="stats-bar-column" title={`${point.date}: ${point.count}`}>
<span className="stats-bar-value">{point.count > 0 ? String(point.count) : ''}</span>
<div className="stats-bar-track">
<div className="stats-bar stats-bar--distance" style={{ height: `${heightPct}%` }} />
</div>
<span className="stats-bar-label">{point.date.slice(5)}</span>
</div>
)
})}
</div>
</div>
)
}
interface AdminDashboardProps {
onBack: () => void
}
export default function AdminDashboard({ onBack }: AdminDashboardProps) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [summary, setSummary] = useState<AdminSummary | null>(null)
const [timeSeries, setTimeSeries] = useState<AdminTimeSeriesResponse | null>(null)
const [bucket, setBucket] = useState<AdminTimeBucket>('day')
const [windowDays, setWindowDays] = useState(90)
useEffect(() => {
let cancelled = false
async function load() {
try {
setLoading(true)
setError(null)
await fetchAdminMe()
const [summaryRes, tsRes] = await Promise.all([
fetchAdminSummary(),
fetchAdminTimeSeries({ bucket, windowDays })
])
if (!cancelled) {
setSummary(summaryRes)
setTimeSeries(tsRes)
}
} catch (err: unknown) {
if (!cancelled) {
const message =
err instanceof Error && err.message ? err.message : 'Fehler beim Laden des Admin-Dashboards'
setError(message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
void load()
return () => {
cancelled = true
}
}, [bucket, windowDays])
if (loading && !summary) {
return (
<div className="admin-page">
<p className="dashboard-status-msg">Admin-Dashboard wird geladen</p>
</div>
)
}
if (error) {
return (
<div className="admin-page">
<header className="admin-header">
<button type="button" className="btn-back" onClick={onBack}>
<ChevronLeft size={16} />
Zur App
</button>
</header>
<p className="dashboard-status-msg">{error}</p>
</div>
)
}
if (!summary) {
return (
<div className="admin-page">
<p className="dashboard-status-msg">Keine Admin-Daten verfügbar.</p>
</div>
)
}
return (
<div className="admin-page">
<header className="admin-header">
<div className="admin-header-left">
<button type="button" className="btn-back" onClick={onBack}>
<ChevronLeft size={16} />
Zur App
</button>
<div>
<h1 className="admin-title">Admin-Dashboard</h1>
<p className="admin-subtitle">
Übersicht über Nutzung und Wachstum von Kapteins Daagbok.
</p>
</div>
</div>
</header>
<main className="admin-main">
<section className="stats-kpi-grid admin-kpi-grid">
<KpiCard icon={<Users size={20} />} label="Registrierte Benutzer" value={summary.totalUsers} />
<KpiCard icon={<Bookmark size={20} />} label="Logbücher" value={summary.totalLogbooks} />
<KpiCard icon={<Image size={20} />} label="Fotos" value={summary.totalPhotos} />
<KpiCard icon={<Mic size={20} />} label="Sprachmemos" value={summary.totalVoiceMemos} />
<KpiCard icon={<MapPin size={20} />} label="GPS-Tracks" value={summary.totalGpsTracks} />
<KpiCard
icon={<BarChart2 size={20} />}
label="Einträge mit AI-Zusammenfassung"
value={summary.aiSummaryEntries}
/>
</section>
<section className="admin-controls">
<div className="admin-control-group">
<span className="admin-control-label">Zeitraum</span>
<div className="admin-control-buttons">
{[30, 90, 365].map((days) => (
<button
key={days}
type="button"
className={days === windowDays ? 'btn primary' : 'btn secondary'}
onClick={() => setWindowDays(days)}
>
{days} Tage
</button>
))}
</div>
</div>
<div className="admin-control-group">
<span className="admin-control-label">Aggregation</span>
<div className="admin-control-buttons">
{(['day', 'week', 'month'] as AdminTimeBucket[]).map((b) => (
<button
key={b}
type="button"
className={b === bucket ? 'btn primary' : 'btn secondary'}
onClick={() => setBucket(b)}
>
{b === 'day' ? 'Tag' : b === 'week' ? 'Woche' : 'Monat'}
</button>
))}
</div>
</div>
</section>
<section className="admin-charts-grid">
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
</section>
</main>
</div>
)
}
@@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next'
import { LayoutDashboard } from 'lucide-react'
interface AdminHeaderButtonProps {
onClick: () => void
}
export default function AdminHeaderButton({ onClick }: AdminHeaderButtonProps) {
const { t } = useTranslation()
return (
<button
type="button"
className="btn-icon skipper-badge"
onClick={onClick}
title={t('nav.admin')}
aria-label={t('nav.admin')}
>
<LayoutDashboard size={18} aria-hidden="true" />
<span className="skipper-badge__name">{t('nav.admin')}</span>
</button>
)
}
+26 -8
View File
@@ -1,4 +1,4 @@
import { Coffee } from 'lucide-react'
import { Coffee, Mail, Compass } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -15,17 +15,35 @@ export default function AppFooter() {
·
</span>
<span className="app-version-footer__copyright">
© 2026 KnorrLabs/
<a
href="mailto:moin@kapteins-daagbok.eu"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
Markus F.J. Busche
</a>
© 2026
</span>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="knorrlabs-footer-badge"
href="https://dashy.elpatron.me/"
target="_blank"
rel="noopener noreferrer"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
<Compass size={14} aria-hidden="true" />
<span>KnorrLabs</span>
</a>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="mail-footer-badge"
href="mailto:moin@kapteins-daagbok.eu"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
<Mail size={14} aria-hidden="true" />
<span>moin@kapteins-daagbok.eu</span>
</a>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="kofi-footer-badge"
href={KOFI_URL}
+133 -5
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import {
@@ -12,7 +12,8 @@ import {
getKnownUsernames,
forgetUsername,
hasUnlockedLocalSession,
logoutUser
logoutUser,
resolveRestoreUsername
} from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
@@ -27,9 +28,15 @@ import {
interface AuthOnboardingProps {
onAuthenticated: () => void
onOpenDemo?: () => void
/** Server session cookie is valid but the in-memory master key was lost (e.g. after reload). */
restoreSession?: boolean
}
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) {
export default function AuthOnboarding({
onAuthenticated,
onOpenDemo,
restoreSession = false
}: AuthOnboardingProps) {
const { t, i18n } = useTranslation()
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
@@ -60,7 +67,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [isNewRegistration, setIsNewRegistration] = useState(false)
const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const [showStandardLogin, setShowStandardLogin] = useState(false)
const autoUnlockAttempted = useRef(false)
const isRestoreFlow = restoreSession && !showStandardLogin
const passkeyHostOk = isPasskeyCompatibleLocation()
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
@@ -144,6 +154,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
}
}
useEffect(() => {
if (!isRestoreFlow || autoUnlockAttempted.current) return
const user = resolveRestoreUsername()
if (user && hasLocalPin(user)) {
autoUnlockAttempted.current = true
setUsername(user)
setShowPinLogin(true)
return
}
if (user && passkeyHostOk) {
autoUnlockAttempted.current = true
void handleLogin(user)
}
}, [isRestoreFlow, passkeyHostOk])
const handleRecoverySubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!recoveryInput.trim() || !encryptedPayloads) return
@@ -347,10 +374,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.enter_pin_title')}</h2>
<h2>{isRestoreFlow ? t('auth.restore_title') : t('auth.enter_pin_title')}</h2>
</div>
<p className="recovery-warning">
{t('auth.enter_pin_warning')}
{isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}
</p>
<form onSubmit={handlePinLoginSubmit} className="auth-form">
@@ -397,6 +424,12 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
type="button"
className="btn secondary"
onClick={() => {
if (isRestoreFlow) {
setShowPinLogin(false)
setPinLoginInput('')
setError(null)
return
}
void (async () => {
setShowPinLogin(false)
setPinLoginInput('')
@@ -480,6 +513,101 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
)
}
// Render: Session restore (active server cookie, master key lost after reload)
if (isRestoreFlow) {
const restoreUser = resolveRestoreUsername()
const restoreKnownUsers = getKnownUsernames()
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.restore_title')}</h2>
</div>
<p className="recovery-warning">{t('auth.restore_subtitle')}</p>
{loading && (
<p className="dashboard-status-msg" style={{ marginTop: '12px' }}>
{t('auth.restore_unlocking')}
</p>
)}
{error && <div className="auth-error">{error}</div>}
{!loading && (
<div className="auth-actions" style={{ flexDirection: 'column', gap: '10px', marginTop: '16px' }}>
{restoreUser && passkeyHostOk && (
<button
type="button"
className="btn primary"
onClick={() => handleLogin(restoreUser)}
style={{ width: '100%' }}
>
{t('auth.restore_with_passkey', { name: restoreUser })}
</button>
)}
{restoreUser && hasLocalPin(restoreUser) && (
<button
type="button"
className="btn secondary"
onClick={() => {
setUsername(restoreUser)
setShowPinLogin(true)
}}
style={{ width: '100%' }}
>
{t('auth.restore_with_pin')}
</button>
)}
{restoreKnownUsers.length > 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
<span style={{ fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>
{t('auth.quick_login')}
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', width: '100%' }}>
{restoreKnownUsers.map((name) => (
<button
key={name}
type="button"
onClick={() => {
if (hasLocalPin(name)) {
setUsername(name)
setShowPinLogin(true)
} else {
void handleLogin(name)
}
}}
disabled={loading}
className="btn secondary"
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<UserRound size={16} />
{name}
</button>
))}
</div>
</div>
)}
<button
type="button"
className="btn secondary"
onClick={() => {
setShowStandardLogin(true)
setError(null)
}}
style={{ width: '100%' }}
>
{t('auth.restore_other_account')}
</button>
</div>
)}
</div>
)
}
// Render 3: Standard Login / Registration options form
return (
<>
+32 -5
View File
@@ -45,11 +45,38 @@ export default function CreatorAvatar({
let photo: string | null = null
let role = ''
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
const snap = crewSnapshotsById[creatorId]
name = snap.name || ''
photo = snap.photo || null
role = snap.role || ''
if (creatorId && crewSnapshotsById) {
let snap: PersonSnapshot | undefined = crewSnapshotsById[creatorId]
// Fallback: If not found directly by key, search by role or name or active user
if (!snap) {
if (creatorId === 'skipper') {
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
} else {
// Try to match name case-insensitively
snap = Object.values(crewSnapshotsById).find(
(s) => (s.name || '').trim().toLowerCase() === creatorId.trim().toLowerCase()
)
// Try to match active username/userid to the skipper snapshot
if (!snap) {
const activeUsername = localStorage.getItem('active_username')
const activeUserId = localStorage.getItem('active_userid')
if (
(activeUsername && creatorId.toLowerCase() === activeUsername.toLowerCase()) ||
(activeUserId && creatorId === activeUserId)
) {
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
}
}
}
}
if (snap) {
name = snap.name || ''
photo = snap.photo || null
role = snap.role || ''
}
}
// Fallback to active username if owner or no crew pool matches
+21 -7
View File
@@ -713,13 +713,27 @@ export default function LiveLogView({
{ analyticsSource: 'live_log' }
)
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return
if (err instanceof WeatherApiError) {
if (err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'UNAUTHORIZED') {
void showAlert(t('settings.weather_unauthorized'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'NOT_FOUND') {
void showAlert(t('settings.weather_not_found'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'BAD_REQUEST') {
void showAlert(t('settings.weather_bad_request'), t('logs.live_weather_owm_btn'))
return
}
}
console.error('Live log OWM weather failed:', err)
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
+21 -7
View File
@@ -1178,13 +1178,27 @@ export default function LogEntryEditor({
showAlert(t('settings.weather_success'))
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
showAlert(t('logs.weather_offline'))
return
}
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
if (err instanceof WeatherApiError) {
if (err.code === 'OFFLINE') {
showAlert(t('logs.weather_offline'))
return
}
if (err.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
}
if (err.code === 'UNAUTHORIZED') {
showAlert(t('settings.weather_unauthorized'))
return
}
if (err.code === 'NOT_FOUND') {
showAlert(t('settings.weather_not_found'))
return
}
if (err.code === 'BAD_REQUEST') {
showAlert(t('settings.weather_bad_request'))
return
}
}
console.error('Weather prefilling failed:', err)
showAlert(t('settings.weather_error'))
+5 -1
View File
@@ -15,11 +15,13 @@ import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiO
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
import AdminHeaderButton from './AdminHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
onLogout: () => void
onOpenProfile: () => void
onOpenAdmin?: () => void
}
type LogbookSortKey = 'name' | 'date'
@@ -42,7 +44,7 @@ function sortLogbooks(
return sorted
}
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile, onOpenAdmin }: LogbookDashboardProps) {
const { t, i18n } = useTranslation()
const { showConfirm } = useDialog()
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
@@ -388,6 +390,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
<ProfileHeaderButton onClick={onOpenProfile} />
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
{/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
<Languages size={18} />
+14 -2
View File
@@ -43,7 +43,8 @@
"deviation": "Tabel over distraktioner",
"logs": "Indlæg i logbogen",
"stats": "Statistik",
"settings": "Indstillinger"
"settings": "Indstillinger",
"admin": "Admin"
},
"auth": {
"welcome": "Velkommen til Kapteins Daagbok.",
@@ -90,7 +91,15 @@
"use_localhost_link": "Skift til localhost",
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen.",
"restore_checking": "Tjekker session…",
"restore_title": "Gendan session",
"restore_subtitle": "Du er stadig logget ind. Lås din logbog op med passkey eller PIN.",
"restore_unlocking": "Låser op…",
"restore_with_passkey": "Lås op med passkey ({{name}})",
"restore_with_pin": "Lås op med PIN",
"restore_pin_warning": "Indtast din lokale PIN for at låse logbogen op efter genindlæsning.",
"restore_other_account": "Log ind med en anden konto"
},
"pwa": {
"title": "Installer app",
@@ -781,6 +790,9 @@
"no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.",
"weather_success": "Vejrdata hentet med succes!",
"weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.",
"weather_unauthorized": "Hentning af vejrdata mislykkedes. API-nøglen er ugyldig eller ikke autoriseret.",
"weather_not_found": "Hentning af vejrdata mislykkedes. Den angivne placering eller koordinater blev ikke fundet.",
"weather_bad_request": "Hentning af vejrdata mislykkedes. Ingen placering eller GPS-position blev angivet.",
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
"share_title": "Del logbog (skrivebeskyttet)",
+14 -2
View File
@@ -43,7 +43,8 @@
"deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge",
"stats": "Statistik",
"settings": "Einstellungen"
"settings": "Einstellungen",
"admin": "Admin"
},
"auth": {
"welcome": "Willkommen bei Kapteins Daagbok",
@@ -90,7 +91,15 @@
"use_localhost_link": "Zu localhost wechseln",
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden.",
"restore_checking": "Session wird geprüft…",
"restore_title": "Session wiederherstellen",
"restore_subtitle": "Deine Anmeldung ist noch aktiv. Entsperre dein Logbuch mit Passkey oder PIN.",
"restore_unlocking": "Wird entsperrt…",
"restore_with_passkey": "Mit Passkey entsperren ({{name}})",
"restore_with_pin": "Mit PIN entsperren",
"restore_pin_warning": "Gib deine lokale PIN ein, um dein Logbuch nach dem Neuladen zu entsperren.",
"restore_other_account": "Anderer Account anmelden"
},
"pwa": {
"title": "App installieren",
@@ -781,6 +790,9 @@
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
"weather_success": "Wetterdaten erfolgreich abgerufen!",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
"weather_unauthorized": "Wetterdatenabruf fehlgeschlagen. Der API-Schlüssel ist ungültig oder nicht autorisiert.",
"weather_not_found": "Wetterdatenabruf fehlgeschlagen. Der angegebene Ort oder die Koordinaten wurden nicht gefunden.",
"weather_bad_request": "Wetterdatenabruf fehlgeschlagen. Es wurde kein Ort und keine GPS-Position angegeben.",
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
"share_title": "Logbuch teilen (Schreibgeschützt)",
+14 -2
View File
@@ -43,7 +43,8 @@
"deviation": "Deviation Table",
"logs": "Logbook Entries",
"stats": "Statistics",
"settings": "Settings"
"settings": "Settings",
"admin": "Admin"
},
"auth": {
"welcome": "Welcome to Kapteins Daagbok",
@@ -90,7 +91,15 @@
"use_localhost_link": "Switch to localhost",
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again.",
"restore_checking": "Checking session…",
"restore_title": "Restore session",
"restore_subtitle": "You are still signed in. Unlock your logbook with passkey or PIN.",
"restore_unlocking": "Unlocking…",
"restore_with_passkey": "Unlock with passkey ({{name}})",
"restore_with_pin": "Unlock with PIN",
"restore_pin_warning": "Enter your local PIN to unlock your logbook after reload.",
"restore_other_account": "Sign in with another account"
},
"pwa": {
"title": "Install app",
@@ -781,6 +790,9 @@
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
"weather_success": "Weather details fetched successfully!",
"weather_error": "Failed to fetch weather. Check your API key and connection.",
"weather_unauthorized": "Failed to fetch weather. The API key is invalid or unauthorized.",
"weather_not_found": "Failed to fetch weather. The specified location or coordinates were not found.",
"weather_bad_request": "Failed to fetch weather. No location or GPS position was specified.",
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
"gps_error": "Please enter a location or fetch GPS coordinates first.",
"share_title": "Share Logbook (Read-Only)",
+14 -2
View File
@@ -43,7 +43,8 @@
"deviation": "Tabell over distraksjoner",
"logs": "Loggbokoppføringer",
"stats": "Statistikk",
"settings": "Innstillinger"
"settings": "Innstillinger",
"admin": "Admin"
},
"auth": {
"welcome": "Velkommen til Kapteins Daagbok",
@@ -90,7 +91,15 @@
"use_localhost_link": "Bytt til localhost",
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen.",
"restore_checking": "Sjekker økt…",
"restore_title": "Gjenopprett økt",
"restore_subtitle": "Du er fortsatt innlogget. Lås opp loggboken med passkey eller PIN.",
"restore_unlocking": "Låser opp…",
"restore_with_passkey": "Lås opp med passkey ({{name}})",
"restore_with_pin": "Lås opp med PIN",
"restore_pin_warning": "Skriv inn din lokale PIN for å låse opp loggboken etter omlasting.",
"restore_other_account": "Logg inn med en annen konto"
},
"pwa": {
"title": "Installer app",
@@ -781,6 +790,9 @@
"no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.",
"weather_success": "Værdata vellykket hentet!",
"weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.",
"weather_unauthorized": "Henting av værdata mislyktes. API-nøkkelen er ugyldig eller ikke autorisert.",
"weather_not_found": "Henting av værdata mislyktes. Den angitte posisjonen eller koordinatene ble ikke funnet.",
"weather_bad_request": "Henting av værdata mislyktes. Ingen posisjon eller GPS-koordinater ble angitt.",
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
"share_title": "Del loggbok (skrivebeskyttet)",
+14 -2
View File
@@ -43,7 +43,8 @@
"deviation": "Distraktionsbord",
"logs": "Loggboksanteckningar",
"stats": "Statistik",
"settings": "Inställningar"
"settings": "Inställningar",
"admin": "Admin"
},
"auth": {
"welcome": "Välkommen till Kapteins Daagbok",
@@ -90,7 +91,15 @@
"use_localhost_link": "Byt till localhost",
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen.",
"restore_checking": "Kontrollerar session…",
"restore_title": "Återställ session",
"restore_subtitle": "Du är fortfarande inloggad. Lås upp din loggbok med passkey eller PIN.",
"restore_unlocking": "Låser upp…",
"restore_with_passkey": "Lås upp med passkey ({{name}})",
"restore_with_pin": "Lås upp med PIN",
"restore_pin_warning": "Ange din lokala PIN för att låsa upp loggboken efter omladdning.",
"restore_other_account": "Logga in med ett annat konto"
},
"pwa": {
"title": "Installera app",
@@ -781,6 +790,9 @@
"no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.",
"weather_success": "Väderdata har hämtats framgångsrikt!",
"weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.",
"weather_unauthorized": "Hämtning av väderdata misslyckades. API-nyckeln är ogiltig eller inte auktoriserad.",
"weather_not_found": "Hämtning av väderdata misslyckades. Den angivna platsen eller koordinaterna hittades inte.",
"weather_bad_request": "Hämtning av väderdata misslyckades. Ingen plats eller GPS-position angavs.",
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
"share_title": "Aktieloggbok (skrivskyddad)",
+74
View File
@@ -0,0 +1,74 @@
import { ApiError, apiJson } from './api.js'
const ADMIN_BASE = '/api/admin'
export interface AdminMe {
isAdmin: boolean
userId: string
}
export interface AdminSummary {
totalUsers: number
totalLogbooks: number
totalPhotos: number
totalVoiceMemos: number
totalGpsTracks: number
totalCollaborations: number
totalInvitations: number
aiSummaryEntries: number
}
export type AdminTimeBucket = 'day' | 'week' | 'month'
export interface AdminTimeSeriesPoint {
date: string
count: number
}
export interface AdminTimeSeriesMetric {
metric: string
points: AdminTimeSeriesPoint[]
}
export interface AdminTimeSeriesResponse {
bucket: AdminTimeBucket
windowDays: number
series: AdminTimeSeriesMetric[]
}
export async function fetchAdminMe(): Promise<AdminMe> {
return await apiJson<AdminMe>(`${ADMIN_BASE}/me`)
}
/** Returns true only for users listed in server ADMIN_USER_IDS. */
export async function checkAdminAccess(): Promise<boolean> {
try {
await fetchAdminMe()
return true
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
return false
}
return false
}
}
export async function fetchAdminSummary(): Promise<AdminSummary> {
return await apiJson<AdminSummary>(`${ADMIN_BASE}/summary`)
}
export async function fetchAdminTimeSeries(
params: { bucket?: AdminTimeBucket; windowDays?: number } = {}
): Promise<AdminTimeSeriesResponse> {
const search = new URLSearchParams()
if (params.bucket) {
search.set('bucket', params.bucket)
}
if (params.windowDays && Number.isFinite(params.windowDays)) {
search.set('window', String(params.windowDays))
}
const query = search.toString()
const url = query ? `${ADMIN_BASE}/timeseries?${query}` : `${ADMIN_BASE}/timeseries`
return await apiJson<AdminTimeSeriesResponse>(url)
}
+9
View File
@@ -64,6 +64,15 @@ export function persistSessionUserId(userId: string | undefined): void {
}
}
/** Username to use when re-unlocking after reload (active account or sole remembered user). */
export function resolveRestoreUsername(): string | null {
const stored = localStorage.getItem('active_username')
if (stored) return stored
const known = getKnownUsernames()
if (known.length === 1) return known[0]
return null
}
export async function reauthWithPasskey(): Promise<boolean> {
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
method: 'POST'
+23
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import {
hasUnlockedLocalCrypto,
hasUnlockedLocalSession,
resolveRestoreUsername,
setActiveMasterKey
} from './auth.js'
@@ -33,6 +34,28 @@ describe('local session unlock checks', () => {
})
})
describe('resolveRestoreUsername', () => {
beforeEach(() => {
localStorage.clear()
})
it('prefers active_username from storage', () => {
localStorage.setItem('active_username', 'captain')
localStorage.setItem('daagbox_known_users', JSON.stringify(['other']))
expect(resolveRestoreUsername()).toBe('captain')
})
it('falls back to a single remembered user', () => {
localStorage.setItem('daagbox_known_users', JSON.stringify(['solo']))
expect(resolveRestoreUsername()).toBe('solo')
})
it('returns null when multiple users and no active username', () => {
localStorage.setItem('daagbox_known_users', JSON.stringify(['alpha', 'beta']))
expect(resolveRestoreUsername()).toBeNull()
})
})
describe('persistSessionUserId', () => {
beforeEach(() => {
localStorage.clear()
+47
View File
@@ -69,4 +69,51 @@ describe('fetchOpenWeatherCurrent', () => {
expect(trackPlausibleEvent).not.toHaveBeenCalled()
})
it('throws UNAUTHORIZED when status is 401', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ error: 'Unauthorized' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('UNAUTHORIZED')
})
it('throws NOT_FOUND when status is 404', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ error: 'Not Found' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('NOT_FOUND')
})
it('throws BAD_REQUEST when status is 400', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'Bad Request' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('BAD_REQUEST')
})
it('throws BAD_REQUEST when coordinates or query are missing', async () => {
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({}).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('BAD_REQUEST')
expect(apiFetch).not.toHaveBeenCalled()
})
})
+15 -3
View File
@@ -7,9 +7,12 @@ import {
} from './analytics.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED'
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST'
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
constructor(
message: string,
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'WeatherApiError'
this.code = code
@@ -38,7 +41,7 @@ export async function fetchOpenWeatherCurrent(
} else if (params.q?.trim()) {
searchParams.set('q', params.q.trim())
} else {
throw new WeatherApiError('lat/lon or location query required')
throw new WeatherApiError('lat/lon or location query required', 'BAD_REQUEST')
}
const userKey = getOwmApiKeyForActiveUser().trim()
@@ -65,6 +68,15 @@ export async function fetchOpenWeatherCurrent(
if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
}
if (res.status === 401) {
throw new WeatherApiError('Invalid OpenWeatherMap API key', 'UNAUTHORIZED')
}
if (res.status === 404) {
throw new WeatherApiError('Location or coordinates not found', 'NOT_FOUND')
}
if (res.status === 400) {
throw new WeatherApiError('Invalid or missing location parameters', 'BAD_REQUEST')
}
const data = await res.json()
if (!res.ok) {
+29
View File
@@ -33,8 +33,36 @@ function versionJsonPlugin(version: string): Plugin {
}
}
function readPlausibleConfig(): { plausibleEnabled: boolean; plausibleHost: string } {
const host = (process.env.PLAUSIBLE_HOST || 'https://plausible.elpatron.me').replace(/\/$/, '')
const flag = (process.env.PLAUSIBLE_ENABLED ?? 'true').trim().toLowerCase()
const plausibleEnabled = !['false', '0', 'no'].includes(flag)
return { plausibleEnabled, plausibleHost: host }
}
function runtimeConfigPlugin(): Plugin {
return {
name: 'runtime-config',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url !== '/runtime-config.json') return next()
res.setHeader('Content-Type', 'application/json')
res.end(`${JSON.stringify(readPlausibleConfig())}\n`)
})
},
writeBundle(options) {
const outDir = options.dir ?? resolve(__dirname, 'dist')
writeFileSync(
resolve(outDir, 'runtime-config.json'),
`${JSON.stringify(readPlausibleConfig(), null, 2)}\n`
)
}
}
}
// https://vite.dev/config/
export default defineConfig({
envDir: resolve(__dirname, '..'),
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts']
@@ -59,6 +87,7 @@ export default defineConfig({
plugins: [
react(),
versionJsonPlugin(readAppVersion()),
runtimeConfigPlugin(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
+71
View File
@@ -0,0 +1,71 @@
services:
db:
image: postgres:16-alpine
container_name: daagbox-staging-db
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:-daagbox_staging}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./server
dockerfile: Dockerfile
container_name: daagbox-staging-backend
restart: always
environment:
PORT: 5000
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox_staging}?schema=public"
RP_ID: ${RP_ID:-localhost}
ORIGIN: ${ORIGIN:-http://localhost}
TRUST_PROXY: ${TRUST_PROXY:-1}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
NTFY_TOPIC: ${NTFY_TOPIC:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
command: sh -c "npx prisma db push && node dist/index.js"
healthcheck:
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
interval: 15s
timeout: 5s
start_period: 60s
retries: 5
depends_on:
db:
condition: service_healthy
frontend:
build:
context: .
dockerfile: client/Dockerfile
args:
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
container_name: daagbox-staging-frontend
restart: always
environment:
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-false}
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
volumes:
pgdata:
name: daagbox-staging-pgdata
+10
View File
@@ -35,10 +35,17 @@ services:
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
NTFY_TOPIC: ${NTFY_TOPIC:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
command: sh -c "npx prisma db push && node dist/index.js"
healthcheck:
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
interval: 15s
timeout: 5s
start_period: 60s
retries: 5
depends_on:
db:
condition: service_healthy
@@ -51,6 +58,9 @@ services:
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
container_name: daagbox-prod-frontend
restart: always
environment:
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-true}
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ports:
- "80:80"
depends_on:
+111
View File
@@ -0,0 +1,111 @@
# Server-Backup (Produktion)
Automatische und manuelle Sicherung von PostgreSQL, `.env`, `docker-compose.yml` und App-Code (Git-Archiv) auf der Prod-VM.
**Staging:** Kein automatisches Backup — Daten sind bewusst wegwerfbar. Deploy via `update-remotes.sh -dest stage` legt kein Backup an. Zum manuellen Testen auf Staging: `-dest stage` (oder Auto-Fallback, wenn nur `daagbox-staging-db` läuft).
## Was wird gesichert?
| Inhalt | Beschreibung |
|--------|--------------|
| `database.sql.gz` | `pg_dump` aus dem laufenden DB-Container |
| `.env` | Server-Secrets (Sessions, DB-Passwort, VAPID, …) |
| `docker-compose.yml` | Aktive Compose-Datei |
| `app.tar.gz` | `git archive HEAD` — Code-Snapshot |
| `manifest.json` | Timestamp, Git-Tag, SHA, Grund (`cron` / `pre-deploy` / `manual`) |
Backups liegen in `/var/backups/kapteins-daagbok/` (mode 700, root-only). Es werden **maximal 5** Archive aufbewahrt.
## Einmalige Einrichtung (Prod-Server)
```bash
ssh root@10.0.0.25
mkdir -p /var/backups/kapteins-daagbok
chmod 700 /var/backups/kapteins-daagbok
cd /opt/kapteins-daagbok
git pull
chmod +x scripts/backup.sh scripts/restore-backup.sh
./scripts/backup.sh --reason manual
```
## Manuell sichern
```bash
cd /opt/kapteins-daagbok
./scripts/backup.sh
./scripts/backup.sh --reason manual --dry-run # Vorschau ohne Schreiben
```
### Staging (manueller Test)
```bash
cd /opt/kapteins-daagbok-staging
./scripts/backup.sh -dest stage --reason manual
# oder: Auto-Fallback, wenn nur daagbox-staging-db läuft
./scripts/backup.sh --reason manual
```
## Crontab (unbeaufsichtigt)
Beispiel: [`scripts/crontab.prod.example`](../../scripts/crontab.prod.example)
```bash
crontab -e
# Zeile einfügen:
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
```
## Pre-Deploy-Backup
Bei `./scripts/update-remotes.sh -dest prod` wird **vor** dem Git-Sync auf dem Server automatisch ein Backup mit Tag `v{VERSION}-predeploy` erstellt. Schlägt das Backup fehl, wird das Deploy abgebrochen.
Staging-Deploys (`-dest stage`) erstellen **kein** Backup.
## Wiederherstellen
Verfügbare Backups anzeigen:
```bash
./scripts/restore-backup.sh --list
```
Vollständige Wiederherstellung (DB + `.env`, optional Git-Tag checkout):
```bash
./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_YYYYMMDD-HHMMSS_vX.Y.Z.tar.gz
```
Nur Datenbank:
```bash
./scripts/restore-backup.sh --restore PATH --db-only
```
Nur `.env`:
```bash
./scripts/restore-backup.sh --restore PATH --env-only
```
Ohne Rückfragen (Notfall):
```bash
./scripts/restore-backup.sh --restore PATH --full --yes
```
## Vor Passwort-Rotation
Vor [`rotate-postgres-password.sh`](../../scripts/rotate-postgres-password.sh) ein Backup anlegen — siehe auch [postgres-password.md](postgres-password.md):
```bash
./scripts/backup.sh --reason manual
```
## Umgebungsvariablen
| Variable | Prod (default) | Staging (`-dest stage`) |
|----------|----------------|-------------------------|
| `COMPOSE_FILE` | `docker-compose.yml` | `docker-compose.staging.yml` |
| `DB_CONTAINER` | `daagbox-prod-db` | `daagbox-staging-db` |
| `BACKUP_DIR` | `/var/backups/kapteins-daagbok` | gleich |
| `RETENTION` | `5` | `5` |
+13 -6
View File
@@ -1,14 +1,14 @@
# 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`).
Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** (Produktion) und **https://staging.kapteins-daagbok.eu/** (Staging) hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf die App-VMs (`10.0.0.25` Prod, `10.0.0.27` Staging).
## NPM Proxy Host
| Einstellung | Wert |
|-------------|------|
| Domain | `kapteins-daagbok.eu` |
| Domain | `kapteins-daagbok.eu` / `staging.kapteins-daagbok.eu` |
| Scheme | `https` |
| Forward Hostname / IP | `172.16.10.110` (oder Container-Port auf dem Host) |
| Forward Hostname / IP | `10.0.0.25` (Prod) / `10.0.0.27` (Staging) |
| Forward Port | `80` (Frontend-Nginx) |
| Websockets | an, falls genutzt |
| Block Common Exploits | an |
@@ -40,13 +40,20 @@ TRUST_PROXY=1
## 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).
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf.template`](../../client/nginx.conf.template) via Container-Entrypoint (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP optional 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"`.
Konfiguration über `.env` (Frontend-Container):
Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz.
```env
PLAUSIBLE_ENABLED=true
PLAUSIBLE_HOST=https://plausible.elpatron.me
```
Staging-Default: `PLAUSIBLE_ENABLED=false` in [`docker-compose.staging.yml`](../../docker-compose.staging.yml).
Script-Host wird in CSP (`script-src`, `connect-src`) nur bei `PLAUSIBLE_ENABLED=true` freigegeben. `data-domain` ist immer der aktuelle Hostname (Prod vs. Staging getrennt, wenn Staging aktiviert wird).
## Nach Deploy prüfen
+1 -1
View File
@@ -8,7 +8,7 @@
## Empfohlene Schritte
1. **Backup/Snapshot** (hast du laut Vorgabe).
1. **Backup/Snapshot** — auf dem Server: `./scripts/backup.sh --reason manual` (Details: [backup.md](backup.md)).
2. Auf dem Server im Repo:
```bash
cd /opt/kapteins-daagbok
+3 -3
View File
@@ -31,12 +31,12 @@ 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).
[`scripts/update-remotes.sh`](../../scripts/update-remotes.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung bei Prod, vor dem SSH-Deploy).
```bash
./scripts/update-prod.sh
./scripts/update-remotes.sh -dest prod
```
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh`
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest prod`
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
+106
View File
@@ -0,0 +1,106 @@
# Staging-Umgebung
Staging läuft auf **VM3** (`10.0.0.27`) unter **https://staging.kapteins-daagbok.eu/** — hinter Nginx Proxy Manager wie Produktion.
## Unterschiede zu Produktion
| | Staging | Produktion |
|---|---------|------------|
| Host | `10.0.0.27` | `10.0.0.25` |
| Verzeichnis | `/opt/kapteins-daagbok-staging` | `/opt/kapteins-daagbok` |
| Compose | `docker-compose.staging.yml` | `docker-compose.yml` |
| Deploy-Skript | `./scripts/update-remotes.sh -dest stage` | `./scripts/update-remotes.sh -dest prod` |
| Release-Tag | nein | ja (`v*`) |
| Datenbank-Volume | `daagbox-staging-pgdata` | `daagbox-prod-pgdata` |
Staging ist **vollständig isoliert**: eigene DB, Session-Secrets, Passkeys (`RP_ID=staging.kapteins-daagbok.eu`) und optional eigene VAPID-/Ntfy-Konfiguration.
## Erstinstallation (VM3)
```bash
ssh root@10.0.0.27
git clone https://gitea.elpatron.me/elpatron/kapteins-daagbok.git /opt/kapteins-daagbok-staging
cd /opt/kapteins-daagbok-staging
git checkout master
# .env anlegen — Secrets neu generieren, nicht von Prod kopieren
openssl rand -hex 24 # POSTGRES_PASSWORD
openssl rand -base64 48 # SESSION_SECRET
nano .env
docker compose -f docker-compose.staging.yml up -d --build
```
### `.env` (Staging)
```env
ORIGIN=https://staging.kapteins-daagbok.eu
RP_ID=staging.kapteins-daagbok.eu
TRUST_PROXY=1
POSTGRES_USER=postgres
POSTGRES_PASSWORD=<generiert>
POSTGRES_DB=daagbox_staging
SESSION_SECRET=<generiert>
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-staging-feedback
# Analytics aus (Staging soll Prod-Statistik nicht verfälschen)
PLAUSIBLE_ENABLED=false
PLAUSIBLE_HOST=https://plausible.elpatron.me
```
Optional: `VAPID_*`, `OpenWeatherMapAPIKey`, `OpenRouterAPIKey`, `ADMIN_USER_IDS`, `NTFY_TOKEN`.
## Deploy vom Entwicklungsrechner
Führt `npm run check` aus, dann SSH-Deploy ohne Release-Tag:
```bash
./scripts/update-remotes.sh -dest stage
```
Konfiguration via Umgebungsvariablen:
```bash
REMOTE_HOST=10.0.0.27 \
REMOTE_DIR=/opt/kapteins-daagbok-staging \
DEPLOY_BRANCH=master \
./scripts/update-remotes.sh -dest stage
```
Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest stage`
## NPM (VM1)
| Einstellung | Wert |
|-------------|------|
| Domain | `staging.kapteins-daagbok.eu` |
| Forward Hostname / IP | `10.0.0.27` |
| Forward Port | `80` |
| SSL | Let's Encrypt |
Empfohlen: Custom Header `X-Robots-Tag: noindex, nofollow` (Staging nicht indexieren).
Details zu Proxy-Headern und Security: [npm-security.md](npm-security.md).
## Nach Deploy prüfen
1. https://staging.kapteins-daagbok.eu/api/health — `status: ok`
2. Neuen Test-Account registrieren (Prod-Passkeys funktionieren nicht auf Staging)
3. Passkey Login
4. Cookie `daagbok_session`: `Secure`, `HttpOnly`, `SameSite=Lax`
## Daten zurücksetzen
Staging-Daten sind wegwerfbar:
```bash
cd /opt/kapteins-daagbok-staging
docker compose -f docker-compose.staging.yml down
docker volume rm daagbox-staging-pgdata
docker compose -f docker-compose.staging.yml up -d
```
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

+318
View File
@@ -0,0 +1,318 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Kapteins Daagbok — Sharepic (Portrait)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 1080px;
height: 1920px;
font-family: 'Plus Jakarta Sans', sans-serif;
color: #e2e8f0;
background: #0f172a;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
padding: 100px 80px;
background:
radial-gradient(circle at 50% 10%, rgba(56, 189, 248, 0.18) 0%, transparent 45%),
radial-gradient(circle at 50% 90%, rgba(134, 59, 255, 0.22) 0%, transparent 45%),
linear-gradient(180deg, #090d16 0%, #111827 50%, #090d16 100%);
position: relative;
}
/* Subtle background grid pattern */
body::after {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Outer border */
.outer-border {
position: absolute;
inset: 40px;
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 30px;
pointer-events: none;
z-index: 1;
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
z-index: 2;
text-align: center;
margin-top: 40px;
}
.logo {
width: 140px;
height: 140px;
object-fit: contain;
filter: drop-shadow(0 8px 24px rgba(56, 189, 248, 0.3));
}
.title-group h1 {
font-size: 64px;
font-weight: 800;
letter-spacing: -0.03em;
color: #ffffff;
line-height: 1.1;
background: linear-gradient(135deg, #ffffff 60%, #94a3b8 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.title-group p {
font-size: 26px;
color: #38bdf8;
font-weight: 600;
margin-top: 8px;
letter-spacing: -0.01em;
}
.main-content {
width: 100%;
display: flex;
flex-direction: column;
gap: 50px;
z-index: 2;
}
.intro-text {
font-size: 26px;
line-height: 1.6;
color: #cbd5e1;
font-weight: 400;
text-align: center;
padding: 0 20px;
}
.intro-text strong {
color: #ffffff;
font-weight: 600;
}
.features-card {
background: rgba(30, 41, 59, 0.45);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
padding: 50px 60px;
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.4);
position: relative;
}
.features-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: 24px;
padding: 1px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.card-title {
font-size: 20px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
margin-bottom: 35px;
display: flex;
align-items: center;
gap: 12px;
}
.card-title::after {
content: "";
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.15);
}
.badge-premium {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b;
font-size: 16px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 6px 14px;
border-radius: 8px;
}
.features-list {
display: flex;
flex-direction: column;
gap: 30px;
list-style: none;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 20px;
font-size: 24px;
line-height: 1.4;
color: #cbd5e1;
font-weight: 500;
}
.feature-icon {
color: #38bdf8;
font-size: 26px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 3px;
text-shadow: 0 0 10px rgba(56, 189, 248, 0.6);
}
.bottom-section {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
z-index: 2;
margin-bottom: 40px;
}
.cta-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
}
.cta-badge {
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
color: #0f172a;
font-size: 32px;
font-weight: 800;
padding: 20px 45px;
border-radius: 16px;
letter-spacing: -0.02em;
box-shadow: 0 10px 30px rgba(56, 189, 248, 0.25);
}
.qr-code {
width: 160px;
height: 160px;
background: #ffffff;
padding: 10px;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
}
.qr-code img {
width: 100%;
height: 100%;
object-fit: contain;
}
footer {
font-size: 18px;
color: #64748b;
text-align: center;
}
footer strong {
color: #94a3b8;
font-weight: 600;
}
</style>
</head>
<body>
<div class="outer-border"></div>
<div class="brand">
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
<div class="title-group">
<h1>Kapteins Daagbok</h1>
<p>Digitales Yacht-Logbuch</p>
</div>
</div>
<div class="main-content">
<p class="intro-text">
Führe dein Bordlogbuch modern & digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und <strong>auch offline</strong> auf See nutzbar.
</p>
<div class="features-card">
<div class="card-title">Top Features <span class="badge-premium">Kostenlos & Werbefrei</span></div>
<ul class="features-list">
<li class="feature-item">
<span class="feature-icon"></span>
<span>Nautisches Logbuch-Format & Streckenstatistik</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Offline-first PWA — läuft auf allen Smartphones & Tablets</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Ende-zu-Ende Verschlüsselung (Zero-Knowledge)</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Einfache passwortlose Passkey-Anmeldung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>GPS-Track-Upload & automatische NMEA-Erfassung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Crew-Einladung zur gemeinsamen Logbuch-Arbeit</span>
</li>
</ul>
</div>
</div>
<div class="bottom-section">
<div class="cta-container">
<div class="cta-badge">
kapteins-daagbok.eu
</div>
<div class="qr-code">
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR Code" />
</div>
</div>
<footer>
<strong>Kapteins Daagbok</strong> ist ein werbefreies, privates Hobbyprojekt.
</footer>
</div>
</body>
</html>
+320
View File
@@ -0,0 +1,320 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Kapteins Daagbok — Sharepic</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 1200px;
height: 630px;
font-family: 'Plus Jakarta Sans', sans-serif;
color: #e2e8f0;
background: #0f172a;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 60px 80px;
background:
radial-gradient(circle at 90% 10%, rgba(56, 189, 248, 0.15) 0%, transparent 45%),
radial-gradient(circle at 10% 90%, rgba(134, 59, 255, 0.18) 0%, transparent 45%),
linear-gradient(165deg, #090d16 0%, #111827 50%, #090d16 100%);
position: relative;
}
/* Subtle background grid pattern */
body::after {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Outer border */
.outer-border {
position: absolute;
inset: 30px;
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 20px;
pointer-events: none;
z-index: 1;
}
.content-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
width: 100%;
z-index: 2;
position: relative;
gap: 50px;
}
.left-col {
flex: 1.1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 30px;
}
.brand {
display: flex;
align-items: center;
gap: 20px;
}
.logo {
width: 80px;
height: 80px;
object-fit: contain;
filter: drop-shadow(0 4px 12px rgba(56, 189, 248, 0.3));
}
.title-group h1 {
font-size: 44px;
font-weight: 800;
letter-spacing: -0.03em;
color: #ffffff;
line-height: 1.1;
background: linear-gradient(135deg, #ffffff 60%, #94a3b8 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.title-group p {
font-size: 18px;
color: #38bdf8;
font-weight: 600;
margin-top: 4px;
letter-spacing: -0.01em;
}
.intro-text {
font-size: 20px;
line-height: 1.6;
color: #cbd5e1;
font-weight: 400;
}
.intro-text strong {
color: #ffffff;
font-weight: 600;
}
.cta-container {
display: flex;
align-items: center;
gap: 20px;
}
.cta-badge {
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
color: #0f172a;
font-size: 22px;
font-weight: 800;
padding: 14px 28px;
border-radius: 12px;
letter-spacing: -0.02em;
box-shadow: 0 4px 20px rgba(56, 189, 248, 0.25);
}
.qr-code {
width: 60px;
height: 60px;
background: #ffffff;
padding: 4px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.qr-code img {
width: 100%;
height: 100%;
object-fit: contain;
}
.right-col {
flex: 0.9;
display: flex;
flex-direction: column;
justify-content: center;
}
.features-card {
background: rgba(30, 41, 59, 0.45);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 35px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
position: relative;
}
.features-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: 20px;
padding: 1px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.features-list {
display: flex;
flex-direction: column;
gap: 18px;
list-style: none;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 14px;
font-size: 16px;
line-height: 1.4;
color: #cbd5e1;
font-weight: 500;
}
.feature-icon {
color: #38bdf8;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 2px;
text-shadow: 0 0 8px rgba(56, 189, 248, 0.6);
}
.badge-premium {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 4px 10px;
border-radius: 6px;
margin-left: auto;
}
.card-title {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.card-title::after {
content: "";
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.15);
}
footer {
position: absolute;
bottom: 45px;
left: 80px;
font-size: 12px;
color: #64748b;
z-index: 2;
}
footer strong {
color: #94a3b8;
font-weight: 600;
}
</style>
</head>
<body>
<div class="outer-border"></div>
<div class="content-wrapper">
<div class="left-col">
<div class="brand">
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
<div class="title-group">
<h1>Kapteins Daagbok</h1>
<p>Digitales Yacht-Logbuch</p>
</div>
</div>
<p class="intro-text">
Führe dein Bordlogbuch modern & digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und <strong>auch offline</strong> auf See nutzbar.
</p>
<div class="cta-container">
<div class="cta-badge">
kapteins-daagbok.eu
</div>
<div class="qr-code">
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR Code" />
</div>
</div>
</div>
<div class="right-col">
<div class="features-card">
<div class="card-title">Top Features <span class="badge-premium">Kostenlos & Werbefrei</span></div>
<ul class="features-list">
<li class="feature-item">
<span class="feature-icon"></span>
<span>Nautisches Logbuch-Format & Streckenstatistik</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Offline-first PWA — läuft auf allen Smartphones & Tablets</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Ende-zu-Ende Verschlüsselung (Zero-Knowledge)</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Einfache passwortlose Passkey-Anmeldung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>GPS-Track-Upload & automatische NMEA-Erfassung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Crew-Einladung zur gemeinsamen Logbuch-Arbeit</span>
</li>
</ul>
</div>
</div>
</div>
<footer>
<strong>Kapteins Daagbok</strong> ist ein werbefreies, privates Hobbyprojekt.
</footer>
</body>
</html>
+11 -2
View File
@@ -1,12 +1,21 @@
# Plausible Custom Events
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js` auf der Domain `kapteins-daagbok.eu`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
**Konfiguration** (`.env`, Frontend-Container / Vite-Dev):
```env
PLAUSIBLE_ENABLED=true # Staging: false
PLAUSIBLE_HOST=https://plausible.elpatron.me
```
Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der aktuelle Hostname. CSP in Nginx enthält `PLAUSIBLE_HOST` nur wenn aktiviert.
**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
## Setup
1. Script in `client/index.html` (bereits eingebunden)
1. `PLAUSIBLE_*` in `.env` setzen (Prod: enabled, Staging: disabled empfohlen)
2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
## Event-Übersicht
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bash
# Create a local backup of PostgreSQL, .env, docker-compose and app (git archive).
#
# Run on the server in repo root (/opt/kapteins-daagbok on production).
#
# Usage:
# ./scripts/backup.sh
# ./scripts/backup.sh -dest stage # Staging-Container (daagbox-staging-db)
# ./scripts/backup.sh --reason cron
# ./scripts/backup.sh --reason pre-deploy --tag v0.1.1.20
# ./scripts/backup.sh --dry-run
#
# Environment overrides:
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, RETENTION, ENV_FILE
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
ENV_FILE="${ENV_FILE:-.env}"
RETENTION="${RETENTION:-5}"
DEST="prod"
REASON="manual"
EXPLICIT_TAG=""
DRY_RUN=0
COMPOSE_FILE=""
DB_CONTAINER=""
apply_dest_config() {
local dest="$1"
local force="${2:-0}"
if [[ "$dest" == "stage" ]]; then
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.staging.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-staging-db"
fi
else
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-prod-db"
fi
fi
}
usage() {
sed -n '2,14p' "$0"
echo ""
echo "Options:"
echo " -dest prod|stage Target environment (default: prod)"
echo " --reason cron|pre-deploy|manual Backup trigger (default: manual)"
echo " --tag TAG Git tag label (e.g. v0.1.1.20 for pre-deploy)"
echo " --dry-run Show actions without writing backup"
echo " -h, --help Show this help"
}
while [ $# -gt 0 ]; do
case "$1" in
-dest)
DEST="${2:?-dest requires an argument}"
shift 2
;;
-dest=*)
DEST="${1#*=}"
shift
;;
--reason)
REASON="${2:?--reason requires an argument}"
shift 2
;;
--tag)
EXPLICIT_TAG="${2:?--tag requires an argument}"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
case "$DEST" in
prod|stage) ;;
*)
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
exit 1
;;
esac
apply_dest_config "$DEST"
case "$REASON" in
cron|pre-deploy|manual) ;;
*)
echo "Error: invalid --reason '$REASON' (use cron, pre-deploy, or manual)" >&2
exit 1
;;
esac
if [ ! -f "$COMPOSE_FILE" ]; then
echo "Error: $COMPOSE_FILE not found" >&2
exit 1
fi
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
GIT_TAG="$EXPLICIT_TAG"
if [ -z "$GIT_TAG" ]; then
GIT_TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"
fi
if [ -z "$GIT_TAG" ] && [ -f VERSION ]; then
GIT_TAG="v$(tr -d '[:space:]' < VERSION)"
fi
if [ -z "$GIT_TAG" ]; then
GIT_TAG="unknown"
fi
APP_VERSION="$(tr -d '[:space:]' < VERSION 2>/dev/null || echo unknown)"
TAG_SLUG="${GIT_TAG}"
if [ "$REASON" = "pre-deploy" ]; then
TAG_SLUG="${GIT_TAG}-predeploy"
fi
TAG_SLUG="${TAG_SLUG//\//-}"
ARCHIVE_NAME="kapteins-daagbok_${TIMESTAMP}_${TAG_SLUG}.tar.gz"
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}"
echo "Backup: reason=$REASON tag=$GIT_TAG sha=${GIT_SHA:0:8}$ARCHIVE_PATH"
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] Would dump database from $DB_CONTAINER"
echo "[dry-run] Would copy $ENV_FILE and $COMPOSE_FILE"
echo "[dry-run] Would create git archive"
echo "[dry-run] Would write manifest and pack to $ARCHIVE_PATH"
echo "[dry-run] Would apply retention (keep $RETENTION)"
exit 0
fi
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found (run from repo root)" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
apply_dest_config stage 1
DEST="stage"
else
echo "Error: DB container '$DB_CONTAINER' not found" >&2
exit 1
fi
fi
if [ "$(docker inspect -f '{{.State.Running}}' "$DB_CONTAINER")" != "true" ]; then
echo "Error: DB container '$DB_CONTAINER' is not running" >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
echo "Dumping PostgreSQL ($POSTGRES_DB)..."
export PGPASSWORD="$POSTGRES_PASSWORD"
if ! docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-acl \
| gzip > "$WORK_DIR/database.sql.gz"; then
echo "Error: pg_dump failed" >&2
exit 1
fi
unset PGPASSWORD
cp "$ENV_FILE" "$WORK_DIR/.env"
chmod 600 "$WORK_DIR/.env"
cp "$COMPOSE_FILE" "$WORK_DIR/docker-compose.yml"
echo "Creating app snapshot (git archive)..."
if git archive --format=tar HEAD | gzip > "$WORK_DIR/app.tar.gz"; then
:
else
echo "Warning: git archive failed — backup continues without app.tar.gz" >&2
rm -f "$WORK_DIR/app.tar.gz"
fi
python3 - "$WORK_DIR/manifest.json" <<PY
import json
import socket
import sys
from datetime import datetime, timezone
manifest = {
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"local_timestamp": "${TIMESTAMP}",
"destination": "${DEST}",
"reason": "${REASON}",
"git_tag": "${GIT_TAG}",
"git_sha": "${GIT_SHA}",
"app_version": "${APP_VERSION}",
"compose_file": "${COMPOSE_FILE}",
"db_container": "${DB_CONTAINER}",
"postgres_db": "${POSTGRES_DB}",
"hostname": socket.gethostname(),
"archive_name": "${ARCHIVE_NAME}",
}
with open(sys.argv[1], "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2)
f.write("\n")
PY
echo "Packing backup archive..."
tar -czf "$ARCHIVE_PATH" -C "$WORK_DIR" \
manifest.json database.sql.gz .env docker-compose.yml \
$( [ -f "$WORK_DIR/app.tar.gz" ] && echo app.tar.gz )
chmod 600 "$ARCHIVE_PATH"
echo "Applying retention (keep last $RETENTION backups)..."
mapfile -t ALL_BACKUPS < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
if [ "${#ALL_BACKUPS[@]}" -gt "$RETENTION" ]; then
for ((i = RETENTION; i < ${#ALL_BACKUPS[@]}; i++)); do
echo "Removing old backup: ${ALL_BACKUPS[$i]}"
rm -f "${ALL_BACKUPS[$i]}"
done
fi
echo "Backup complete: $ARCHIVE_PATH"
echo "$ARCHIVE_PATH"
+11
View File
@@ -0,0 +1,11 @@
# Kapteins Daagbok — Production backup cron (install on 10.0.0.25)
#
# Install:
# crontab -e
# (paste the line below)
#
# Ensure log directory exists:
# touch /var/log/kapteins-backup.log && chmod 600 /var/log/kapteins-backup.log
# Daily backup at 03:00 UTC — keeps last 5 in /var/backups/kapteins-daagbok/
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* Generates the sharepic PNGs (landscape & portrait) from HTML files
*
* Usage:
* node scripts/generate-sharepic.mjs
*/
import { execSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '..')
const clientDir = resolve(repoRoot, 'client')
const marketingDir = resolve(repoRoot, 'docs/marketing')
const require = createRequire(resolve(clientDir, 'package.json'))
function isMissingBrowserError(err) {
const msg = err instanceof Error ? err.message : String(err)
return msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')
}
async function ensurePlaywrightChromium(playwright) {
try {
const browser = await playwright.chromium.launch({ headless: true })
await browser.close()
return
} catch (err) {
if (!isMissingBrowserError(err)) throw err
}
console.log('Playwright Chromium fehlt — installiere Browser (einmalig)…')
execSync('npx playwright install chromium', {
cwd: clientDir,
stdio: 'inherit'
})
}
function loadPlaywright() {
try {
return require('playwright')
} catch {
console.error('Fehlende Abhängigkeit: "npm install -D playwright" in client/ ausführen.')
process.exit(1)
}
}
async function renderSharepic(browser, htmlName, pngName, width, height) {
const htmlPath = resolve(marketingDir, htmlName)
const pngPath = resolve(marketingDir, pngName)
console.log(`Generating sharepic (${width}x${height}) from ${htmlName}...`)
const context = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: 2 // High-DPI for crisp text
})
const page = await context.newPage()
try {
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
await page.screenshot({
path: pngPath,
type: 'png'
})
console.log('Successfully wrote:', pngPath)
} finally {
await page.close()
}
}
async function main() {
const playwright = loadPlaywright()
await ensurePlaywrightChromium(playwright)
const browser = await playwright.chromium.launch({ headless: true })
try {
// Landscape 1200x630
await renderSharepic(browser, 'sharepic.html', 'kapteins-daagbok-sharepic.png', 1200, 630)
// Portrait 1080x1920
await renderSharepic(browser, 'sharepic-portrait.html', 'kapteins-daagbok-sharepic-portrait.png', 1080, 1920)
} finally {
await browser.close()
}
}
main().catch((err) => {
console.error('Error generating sharepics:', err)
process.exit(1)
})
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env bash
# Restore server backup created by scripts/backup.sh
#
# Usage:
# ./scripts/restore-backup.sh --list
# ./scripts/restore-backup.sh -dest stage --restore PATH
# ./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_....tar.gz
#
# Environment overrides:
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, BACKEND_CONTAINER, ENV_FILE
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
ENV_FILE="${ENV_FILE:-.env}"
MAX_WAIT=90
DEST="prod"
COMPOSE_FILE=""
DB_CONTAINER=""
BACKEND_CONTAINER=""
apply_dest_config() {
local dest="$1"
local force="${2:-0}"
if [[ "$dest" == "stage" ]]; then
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.staging.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-staging-db"
fi
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
BACKEND_CONTAINER="daagbox-staging-backend"
fi
else
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-prod-db"
fi
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
BACKEND_CONTAINER="daagbox-prod-backend"
fi
fi
}
MODE="full"
RESTORE_PATH=""
LIST=0
ASSUME_YES=0
usage() {
sed -n '2,10p' "$0"
echo ""
echo "Options:"
echo " -dest prod|stage Target environment (default: prod)"
echo " --list List available backups"
echo " --restore PATH Backup archive to restore"
echo " --full Restore DB + .env (default)"
echo " --db-only Restore database only"
echo " --env-only Restore .env only"
echo " --yes Skip confirmation prompts"
echo " -h, --help Show this help"
}
confirm() {
local prompt="$1"
if [ "$ASSUME_YES" -eq 1 ]; then
return 0
fi
read -r -p "$prompt [y/N] " answer
[[ "$answer" =~ ^[yY]$ ]]
}
while [ $# -gt 0 ]; do
case "$1" in
-dest)
DEST="${2:?-dest requires an argument}"
shift 2
;;
-dest=*)
DEST="${1#*=}"
shift
;;
--list)
LIST=1
shift
;;
--restore)
RESTORE_PATH="${2:?--restore requires a path}"
shift 2
;;
--full)
MODE="full"
shift
;;
--db-only)
MODE="db-only"
shift
;;
--env-only)
MODE="env-only"
shift
;;
--yes)
ASSUME_YES=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
case "$DEST" in
prod|stage) ;;
*)
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
exit 1
;;
esac
apply_dest_config "$DEST"
list_backups() {
if [ ! -d "$BACKUP_DIR" ]; then
echo "No backup directory: $BACKUP_DIR"
return 0
fi
local found=0
while IFS= read -r archive; do
found=1
echo "=== $archive ==="
tar -xOf "$archive" manifest.json 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "(no manifest)"
echo ""
done < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
if [ "$found" -eq 0 ]; then
echo "No backups found in $BACKUP_DIR"
fi
}
if [ "$LIST" -eq 1 ]; then
list_backups
exit 0
fi
if [ -z "$RESTORE_PATH" ]; then
echo "Error: --restore PATH or --list required" >&2
usage >&2
exit 1
fi
if [ ! -f "$RESTORE_PATH" ]; then
echo "Error: backup archive not found: $RESTORE_PATH" >&2
exit 1
fi
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
echo "Extracting $RESTORE_PATH..."
tar -xzf "$RESTORE_PATH" -C "$WORK_DIR"
if [ ! -f "$WORK_DIR/manifest.json" ]; then
echo "Error: manifest.json missing in backup archive" >&2
exit 1
fi
MANIFEST="$WORK_DIR/manifest.json"
echo "Backup manifest:"
python3 -m json.tool "$MANIFEST"
echo ""
read_manifest_field() {
python3 -c "import json; print(json.load(open('$MANIFEST')).get('$1', '') or '')"
}
MANIFEST_COMPOSE="$(read_manifest_field compose_file)"
MANIFEST_DB="$(read_manifest_field db_container)"
MANIFEST_DEST="$(read_manifest_field destination)"
if [ -n "$MANIFEST_COMPOSE" ]; then
COMPOSE_FILE="$MANIFEST_COMPOSE"
fi
if [ -n "$MANIFEST_DB" ]; then
DB_CONTAINER="$MANIFEST_DB"
fi
if [ -n "$MANIFEST_DEST" ]; then
if [[ "$MANIFEST_DEST" == "stage" ]]; then
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}"
else
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
fi
fi
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
apply_dest_config stage 1
DEST="stage"
else
echo "Error: DB container '$DB_CONTAINER' not found" >&2
exit 1
fi
fi
GIT_TAG="$(read_manifest_field git_tag)"
GIT_SHA="$(read_manifest_field git_sha)"
BACKUP_TS="$(read_manifest_field local_timestamp)"
if ! confirm "Restore backup from $BACKUP_TS (tag: $GIT_TAG)? Mode: $MODE"; then
echo "Aborted."
exit 1
fi
restore_env() {
if [ ! -f "$WORK_DIR/.env" ]; then
echo "Error: .env missing in backup archive" >&2
exit 1
fi
if [ -f "$ENV_FILE" ]; then
BAK="${ENV_FILE}.bak-restore.$(date +%Y%m%d-%H%M%S)"
cp "$ENV_FILE" "$BAK"
echo "Current $ENV_FILE saved to $BAK"
fi
cp "$WORK_DIR/.env" "$ENV_FILE"
chmod 600 "$ENV_FILE"
echo "Restored $ENV_FILE"
}
restore_db() {
if [ ! -f "$WORK_DIR/database.sql.gz" ]; then
echo "Error: database.sql.gz missing in backup archive" >&2
exit 1
fi
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE required for database restore" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
echo "Error: DB container '$DB_CONTAINER' not found" >&2
exit 1
fi
echo "Stopping backend before database restore..."
docker compose -f "$COMPOSE_FILE" stop backend || true
echo "Resetting public schema..."
export PGPASSWORD="$POSTGRES_PASSWORD"
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
DROP SCHEMA IF EXISTS public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO "${POSTGRES_USER}";
GRANT ALL ON SCHEMA public TO public;
SQL
echo "Importing database dump..."
gunzip -c "$WORK_DIR/database.sql.gz" | docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1
unset PGPASSWORD
echo "Database restore complete."
}
wait_for_healthy() {
echo "Starting stack and waiting for health..."
docker compose -f "$COMPOSE_FILE" up -d
local counter=0
while [ "$counter" -lt "$MAX_WAIT" ]; do
local status
status="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)"
if [ "$status" = "healthy" ]; then
echo "Backend is healthy."
return 0
fi
if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then
echo "API health check OK."
return 0
fi
sleep 1
counter=$((counter + 1))
printf "."
done
echo ""
echo "Warning: backend did not become healthy in time." >&2
return 1
}
case "$MODE" in
env-only)
restore_env
;;
db-only)
restore_db
wait_for_healthy || exit 1
;;
full)
restore_env
restore_db
wait_for_healthy || exit 1
if [ -f "$WORK_DIR/app.tar.gz" ] && [ "$GIT_TAG" != "unknown" ]; then
if confirm "Checkout app code at tag $GIT_TAG? (git fetch + checkout)"; then
git fetch --tags origin
git checkout "$GIT_TAG"
echo "Checked out $GIT_TAG"
fi
fi
;;
*)
echo "Error: unknown mode $MODE" >&2
exit 1
;;
esac
echo "Restore finished (mode: $MODE, tag: $GIT_TAG, sha: ${GIT_SHA:0:8})."
+1 -1
View File
@@ -81,7 +81,7 @@ require_node_toolchain() {
echo ""
echo "On the production host, prefer updating the running stack:"
echo " docker compose -f docker-compose.yml up -d --build"
echo " # or from your workstation: ./scripts/update-prod.sh"
echo " # or from your workstation: ./scripts/update-remotes.sh -dest prod"
exit 1
}
-254
View File
@@ -1,254 +0,0 @@
#!/bin/bash
set -euo pipefail
# Remote deployment configuration
# Override any of these via environment variables if needed, e.g.:
# REMOTE_HOST=192.168.1.10 ./scripts/update-prod.sh
REMOTE_USER="${REMOTE_USER:-root}"
REMOTE_HOST="${REMOTE_HOST:-10.0.0.25}"
REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok}"
REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}"
# Configuration
COMPOSE_FILE="docker-compose.yml"
BACKEND_CONTAINER="daagbox-prod-backend"
MAX_WAIT=35
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_FILE="$REPO_ROOT/VERSION"
DEFAULT_VERSION="0.1.0.0"
echo "=================================================="
echo " Kapteins Daagbok Prod Environment Update "
echo "=================================================="
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "=================================================="
cd "$REPO_ROOT"
read_current_version() {
if [ -f "$VERSION_FILE" ]; then
tr -d '[:space:]' < "$VERSION_FILE"
return
fi
local latest_tag
latest_tag="$(git tag -l 'v*' --sort=-v:refname | head -n 1 || true)"
if [ -n "$latest_tag" ]; then
echo "${latest_tag#v}"
return
fi
echo "$DEFAULT_VERSION"
}
bump_patch_version() {
local version="$1"
local major minor patch build
IFS='.' read -r major minor patch build <<< "$version"
major="${major:-0}"
minor="${minor:-1}"
patch="${patch:-0}"
build="${build:-0}"
build=$((10#$build + 1))
echo "${major}.${minor}.${patch}.${build}"
}
ensure_clean_git_tree() {
if [ -z "$(git status --porcelain)" ]; then
return 0
fi
echo ""
echo "Uncommitted local changes detected:"
git status --short
echo ""
read -r -p "Commit all changes now before release? [y/N] " answer
if [[ ! "$answer" =~ ^[yY]$ ]]; then
echo "Aborting: working tree is not clean."
exit 1
fi
read -r -p "Commit message: " commit_message
if [ -z "$commit_message" ]; then
echo "Aborting: commit message is required."
exit 1
fi
git add -A
git commit -m "$commit_message"
}
prepare_release() {
local current_version release_version next_version tag_name
ensure_clean_git_tree
current_version="$(read_current_version)"
release_version="$current_version"
next_version="$(bump_patch_version "$current_version")"
tag_name="v${release_version}"
if git rev-parse "$tag_name" >/dev/null 2>&1; then
echo "Error: Git tag '$tag_name' already exists."
exit 1
fi
echo "$next_version" > "$VERSION_FILE"
git add "$VERSION_FILE"
git commit -m "chore: release ${tag_name}"
git tag -a "$tag_name" -m "Release ${tag_name}"
echo ""
echo "Prepared release ${tag_name}"
echo " Released: ${tag_name}"
echo " Next prep: v${next_version}"
echo ""
read -r -p "Push commit and tag to origin? [Y/n] " push_answer
if [[ ! "$push_answer" =~ ^[nN]$ ]]; then
current_branch="$(git branch --show-current)"
git push origin "$current_branch"
git push origin "$tag_name"
echo "Pushed ${current_branch} and ${tag_name} to origin."
else
echo "Skipped push. Remote host must receive this commit/tag manually."
fi
export APP_VERSION="$release_version"
}
prepare_release
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
else
echo "=================================================="
echo " Pre-deploy checks (local)"
echo "=================================================="
"$SCRIPT_DIR/predeploy-check.sh"
fi
echo "=================================================="
echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "=================================================="
# Run the whole update procedure remotely over SSH.
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$REMOTE_HOST" "$APP_VERSION" <<'REMOTE_SCRIPT'
set -uo pipefail
REMOTE_DIR="$1"
COMPOSE_FILE="$2"
BACKEND_CONTAINER="$3"
MAX_WAIT="$4"
REMOTE_HOST="$5"
APP_VERSION="$6"
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
echo "Syncing repository from origin..."
CURRENT_BRANCH="$(git branch --show-current)"
if [ -z "$CURRENT_BRANCH" ]; then
echo "Error: Could not determine current Git branch."
exit 1
fi
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
echo "Warning: Local changes on deployment host will be discarded."
fi
git fetch --tags origin
if [ $? -ne 0 ]; then
echo "Error: Git fetch failed."
exit 1
fi
git reset --hard "origin/${CURRENT_BRANCH}"
if [ $? -ne 0 ]; then
echo "Error: Git reset to origin/${CURRENT_BRANCH} failed."
exit 1
fi
REMOTE_VERSION="$(tr -d '[:space:]' < VERSION)"
if [ "$REMOTE_VERSION" != "$APP_VERSION" ]; then
echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})."
echo " Building deployed release v${APP_VERSION}."
fi
export APP_VERSION="$APP_VERSION"
echo "Rebuilding Docker images without cache (APP_VERSION=${APP_VERSION})..."
docker compose -f "$COMPOSE_FILE" build --no-cache
if [ $? -ne 0 ]; then
echo "Error: Docker compose build failed."
exit 1
fi
echo "Starting updated container stack..."
docker compose -f "$COMPOSE_FILE" up -d
if [ $? -ne 0 ]; then
echo "Error: Failed to spin up docker-compose stack."
exit 1
fi
echo "Cleaning up old/unused Docker resources..."
docker system prune -f
if [ $? -ne 0 ]; then
echo "Warning: Docker system prune failed to run completely."
fi
echo "Waiting for services to become healthy..."
COUNTER=0
IS_READY=false
while [ $COUNTER -lt $MAX_WAIT ]; do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null)
if [ "$STATUS" = "healthy" ]; then
IS_READY=true
break
fi
sleep 1
COUNTER=$((COUNTER + 1))
printf "."
done
echo ""
echo "=================================================="
echo "Container Statuses:"
docker compose -f "$COMPOSE_FILE" ps
echo "=================================================="
if [ "$IS_READY" = true ]; then
echo "SUCCESS: Production environment updated and healthy!"
echo " -> Version: v${APP_VERSION}"
echo " -> App Frontend (Nginx): http://${REMOTE_HOST}"
echo " -> Backend API Health: http://${REMOTE_HOST}/api/health"
echo "=================================================="
else
echo "WARNING: Backend did not transition to healthy in time."
echo "Check backend container logs for details:"
echo " -> docker compose logs backend"
echo "=================================================="
exit 3
fi
REMOTE_SCRIPT
REMOTE_EXIT=$?
echo "=================================================="
if [ $REMOTE_EXIT -eq 0 ]; then
echo "Remote update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})."
elif [ $REMOTE_EXIT -eq 3 ]; then
echo "Remote update finished, but the backend was not healthy in time on ${REMOTE_TARGET}."
else
echo "Remote update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})."
fi
echo "=================================================="
exit $REMOTE_EXIT
+432
View File
@@ -0,0 +1,432 @@
#!/bin/bash
set -euo pipefail
usage() {
cat <<EOF
Usage: $(basename "$0") [-dest prod|stage]
Deploy Kapteins Daagbok to production or staging.
-dest prod Production (default): release tag, bump VERSION, deploy to 10.0.0.25
-dest stage Staging: no release tag, deploy branch to 10.0.0.27
Environment overrides (optional):
REMOTE_HOST, REMOTE_USER, REMOTE_DIR, COMPOSE_FILE, BACKEND_CONTAINER
DEPLOY_BRANCH (stage only, default: master)
SKIP_PREDEPLOY_CHECK=1
Local repo must be clean and match origin before deploy (git fetch + compare HEAD).
Examples:
$(basename "$0") -dest prod
$(basename "$0") -dest stage
DEPLOY_BRANCH=feature/foo $(basename "$0") -dest stage
EOF
}
DEST="prod"
while [[ $# -gt 0 ]]; do
case "$1" in
-dest)
if [[ $# -lt 2 ]]; then
echo "Error: -dest requires an argument (prod or stage)." >&2
usage
exit 1
fi
DEST="$2"
shift 2
;;
-dest=*)
DEST="${1#*=}"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: Unknown argument: $1" >&2
usage
exit 1
;;
esac
done
case "$DEST" in
prod|stage) ;;
*)
echo "Error: Invalid -dest '$DEST' (use prod or stage)." >&2
usage
exit 1
;;
esac
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_FILE="$REPO_ROOT/VERSION"
DEFAULT_VERSION="0.1.0.0"
MAX_WAIT=90
REMOTE_USER="${REMOTE_USER:-root}"
if [[ "$DEST" == "stage" ]]; then
REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}"
REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok-staging}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.staging.yml}"
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}"
APP_URL="${APP_URL:-https://staging.kapteins-daagbok.eu}"
DEPLOY_BRANCH="${DEPLOY_BRANCH:-master}"
ENV_LABEL="Staging"
else
REMOTE_HOST="${REMOTE_HOST:-10.0.0.25}"
REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
APP_URL="${APP_URL:-https://kapteins-daagbok.eu}"
DEPLOY_BRANCH=""
ENV_LABEL="Production"
fi
REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}"
echo "=================================================="
echo " Kapteins Daagbok ${ENV_LABEL} Update"
echo "=================================================="
echo "Destination: ${DEST}"
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
if [[ "$DEST" == "stage" ]]; then
echo "Branch: ${DEPLOY_BRANCH}"
fi
echo "URL: ${APP_URL}"
echo "=================================================="
cd "$REPO_ROOT"
read_current_version() {
if [ -f "$VERSION_FILE" ]; then
tr -d '[:space:]' < "$VERSION_FILE"
return
fi
local latest_tag
latest_tag="$(git tag -l 'v*' --sort=-v:refname | head -n 1 || true)"
if [ -n "$latest_tag" ]; then
echo "${latest_tag#v}"
return
fi
echo "$DEFAULT_VERSION"
}
bump_patch_version() {
local version="$1"
local major minor patch build
IFS='.' read -r major minor patch build <<< "$version"
major="${major:-0}"
minor="${minor:-1}"
patch="${patch:-0}"
build="${build:-0}"
build=$((10#$build + 1))
echo "${major}.${minor}.${patch}.${build}"
}
ensure_clean_git_tree() {
if [ -z "$(git status --porcelain)" ]; then
return 0
fi
echo ""
echo "Uncommitted local changes detected:"
git status --short
echo ""
read -r -p "Commit all changes now before release? [y/N] " answer
if [[ ! "$answer" =~ ^[yY]$ ]]; then
echo "Aborting: working tree is not clean."
exit 1
fi
read -r -p "Commit message: " commit_message
if [ -z "$commit_message" ]; then
echo "Aborting: commit message is required."
exit 1
fi
git add -A
git commit -m "$commit_message"
}
ensure_local_sync_with_origin() {
local branch="$1"
local local_sha origin_sha current_branch
if [ -z "$branch" ]; then
echo "Error: deploy branch is not set." >&2
exit 1
fi
if [ -n "$(git status --porcelain)" ]; then
echo "Error: Working tree is not clean. Commit or stash changes before deploying." >&2
git status --short
exit 1
fi
current_branch="$(git branch --show-current)"
if [ -z "$current_branch" ]; then
echo "Error: Detached HEAD — checkout branch '$branch' before deploying." >&2
exit 1
fi
if [ "$current_branch" != "$branch" ]; then
echo "Error: On branch '$current_branch', expected '$branch'." >&2
exit 1
fi
echo "Syncing with origin..."
git fetch --tags origin
if [ $? -ne 0 ]; then
echo "Error: git fetch origin failed." >&2
exit 1
fi
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then
echo "Error: origin/${branch} does not exist." >&2
exit 1
fi
local_sha="$(git rev-parse HEAD)"
origin_sha="$(git rev-parse "origin/${branch}")"
if [ "$local_sha" = "$origin_sha" ]; then
echo "Local branch '$branch' matches origin/${branch} ($(git rev-parse --short HEAD))."
return 0
fi
echo "Error: Local '$branch' is not in sync with origin/${branch}." >&2
echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2
echo " origin: $(git rev-parse --short "origin/${branch}") $(git log -1 --format='%s' "origin/${branch}")" >&2
if git merge-base --is-ancestor "$local_sha" "origin/${branch}" 2>/dev/null; then
echo "Hint: run 'git pull' to fast-forward." >&2
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then
echo "Hint: run 'git push origin ${branch}' before deploying." >&2
else
echo "Hint: branches have diverged — reconcile manually before deploying." >&2
fi
exit 1
}
prepare_release() {
local current_version release_version next_version tag_name
ensure_clean_git_tree
current_version="$(read_current_version)"
release_version="$current_version"
next_version="$(bump_patch_version "$current_version")"
tag_name="v${release_version}"
if git rev-parse "$tag_name" >/dev/null 2>&1; then
echo "Error: Git tag '$tag_name' already exists."
exit 1
fi
echo "$next_version" > "$VERSION_FILE"
git add "$VERSION_FILE"
git commit -m "chore: release ${tag_name}"
git tag -a "$tag_name" -m "Release ${tag_name}"
echo ""
echo "Prepared release ${tag_name}"
echo " Released: ${tag_name}"
echo " Next prep: v${next_version}"
echo ""
read -r -p "Push commit and tag to origin? [Y/n] " push_answer
if [[ ! "$push_answer" =~ ^[nN]$ ]]; then
current_branch="$(git branch --show-current)"
git push origin "$current_branch"
git push origin "$tag_name"
echo "Pushed ${current_branch} and ${tag_name} to origin."
else
echo "Skipped push. Remote host must receive this commit/tag manually."
fi
export APP_VERSION="$release_version"
}
if [[ "$DEST" == "prod" ]]; then
prepare_release
ensure_local_sync_with_origin "$(git branch --show-current)"
else
ensure_local_sync_with_origin "$DEPLOY_BRANCH"
APP_VERSION="$(read_current_version)"
fi
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
else
echo "=================================================="
echo " Pre-deploy checks (local)"
echo "=================================================="
"$SCRIPT_DIR/predeploy-check.sh"
fi
echo "=================================================="
echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "=================================================="
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" <<'REMOTE_SCRIPT'
set -uo pipefail
REMOTE_DIR="$1"
COMPOSE_FILE="$2"
BACKEND_CONTAINER="$3"
MAX_WAIT="$4"
APP_URL="$5"
APP_VERSION="$6"
DEST="$7"
DEPLOY_BRANCH="${8:-}"
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
echo "Warning: Local changes on deployment host will be discarded."
fi
if [[ "$DEST" == "prod" ]]; then
echo "Creating pre-deploy backup..."
if [ -x "./scripts/backup.sh" ]; then
if ! ./scripts/backup.sh --reason pre-deploy --tag "v${APP_VERSION}"; then
echo "Error: Pre-deploy backup failed. Aborting update."
exit 1
fi
else
echo "Warning: scripts/backup.sh not found or not executable — skipping backup."
fi
fi
if [[ "$DEST" == "stage" ]]; then
echo "Syncing repository from origin/${DEPLOY_BRANCH}..."
git fetch origin
if [ $? -ne 0 ]; then
echo "Error: Git fetch failed."
exit 1
fi
git checkout "$DEPLOY_BRANCH" 2>/dev/null || git checkout -b "$DEPLOY_BRANCH" "origin/${DEPLOY_BRANCH}"
git reset --hard "origin/${DEPLOY_BRANCH}"
if [ $? -ne 0 ]; then
echo "Error: Git reset to origin/${DEPLOY_BRANCH} failed."
exit 1
fi
else
echo "Syncing repository from origin..."
CURRENT_BRANCH="$(git branch --show-current)"
if [ -z "$CURRENT_BRANCH" ]; then
echo "Error: Could not determine current Git branch."
exit 1
fi
git fetch --tags origin
if [ $? -ne 0 ]; then
echo "Error: Git fetch failed."
exit 1
fi
git reset --hard "origin/${CURRENT_BRANCH}"
if [ $? -ne 0 ]; then
echo "Error: Git reset to origin/${CURRENT_BRANCH} failed."
exit 1
fi
REMOTE_VERSION="$(tr -d '[:space:]' < VERSION)"
if [ "$REMOTE_VERSION" != "$APP_VERSION" ]; then
echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})."
echo " Building deployed release v${APP_VERSION}."
fi
fi
export APP_VERSION="$APP_VERSION"
if [[ "$DEST" == "prod" ]]; then
echo "Rebuilding Docker images without cache (APP_VERSION=${APP_VERSION})..."
docker compose -f "$COMPOSE_FILE" build --no-cache
else
echo "Rebuilding Docker images (APP_VERSION=${APP_VERSION})..."
docker compose -f "$COMPOSE_FILE" build
fi
if [ $? -ne 0 ]; then
echo "Error: Docker compose build failed."
exit 1
fi
echo "Starting updated container stack..."
docker compose -f "$COMPOSE_FILE" up -d
if [ $? -ne 0 ]; then
echo "Error: Failed to spin up docker-compose stack."
exit 1
fi
echo "Cleaning up old/unused Docker resources..."
docker system prune -f || echo "Warning: Docker system prune failed."
echo "Waiting for services to become healthy..."
COUNTER=0
IS_READY=false
while [ $COUNTER -lt $MAX_WAIT ]; do
STATUS=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)
if [ "$STATUS" = "healthy" ]; then
IS_READY=true
break
fi
# End-to-end fallback via frontend nginx (covers missing/stale container health state)
if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then
IS_READY=true
break
fi
sleep 1
COUNTER=$((COUNTER + 1))
printf "."
done
echo ""
echo "=================================================="
echo "Container Statuses:"
docker compose -f "$COMPOSE_FILE" ps
echo "=================================================="
if [ "$IS_READY" = true ]; then
echo "SUCCESS: ${DEST} environment updated and healthy!"
echo " -> Version: v${APP_VERSION}"
echo " -> App Frontend: ${APP_URL}"
echo " -> Backend API Health: ${APP_URL}/api/health"
echo "=================================================="
else
echo "WARNING: Backend did not transition to healthy in time."
echo "Check backend container logs:"
echo " -> docker compose -f ${COMPOSE_FILE} logs backend"
echo "=================================================="
exit 3
fi
REMOTE_SCRIPT
REMOTE_EXIT=$?
echo "=================================================="
if [ $REMOTE_EXIT -eq 0 ]; then
echo "${ENV_LABEL} update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})."
elif [ $REMOTE_EXIT -eq 3 ]; then
echo "${ENV_LABEL} update finished, but the backend was not healthy in time on ${REMOTE_TARGET}."
else
echo "${ENV_LABEL} update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})."
fi
echo "=================================================="
exit $REMOTE_EXIT
+25
View File
@@ -0,0 +1,25 @@
const ADMIN_ENV_KEY = 'ADMIN_USER_IDS'
export function getAdminUserIds(): Set<string> {
const raw = process.env[ADMIN_ENV_KEY]
if (!raw) {
return new Set()
}
const ids = raw
.split(',')
.map((id) => id.trim())
.filter((id) => id.length > 0)
if (ids.length === 0) {
return new Set()
}
return new Set(ids)
}
export function isAdminUserId(userId: string): boolean {
const adminIds = getAdminUserIds()
return adminIds.has(userId)
}
+2
View File
@@ -12,6 +12,7 @@ import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js'
import aiRouter from './routes/ai.js'
import feedbackRouter from './routes/feedback.js'
import adminRouter from './routes/admin.js'
import { prisma } from './db.js'
import { buildCorsOptions } from './cors.js'
@@ -121,6 +122,7 @@ export function createApp(): express.Express {
app.use('/api/weather', weatherRouter)
app.use('/api/ai', aiRouter)
app.use('/api/feedback', feedbackRouter)
app.use('/api/admin', adminRouter)
app.get('/api/health', async (_req, res) => {
try {
+19
View File
@@ -1,5 +1,6 @@
import type { Request, Response, NextFunction } from 'express'
import { hasValidReauth, readSessionFromRequest } from '../session.js'
import { isAdminUserId } from '../adminConfig.js'
export interface AuthedRequest extends Request {
userId: string
@@ -31,3 +32,21 @@ export function requireReauth(req: Request, res: Response, next: NextFunction):
;(req as AuthedRequest).session = session
next()
}
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
const session = readSessionFromRequest(req)
if (!session) {
res.status(401).json({ error: 'Unauthorized: valid session required' })
return
}
if (!isAdminUserId(session.userId)) {
res.status(403).json({ error: 'Forbidden: admin access required' })
return
}
;(req as AuthedRequest).userId = session.userId
;(req as AuthedRequest).session = session
next()
}
+151
View File
@@ -0,0 +1,151 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { requireUser, requireAdmin, type AuthedRequest } from '../middleware/auth.js'
const router = Router()
router.get('/me', requireUser, requireAdmin, (req, res) => {
const { userId } = req as AuthedRequest
res.json({ isAdmin: true, userId })
})
router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
try {
const [totalUsers, totalLogbooks, totalPhotos, totalVoiceMemos, totalGpsTracks, totalCollaborations, totalInvitations, aiSummaryEntries] =
await Promise.all([
prisma.user.count(),
prisma.logbook.count(),
prisma.photoPayload.count(),
prisma.voiceMemoPayload.count(),
prisma.gpsTrackPayload.count(),
prisma.collaboration.count(),
prisma.invitation.count(),
prisma.aiSummaryUsage.count()
])
res.json({
totalUsers,
totalLogbooks,
totalPhotos,
totalVoiceMemos,
totalGpsTracks,
totalCollaborations,
totalInvitations,
aiSummaryEntries
})
} catch (error: unknown) {
console.error('admin/summary error', error)
res.status(500).json({ error: 'Failed to load admin summary' })
}
})
type TimeBucket = 'day' | 'week' | 'month'
interface TimeSeriesPoint {
date: string
count: number
}
interface TimeSeries {
metric: string
points: TimeSeriesPoint[]
}
function normalizeBucket(value: string | undefined | null): TimeBucket {
if (value === 'week' || value === 'month') return value
return 'day'
}
function parseWindowDays(raw: string | undefined | null): number {
const n = raw ? Number.parseInt(raw, 10) : NaN
if (!Number.isFinite(n) || n <= 0) return 90
return Math.min(n, 365)
}
function startOfDay(date: Date): Date {
const d = new Date(date)
d.setUTCHours(0, 0, 0, 0)
return d
}
function startOfWeek(date: Date): Date {
const d = startOfDay(date)
const day = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() - (day - 1))
return d
}
function startOfMonth(date: Date): Date {
const d = startOfDay(date)
d.setUTCDate(1)
return d
}
function bucketDate(date: Date, bucket: TimeBucket): string {
const base =
bucket === 'week' ? startOfWeek(date) : bucket === 'month' ? startOfMonth(date) : startOfDay(date)
return base.toISOString().slice(0, 10)
}
async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<TimeSeries[]> {
const since = new Date()
since.setUTCDate(since.getUTCDate() - windowDays)
const [users, logbooks, photos] = await Promise.all([
prisma.user.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true }
}),
prisma.logbook.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true }
}),
prisma.photoPayload.findMany({
where: { updatedAt: { gte: since } },
select: { updatedAt: true }
})
])
function aggregate(dates: Date[], metric: string): TimeSeries {
const map = new Map<string, number>()
for (const d of dates) {
const key = bucketDate(d, bucket)
map.set(key, (map.get(key) ?? 0) + 1)
}
const points = Array.from(map.entries())
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([date, count]) => ({ date, count }))
return { metric, points }
}
return [
aggregate(
users.map((u) => u.createdAt),
'users_created'
),
aggregate(
logbooks.map((l) => l.createdAt),
'logbooks_created'
),
aggregate(
photos.map((p) => p.updatedAt),
'photos_updated'
)
]
}
router.get('/timeseries', requireUser, requireAdmin, async (req, res) => {
try {
const bucket = normalizeBucket(typeof req.query.bucket === 'string' ? req.query.bucket : undefined)
const windowDays = parseWindowDays(typeof req.query.window === 'string' ? req.query.window : undefined)
const series = await buildTimeSeries(bucket, windowDays)
res.json({ bucket, windowDays, series })
} catch (error: unknown) {
console.error('admin/timeseries error', error)
res.status(500).json({ error: 'Failed to load admin time series' })
}
})
export default router