Compare commits
12 Commits
v0.1.1.16
...
a2180a302c
| Author | SHA1 | Date | |
|---|---|---|---|
| a2180a302c | |||
| cd29115233 | |||
| e4b07ca896 | |||
| f0c3cacb06 | |||
| 5821e20086 | |||
| aff8d1517d | |||
| f4d6b11414 | |||
| 968e81f4fb | |||
| 10835c9def | |||
| cdbc618521 | |||
| f75fe42910 | |||
| 212775ffdc |
+12
-1
@@ -15,6 +15,11 @@ DeepLAPIKey=
|
|||||||
# Production (kapteins-daagbok.eu):
|
# Production (kapteins-daagbok.eu):
|
||||||
# RP_ID=kapteins-daagbok.eu
|
# RP_ID=kapteins-daagbok.eu
|
||||||
# ORIGIN=https://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
|
RP_ID=localhost
|
||||||
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||||
ORIGIN=http://localhost:5173
|
ORIGIN=http://localhost:5173
|
||||||
@@ -37,7 +42,7 @@ ORIGIN=http://localhost:5173
|
|||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
|
|
||||||
# Admin dashboard access — comma-separated list of User IDs (UUIDs)
|
# Admin dashboard access — comma-separated list of User IDs (UUIDs)
|
||||||
# Example: ADMIN_USER_IDS=e6bcd493-80a0-400f-8a27-43c9cdce6e29,11111111-2222-3333-4444-555555555555
|
# Example: ADMIN_USER_IDS=11111111-2222-3333-4444-555555555555,22222222-3333-4444-5555-666666666666
|
||||||
ADMIN_USER_IDS=
|
ADMIN_USER_IDS=
|
||||||
|
|
||||||
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
|
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
|
||||||
@@ -51,3 +56,9 @@ VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
|||||||
NTFY_SERVER=https://ntfy.sh
|
NTFY_SERVER=https://ntfy.sh
|
||||||
NTFY_TOPIC=kapteins-daagbok-feedback
|
NTFY_TOPIC=kapteins-daagbok-feedback
|
||||||
NTFY_TOKEN=tk_example_ntfy_access_token
|
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
|
||||||
|
|||||||
@@ -251,15 +251,25 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ
|
|||||||
|
|
||||||
|
|
||||||
```bash
|
```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.
|
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
||||||
|
|
||||||
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
|
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
|
## Dokumentation
|
||||||
|
|
||||||
| Dokument | Inhalt |
|
| Dokument | Inhalt |
|
||||||
@@ -267,6 +277,7 @@ 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/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/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/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
|
||||||
|
| [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/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
||||||
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
||||||
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
||||||
|
|||||||
+7
-4
@@ -18,15 +18,18 @@ RUN npm run build
|
|||||||
FROM nginx:1.25-alpine
|
FROM nginx:1.25-alpine
|
||||||
WORKDIR /usr/share/nginx/html
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
# Copy custom Nginx configuration
|
RUN apk add --no-cache gettext
|
||||||
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
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 built assets from builder
|
||||||
COPY --from=builder /app/dist .
|
COPY --from=builder /app/dist .
|
||||||
|
|
||||||
# Expose HTTP port
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Health check to verify Nginx is actively running
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
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
|
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
|||||||
Executable
+26
@@ -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
@@ -22,6 +22,7 @@
|
|||||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||||
<meta name="theme-color" content="#0b0c10" />
|
<meta name="theme-color" content="#0b0c10" />
|
||||||
<script src="/appearance-bootstrap.js"></script>
|
<script src="/appearance-bootstrap.js"></script>
|
||||||
|
<script src="/plausible-bootstrap.js"></script>
|
||||||
<script src="/bootstrap-watchdog.js"></script>
|
<script src="/bootstrap-watchdog.js"></script>
|
||||||
<link rel="apple-touch-icon" href="/logo.png" />
|
<link rel="apple-touch-icon" href="/logo.png" />
|
||||||
<meta property="og:type" content="website" />
|
<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: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" content="https://kapteins-daagbok.eu/logo.png" />
|
||||||
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
|
<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>
|
<title>Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+2
-51
@@ -1,51 +1,2 @@
|
|||||||
server {
|
# Generated at container start from PLAUSIBLE_* — see client/nginx.conf.template and docker-entrypoint.sh
|
||||||
listen 80;
|
# Local Docker Compose uses the template via client/Dockerfile entrypoint.
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
|
})
|
||||||
|
})()
|
||||||
+93
-8
@@ -5025,16 +5025,68 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.admin-page {
|
.admin-page {
|
||||||
padding: 16px;
|
padding: 12px 12px 20px;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-header-left {
|
.admin-header-left {
|
||||||
flex-direction: column;
|
display: grid;
|
||||||
gap: 12px;
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 10px;
|
||||||
|
row-gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-kpi-grid {
|
.admin-header-left .btn-back {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5137,6 +5189,36 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.stats-kpi-value {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
@@ -5952,13 +6034,15 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
.app-tour-root {
|
.app-tour-root {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 10000;
|
/* Above .app-tour-target-active (10001) so tooltip/backdrop stay topmost */
|
||||||
|
z-index: 10010;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-tour-backdrop {
|
.app-tour-backdrop {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
background: rgba(2, 6, 23, 0.62);
|
background: rgba(2, 6, 23, 0.62);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
@@ -5976,7 +6060,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
0 0 32px rgba(56, 189, 248, 0.5),
|
0 0 32px rgba(56, 189, 248, 0.5),
|
||||||
0 12px 40px rgba(0, 0, 0, 0.35);
|
0 12px 40px rgba(0, 0, 0, 0.35);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10001;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.app-tour-active .app-tour-target-active {
|
body.app-tour-active .app-tour-target-active {
|
||||||
@@ -5987,7 +6071,8 @@ body.app-tour-active .app-tour-target-active {
|
|||||||
|
|
||||||
.app-tour-tooltip {
|
.app-tour-tooltip {
|
||||||
position: fixed;
|
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;
|
box-sizing: border-box;
|
||||||
width: min(420px, calc(100vw - 32px));
|
width: min(420px, calc(100vw - 32px));
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
|
|||||||
+19
-1
@@ -97,6 +97,8 @@ function App() {
|
|||||||
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
||||||
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
|
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
|
||||||
const [isAdminUser, setIsAdminUser] = useState(false)
|
const [isAdminUser, setIsAdminUser] = useState(false)
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false)
|
||||||
|
const [serverSessionActive, setServerSessionActive] = useState(false)
|
||||||
|
|
||||||
const syncQueueCount = useLiveQuery(
|
const syncQueueCount = useLiveQuery(
|
||||||
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
||||||
@@ -316,6 +318,8 @@ function App() {
|
|||||||
const session = await checkServerSession()
|
const session = await checkServerSession()
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
||||||
|
setServerSessionActive(session.authenticated)
|
||||||
|
|
||||||
if (session.authenticated) {
|
if (session.authenticated) {
|
||||||
persistSessionUserId(session.userId)
|
persistSessionUserId(session.userId)
|
||||||
}
|
}
|
||||||
@@ -335,6 +339,10 @@ function App() {
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.warn('Session restore failed:', err)
|
console.warn('Session restore failed:', err)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSessionChecked(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -618,7 +626,17 @@ function App() {
|
|||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-screen">
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
getKnownUsernames,
|
getKnownUsernames,
|
||||||
forgetUsername,
|
forgetUsername,
|
||||||
hasUnlockedLocalSession,
|
hasUnlockedLocalSession,
|
||||||
logoutUser
|
logoutUser,
|
||||||
|
resolveRestoreUsername
|
||||||
} from '../services/auth.js'
|
} from '../services/auth.js'
|
||||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||||
@@ -27,9 +28,15 @@ import {
|
|||||||
interface AuthOnboardingProps {
|
interface AuthOnboardingProps {
|
||||||
onAuthenticated: () => void
|
onAuthenticated: () => void
|
||||||
onOpenDemo?: () => 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 { t, i18n } = useTranslation()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -60,7 +67,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
const [isNewRegistration, setIsNewRegistration] = useState(false)
|
const [isNewRegistration, setIsNewRegistration] = useState(false)
|
||||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||||
const [showHelp, setShowHelp] = useState(false)
|
const [showHelp, setShowHelp] = useState(false)
|
||||||
|
const [showStandardLogin, setShowStandardLogin] = useState(false)
|
||||||
|
const autoUnlockAttempted = useRef(false)
|
||||||
|
|
||||||
|
const isRestoreFlow = restoreSession && !showStandardLogin
|
||||||
const passkeyHostOk = isPasskeyCompatibleLocation()
|
const passkeyHostOk = isPasskeyCompatibleLocation()
|
||||||
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
|
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) => {
|
const handleRecoverySubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!recoveryInput.trim() || !encryptedPayloads) return
|
if (!recoveryInput.trim() || !encryptedPayloads) return
|
||||||
@@ -347,10 +374,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
<div className="auth-card glass">
|
<div className="auth-card glass">
|
||||||
<div className="auth-header">
|
<div className="auth-header">
|
||||||
<KeyRound className="auth-icon accent" size={48} />
|
<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>
|
</div>
|
||||||
<p className="recovery-warning">
|
<p className="recovery-warning">
|
||||||
{t('auth.enter_pin_warning')}
|
{isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handlePinLoginSubmit} className="auth-form">
|
<form onSubmit={handlePinLoginSubmit} className="auth-form">
|
||||||
@@ -397,6 +424,12 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (isRestoreFlow) {
|
||||||
|
setShowPinLogin(false)
|
||||||
|
setPinLoginInput('')
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setShowPinLogin(false)
|
setShowPinLogin(false)
|
||||||
setPinLoginInput('')
|
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
|
// Render 3: Standard Login / Registration options form
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -91,7 +91,15 @@
|
|||||||
"use_localhost_link": "Skift til localhost",
|
"use_localhost_link": "Skift til localhost",
|
||||||
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
|
"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_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": {
|
"pwa": {
|
||||||
"title": "Installer app",
|
"title": "Installer app",
|
||||||
|
|||||||
@@ -91,7 +91,15 @@
|
|||||||
"use_localhost_link": "Zu localhost wechseln",
|
"use_localhost_link": "Zu localhost wechseln",
|
||||||
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
|
"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_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": {
|
"pwa": {
|
||||||
"title": "App installieren",
|
"title": "App installieren",
|
||||||
|
|||||||
@@ -91,7 +91,15 @@
|
|||||||
"use_localhost_link": "Switch to localhost",
|
"use_localhost_link": "Switch to localhost",
|
||||||
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
|
"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_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": {
|
"pwa": {
|
||||||
"title": "Install app",
|
"title": "Install app",
|
||||||
|
|||||||
@@ -91,7 +91,15 @@
|
|||||||
"use_localhost_link": "Bytt til localhost",
|
"use_localhost_link": "Bytt til localhost",
|
||||||
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
|
"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_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": {
|
"pwa": {
|
||||||
"title": "Installer app",
|
"title": "Installer app",
|
||||||
|
|||||||
@@ -91,7 +91,15 @@
|
|||||||
"use_localhost_link": "Byt till localhost",
|
"use_localhost_link": "Byt till localhost",
|
||||||
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
|
"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_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": {
|
"pwa": {
|
||||||
"title": "Installera app",
|
"title": "Installera app",
|
||||||
|
|||||||
@@ -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> {
|
export async function reauthWithPasskey(): Promise<boolean> {
|
||||||
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
|
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
hasUnlockedLocalCrypto,
|
hasUnlockedLocalCrypto,
|
||||||
hasUnlockedLocalSession,
|
hasUnlockedLocalSession,
|
||||||
|
resolveRestoreUsername,
|
||||||
setActiveMasterKey
|
setActiveMasterKey
|
||||||
} from './auth.js'
|
} 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', () => {
|
describe('persistSessionUserId', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
|||||||
@@ -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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
envDir: resolve(__dirname, '..'),
|
||||||
test: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
include: ['src/**/*.test.ts']
|
include: ['src/**/*.test.ts']
|
||||||
@@ -59,6 +87,7 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
versionJsonPlugin(readAppVersion()),
|
versionJsonPlugin(readAppVersion()),
|
||||||
|
runtimeConfigPlugin(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
strategies: 'injectManifest',
|
strategies: 'injectManifest',
|
||||||
srcDir: 'src',
|
srcDir: 'src',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -35,10 +35,17 @@ services:
|
|||||||
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
||||||
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||||
|
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||||
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||||
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||||
command: sh -c "npx prisma db push && node dist/index.js"
|
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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -51,6 +58,9 @@ services:
|
|||||||
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
|
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
|
||||||
container_name: daagbox-prod-frontend
|
container_name: daagbox-prod-frontend
|
||||||
restart: always
|
restart: always
|
||||||
|
environment:
|
||||||
|
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-true}
|
||||||
|
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Deployment: Nginx Proxy Manager & Security (Sprint 1)
|
# 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
|
## NPM Proxy Host
|
||||||
|
|
||||||
| Einstellung | Wert |
|
| Einstellung | Wert |
|
||||||
|-------------|------|
|
|-------------|------|
|
||||||
| Domain | `kapteins-daagbok.eu` |
|
| Domain | `kapteins-daagbok.eu` / `staging.kapteins-daagbok.eu` |
|
||||||
| Scheme | `https` |
|
| 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) |
|
| Forward Port | `80` (Frontend-Nginx) |
|
||||||
| Websockets | an, falls genutzt |
|
| Websockets | an, falls genutzt |
|
||||||
| Block Common Exploits | an |
|
| Block Common Exploits | an |
|
||||||
@@ -40,13 +40,20 @@ TRUST_PROXY=1
|
|||||||
## Security-Header
|
## Security-Header
|
||||||
|
|
||||||
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden.
|
- **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
|
### 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
|
## Nach Deploy prüfen
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ cd server && npm test
|
|||||||
|
|
||||||
## Nach erfolgreichem Check
|
## 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
|
```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)).
|
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
# Plausible Custom Events
|
# 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.ä.).
|
**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
|
||||||
|
|
||||||
## Setup
|
## 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)
|
2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
|
||||||
|
|
||||||
## Event-Übersicht
|
## Event-Übersicht
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ require_node_toolchain() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo "On the production host, prefer updating the running stack:"
|
echo "On the production host, prefer updating the running stack:"
|
||||||
echo " docker compose -f docker-compose.yml up -d --build"
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,28 +2,102 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Remote deployment configuration
|
usage() {
|
||||||
# Override any of these via environment variables if needed, e.g.:
|
cat <<EOF
|
||||||
# REMOTE_HOST=192.168.1.10 ./scripts/update-prod.sh
|
Usage: $(basename "$0") [-dest prod|stage]
|
||||||
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
|
Deploy Kapteins Daagbok to production or staging.
|
||||||
COMPOSE_FILE="docker-compose.yml"
|
|
||||||
BACKEND_CONTAINER="daagbox-prod-backend"
|
-dest prod Production (default): release tag, bump VERSION, deploy to 10.0.0.25
|
||||||
MAX_WAIT=35
|
-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
|
||||||
|
|
||||||
|
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)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
VERSION_FILE="$REPO_ROOT/VERSION"
|
VERSION_FILE="$REPO_ROOT/VERSION"
|
||||||
DEFAULT_VERSION="0.1.0.0"
|
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 "=================================================="
|
||||||
echo " Kapteins Daagbok Prod Environment Update "
|
echo " Kapteins Daagbok ${ENV_LABEL} Update"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
echo "Destination: ${DEST}"
|
||||||
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
|
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||||
|
if [[ "$DEST" == "stage" ]]; then
|
||||||
|
echo "Branch: ${DEPLOY_BRANCH}"
|
||||||
|
fi
|
||||||
|
echo "URL: ${APP_URL}"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
@@ -123,7 +197,11 @@ prepare_release() {
|
|||||||
export APP_VERSION="$release_version"
|
export APP_VERSION="$release_version"
|
||||||
}
|
}
|
||||||
|
|
||||||
prepare_release
|
if [[ "$DEST" == "prod" ]]; then
|
||||||
|
prepare_release
|
||||||
|
else
|
||||||
|
APP_VERSION="$(read_current_version)"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
|
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
|
||||||
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
|
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
|
||||||
@@ -135,56 +213,77 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
|
||||||
# Run the whole update procedure remotely over SSH.
|
|
||||||
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
|
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
|
||||||
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$REMOTE_HOST" "$APP_VERSION" <<'REMOTE_SCRIPT'
|
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" <<'REMOTE_SCRIPT'
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
REMOTE_DIR="$1"
|
REMOTE_DIR="$1"
|
||||||
COMPOSE_FILE="$2"
|
COMPOSE_FILE="$2"
|
||||||
BACKEND_CONTAINER="$3"
|
BACKEND_CONTAINER="$3"
|
||||||
MAX_WAIT="$4"
|
MAX_WAIT="$4"
|
||||||
REMOTE_HOST="$5"
|
APP_URL="$5"
|
||||||
APP_VERSION="$6"
|
APP_VERSION="$6"
|
||||||
|
DEST="$7"
|
||||||
|
DEPLOY_BRANCH="$8"
|
||||||
|
|
||||||
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
|
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
|
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
|
||||||
echo "Warning: Local changes on deployment host will be discarded."
|
echo "Warning: Local changes on deployment host will be discarded."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git fetch --tags origin
|
if [[ "$DEST" == "stage" ]]; then
|
||||||
if [ $? -ne 0 ]; then
|
echo "Syncing repository from origin/${DEPLOY_BRANCH}..."
|
||||||
|
git fetch origin
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
echo "Error: Git fetch failed."
|
echo "Error: Git fetch failed."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 reset --hard "origin/${CURRENT_BRANCH}"
|
git fetch --tags origin
|
||||||
if [ $? -ne 0 ]; then
|
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."
|
echo "Error: Git reset to origin/${CURRENT_BRANCH} failed."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
REMOTE_VERSION="$(tr -d '[:space:]' < VERSION)"
|
REMOTE_VERSION="$(tr -d '[:space:]' < VERSION)"
|
||||||
if [ "$REMOTE_VERSION" != "$APP_VERSION" ]; then
|
if [ "$REMOTE_VERSION" != "$APP_VERSION" ]; then
|
||||||
echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})."
|
echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})."
|
||||||
echo " Building deployed release v${APP_VERSION}."
|
echo " Building deployed release v${APP_VERSION}."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export APP_VERSION="$APP_VERSION"
|
export APP_VERSION="$APP_VERSION"
|
||||||
|
|
||||||
echo "Rebuilding Docker images without cache (APP_VERSION=${APP_VERSION})..."
|
if [[ "$DEST" == "prod" ]]; then
|
||||||
docker compose -f "$COMPOSE_FILE" build --no-cache
|
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
|
if [ $? -ne 0 ]; then
|
||||||
echo "Error: Docker compose build failed."
|
echo "Error: Docker compose build failed."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -198,23 +297,26 @@ if [ $? -ne 0 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Cleaning up old/unused Docker resources..."
|
echo "Cleaning up old/unused Docker resources..."
|
||||||
docker system prune -f
|
docker system prune -f || echo "Warning: Docker system prune failed."
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Warning: Docker system prune failed to run completely."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Waiting for services to become healthy..."
|
echo "Waiting for services to become healthy..."
|
||||||
COUNTER=0
|
COUNTER=0
|
||||||
IS_READY=false
|
IS_READY=false
|
||||||
|
|
||||||
while [ $COUNTER -lt $MAX_WAIT ]; do
|
while [ $COUNTER -lt $MAX_WAIT ]; do
|
||||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null)
|
STATUS=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)
|
||||||
|
|
||||||
if [ "$STATUS" = "healthy" ]; then
|
if [ "$STATUS" = "healthy" ]; then
|
||||||
IS_READY=true
|
IS_READY=true
|
||||||
break
|
break
|
||||||
fi
|
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
|
sleep 1
|
||||||
COUNTER=$((COUNTER + 1))
|
COUNTER=$((COUNTER + 1))
|
||||||
printf "."
|
printf "."
|
||||||
@@ -227,15 +329,15 @@ docker compose -f "$COMPOSE_FILE" ps
|
|||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
|
||||||
if [ "$IS_READY" = true ]; then
|
if [ "$IS_READY" = true ]; then
|
||||||
echo "SUCCESS: Production environment updated and healthy!"
|
echo "SUCCESS: ${DEST} environment updated and healthy!"
|
||||||
echo " -> Version: v${APP_VERSION}"
|
echo " -> Version: v${APP_VERSION}"
|
||||||
echo " -> App Frontend (Nginx): http://${REMOTE_HOST}"
|
echo " -> App Frontend: ${APP_URL}"
|
||||||
echo " -> Backend API Health: http://${REMOTE_HOST}/api/health"
|
echo " -> Backend API Health: ${APP_URL}/api/health"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
else
|
else
|
||||||
echo "WARNING: Backend did not transition to healthy in time."
|
echo "WARNING: Backend did not transition to healthy in time."
|
||||||
echo "Check backend container logs for details:"
|
echo "Check backend container logs:"
|
||||||
echo " -> docker compose logs backend"
|
echo " -> docker compose -f ${COMPOSE_FILE} logs backend"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
@@ -244,11 +346,11 @@ REMOTE_SCRIPT
|
|||||||
REMOTE_EXIT=$?
|
REMOTE_EXIT=$?
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
if [ $REMOTE_EXIT -eq 0 ]; then
|
if [ $REMOTE_EXIT -eq 0 ]; then
|
||||||
echo "Remote update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})."
|
echo "${ENV_LABEL} update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})."
|
||||||
elif [ $REMOTE_EXIT -eq 3 ]; then
|
elif [ $REMOTE_EXIT -eq 3 ]; then
|
||||||
echo "Remote update finished, but the backend was not healthy in time on ${REMOTE_TARGET}."
|
echo "${ENV_LABEL} update finished, but the backend was not healthy in time on ${REMOTE_TARGET}."
|
||||||
else
|
else
|
||||||
echo "Remote update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})."
|
echo "${ENV_LABEL} update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})."
|
||||||
fi
|
fi
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
exit $REMOTE_EXIT
|
exit $REMOTE_EXIT
|
||||||
Reference in New Issue
Block a user