diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 326db6c..0a7c8f3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -3,6 +3,9 @@ name: Build & Push Docker Image on: push: branches: [main] + paths-ignore: + - 'docs/**' + - '**/*.md' workflow_dispatch: permissions: diff --git a/AUDIT_FINDINGS.md b/AUDIT_FINDINGS.md deleted file mode 100644 index 65fff0f..0000000 --- a/AUDIT_FINDINGS.md +++ /dev/null @@ -1,281 +0,0 @@ -# TREK Security & Code Quality Audit - -**Date:** 2026-03-30 -**Auditor:** Automated comprehensive audit -**Scope:** Full codebase — server, client, infrastructure, dependencies - ---- - -## Table of Contents - -1. [Security](#1-security) -2. [Code Quality](#2-code-quality) -3. [Best Practices](#3-best-practices) -4. [Dependency Hygiene](#4-dependency-hygiene) -5. [Documentation & DX](#5-documentation--dx) -6. [Testing](#6-testing) -7. [Remediation Summary](#7-remediation-summary) - ---- - -## 1. Security - -### 1.1 General - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| S-1 | **CRITICAL** | `server/src/middleware/auth.ts` | 17 | JWT `verify()` does not pin algorithm — accepts whatever algorithm is in the token header, potentially including `none`. | Pass `{ algorithms: ['HS256'] }` to all `jwt.verify()` calls. | FIXED | -| S-2 | **HIGH** | `server/src/websocket.ts` | 56 | Same JWT verify without algorithm pinning in WebSocket auth. | Pin algorithm to HS256. | FIXED | -| S-3 | **HIGH** | `server/src/middleware/mfaPolicy.ts` | 54 | Same JWT verify without algorithm pinning. | Pin algorithm to HS256. | FIXED | -| S-4 | **HIGH** | `server/src/routes/oidc.ts` | 84-88 | OIDC `generateToken()` includes excessive claims (username, email, role) in JWT payload. If the JWT is leaked, this exposes PII. | Only include `{ id: user.id }` in token, consistent with auth.ts. | FIXED | -| S-5 | **HIGH** | `client/src/api/websocket.ts` | 27 | Auth token passed in WebSocket URL query string (`?token=`). Tokens in URLs appear in server logs, proxy logs, and browser history. | Document as known limitation; WebSocket protocol doesn't easily support headers from browsers. Add `LOW` priority note to switch to message-based auth in the future. | DOCUMENTED | -| S-6 | **HIGH** | `client/vite.config.js` | 47-56 | Service worker caches ALL `/api/.*` responses with `NetworkFirst`, including auth tokens, user data, budget, reservations. Data persists after logout. | Exclude sensitive API paths from caching: `/api/auth/.*`, `/api/admin/.*`, `/api/backup/.*`. | FIXED | -| S-7 | **HIGH** | `client/vite.config.js` | 57-65 | User-uploaded files (possibly passport scans, booking confirmations) cached with `CacheFirst` for 30 days, persisting after logout. | Reduce cache lifetime; add note about clearing on logout. | FIXED | -| S-8 | **MEDIUM** | `server/src/index.ts` | 60 | CSP allows `'unsafe-inline'` for scripts, weakening XSS protection. | Remove `'unsafe-inline'` from `scriptSrc` if Vite build doesn't require it. If needed for development, only allow in non-production. | FIXED | -| S-9 | **MEDIUM** | `server/src/index.ts` | 64 | CSP `connectSrc` allows `http:` and `https:` broadly, permitting connections to any origin. | Restrict to known API domains (nominatim, overpass, Google APIs) or use `'self'` with specific external origins. | FIXED | -| S-10 | **MEDIUM** | `server/src/index.ts` | 62 | CSP `imgSrc` allows `http:` broadly. | Restrict to `https:` and `'self'` plus known image domains. | FIXED | -| S-11 | **MEDIUM** | `server/src/websocket.ts` | 84-90 | No message size limit on WebSocket messages. A malicious client could send very large messages to exhaust server memory. | Set `maxPayload` on WebSocketServer configuration. | FIXED | -| S-12 | **MEDIUM** | `server/src/websocket.ts` | 84 | No rate limiting on WebSocket messages. A client can flood the server with join/leave messages. | Add per-connection message rate limiting. | FIXED | -| S-13 | **MEDIUM** | `server/src/websocket.ts` | 29 | No origin validation on WebSocket connections. | Add origin checking against allowed origins. | FIXED | -| S-14 | **MEDIUM** | `server/src/routes/auth.ts` | 157-163 | JWT tokens have 24h expiry with no refresh token mechanism. Long-lived tokens increase window of exposure if leaked. | Document as accepted risk for self-hosted app. Consider refresh tokens in future. | DOCUMENTED | -| S-15 | **MEDIUM** | `server/src/routes/auth.ts` | 367-368 | Password change does not invalidate existing JWT tokens. Old tokens remain valid for up to 24h. | Implement token version/generation tracking, or reduce token expiry and add refresh tokens. | REQUIRES MANUAL REVIEW | -| S-16 | **MEDIUM** | `server/src/services/mfaCrypto.ts` | 2, 5 | MFA encryption key is derived from JWT_SECRET. If JWT_SECRET is compromised, all MFA secrets are also compromised. Single point of failure. | Use a separate MFA_ENCRYPTION_KEY env var, or derive using a different salt/purpose. Current implementation with `:mfa:v1` salt is acceptable but tightly coupled. | DOCUMENTED | -| S-17 | **MEDIUM** | `server/src/routes/maps.ts` | 429 | Google API key exposed in URL query string (`&key=${apiKey}`). Could appear in logs. | Use header-based auth (X-Goog-Api-Key) consistently. Already used elsewhere in the file. | FIXED | -| S-18 | **MEDIUM** | `MCP.md` | 232-235 | Contains publicly accessible database download link with hardcoded credentials (`admin@admin.com` / `admin123`). | Remove credentials from documentation. | FIXED | -| S-19 | **LOW** | `server/src/index.ts` | 229 | Error handler logs full error object including stack trace to console. In containerized deployments, this could leak to centralized logging. | Sanitize error logging in production. | FIXED | -| S-20 | **LOW** | `server/src/routes/backup.ts` | 301-304 | Error detail leaked in non-production environments (`detail: process.env.NODE_ENV !== 'production' ? msg : undefined`). | Acceptable for dev, but ensure it's consistently not leaked in production. Already correct. | OK | - -### 1.2 Auth (JWT + OIDC + TOTP) - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| A-1 | **CRITICAL** | All jwt.verify calls | Multiple | JWT algorithm not pinned. `jsonwebtoken` library defaults to accepting the algorithm specified in the token header, which could include `none`. | Add `{ algorithms: ['HS256'] }` to every `jwt.verify()` call. | FIXED | -| A-2 | **MEDIUM** | `server/src/routes/auth.ts` | 315-318 | MFA login token uses same JWT_SECRET and same `jwt.sign()`. Purpose field `mfa_login` prevents misuse but should use a shorter expiry. Currently 5m which is acceptable. | OK — 5 minute expiry is reasonable. | OK | -| A-3 | **MEDIUM** | `server/src/routes/oidc.ts` | 113-143 | OIDC redirect URI is dynamically constructed from request headers (`x-forwarded-proto`, `x-forwarded-host`). An attacker who can control these headers could redirect the callback to a malicious domain. | Validate the constructed redirect URI against an allowlist, or use a configured base URL from env vars. | FIXED | -| A-4 | **LOW** | `server/src/routes/auth.ts` | 21 | TOTP `window: 1` allows codes from adjacent time periods (±30s). This is standard and acceptable. | OK | OK | - -### 1.3 SQLite (better-sqlite3) - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| D-1 | **HIGH** | `server/src/routes/files.ts` | 90-91 | Dynamic SQL with `IN (${placeholders})` — however, placeholders are correctly generated from array length and values are parameterized. **Not an injection risk.** | OK — pattern is safe. | OK | -| D-2 | **MEDIUM** | `server/src/routes/auth.ts` | 455 | Dynamic SQL `UPDATE users SET ${updates.join(', ')} WHERE id = ?` — column names come from controlled server-side code, not user input. Parameters are properly bound. | OK — column names are from a controlled set. | OK | -| D-3 | **LOW** | `server/src/db/database.ts` | 26-28 | WAL mode and busy_timeout configured. Good. | OK | OK | - -### 1.4 WebSocket (ws) - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| W-1 | **MEDIUM** | `server/src/websocket.ts` | 29 | No `maxPayload` set on WebSocketServer. Default is 100MB which is excessive. | Set `maxPayload: 64 * 1024` (64KB). | FIXED | -| W-2 | **MEDIUM** | `server/src/websocket.ts` | 84-110 | Only `join` and `leave` message types are handled; unknown types are silently ignored. This is acceptable but there is no schema validation on the message structure. | Add basic type/schema validation using Zod. | FIXED | -| W-3 | **LOW** | `server/src/websocket.ts` | 88 | `JSON.parse` errors are silently caught with empty catch. | Log malformed messages at debug level. | FIXED | - -### 1.5 Express - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| E-1 | **LOW** | `server/src/index.ts` | 82 | Body parser limit set to 100KB. Good. | OK | OK | -| E-2 | **LOW** | `server/src/index.ts` | 14-16 | Trust proxy configured conditionally. Good. | OK | OK | -| E-3 | **LOW** | `server/src/index.ts` | 121-136 | Path traversal protection on uploads endpoint. Uses `path.basename` and `path.resolve` check. Good. | OK | OK | - -### 1.6 PWA / Workbox - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| P-1 | **HIGH** | `client/vite.config.js` | 47-56 | API response caching includes sensitive endpoints. | Exclude auth, admin, backup, and settings endpoints from caching. | FIXED | -| P-2 | **MEDIUM** | `client/vite.config.js` | 23, 31, 42, 54, 63 | `cacheableResponse: { statuses: [0, 200] }` — status 0 represents opaque responses which may cache error responses silently. | Remove status 0 from API and upload caches (keep for CDN/map tiles where CORS may return opaque responses). | FIXED | -| P-3 | **MEDIUM** | `client/src/store/authStore.ts` | 126-135 | Logout does not clear service worker caches. Sensitive data persists after logout. | Clear CacheStorage for `api-data` and `user-uploads` caches on logout. | FIXED | - ---- - -## 2. Code Quality - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| Q-1 | **MEDIUM** | `client/src/store/authStore.ts` | 153-161 | `loadUser` silently catches all errors and logs user out. A transient network failure logs the user out. | Only logout on 401 responses, not on network errors. | FIXED | -| Q-2 | **MEDIUM** | `client/src/hooks/useRouteCalculation.ts` | 36 | `useCallback` depends on entire `tripStore` object, defeating memoization. | Select only needed properties from the store. | DOCUMENTED | -| Q-3 | **MEDIUM** | `client/src/hooks/useTripWebSocket.ts` | 14 | `collabFileSync` captures stale `tripStore` reference from initial render. | Use `useTripStore.getState()` instead. | DOCUMENTED | -| Q-4 | **MEDIUM** | `client/src/store/authStore.ts` | 38 vs 105 | `register` function accepts 4 params but TypeScript interface only declares 3. | Update interface to include optional `invite_token`. | FIXED | -| Q-5 | **LOW** | `client/src/store/slices/filesSlice.ts` | — | Empty catch block on file link operation (`catch {}`). | Log error. | DOCUMENTED | -| Q-6 | **LOW** | `client/src/App.tsx` | 101, 108 | Empty catch blocks silently swallow errors. | Add minimal error logging. | DOCUMENTED | -| Q-7 | **LOW** | `client/src/App.tsx` | 155 | `RegisterPage` imported but never used — `/register` route renders `LoginPage`. | Remove unused import. | FIXED | -| Q-8 | **LOW** | `client/tsconfig.json` | 14 | `strict: false` disables TypeScript strict mode. | Enable strict mode and fix resulting type errors. | REQUIRES MANUAL REVIEW | -| Q-9 | **LOW** | `client/src/main.tsx` | 7 | Non-null assertion on `getElementById('root')!`. | Add null check. | DOCUMENTED | -| Q-10 | **LOW** | `server/src/routes/files.ts` | 278 | Empty catch block on file link insert (`catch {}`). | Log duplicate link errors. | FIXED | -| Q-11 | **LOW** | `server/src/db/database.ts` | 20-21 | Silent catch on WAL checkpoint in `initDb`. | Log warning on failure. | DOCUMENTED | - ---- - -## 3. Best Practices - -### 3.1 Node / Express - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| B-1 | **LOW** | `server/src/index.ts` | 251-271 | Graceful shutdown implemented with SIGTERM/SIGINT handlers. Good — closes DB, HTTP server, with 10s timeout. | OK | OK | -| B-2 | **LOW** | `server/src/index.ts` | 87-112 | Debug logging redacts sensitive fields. Good. | OK | OK | - -### 3.2 React / Vite - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| V-1 | **MEDIUM** | `client/vite.config.js` | — | No explicit `build.sourcemap: false` for production. Source maps may be generated. | Add `build: { sourcemap: false }` to Vite config. | FIXED | -| V-2 | **LOW** | `client/index.html` | 24 | Leaflet CSS loaded from unpkg CDN without Subresource Integrity (SRI) hash. | Add `integrity` and `crossorigin` attributes. | FIXED | - -### 3.3 Docker - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| K-1 | **MEDIUM** | `Dockerfile` | 2, 10 | Base images use floating tags (`node:22-alpine`), not pinned to digest. | Pin to specific digest for reproducible builds. | DOCUMENTED | -| K-2 | **MEDIUM** | `Dockerfile` | — | No `HEALTHCHECK` instruction. Only docker-compose has health check. | Add `HEALTHCHECK` to Dockerfile for standalone deployments. | FIXED | -| K-3 | **LOW** | `.dockerignore` | — | Missing exclusions for `chart/`, `docs/`, `.github/`, `docker-compose.yml`, `*.sqlite*`. | Add missing exclusions. | FIXED | - -### 3.4 docker-compose.yml - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| C-1 | **HIGH** | `docker-compose.yml` | 25 | `JWT_SECRET` defaults to empty string if not set. App auto-generates one, but it changes on restart, invalidating all sessions. | Log a prominent warning on startup if JWT_SECRET is auto-generated. | FIXED | -| C-2 | **MEDIUM** | `docker-compose.yml` | — | No resource limits defined for the `app` service. | Add `deploy.resources.limits` section. | DOCUMENTED | - -### 3.5 Git Hygiene - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| G-1 | **HIGH** | `.gitignore` | 12-14 | Missing `*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm` patterns. Only `*.db` variants covered. | Add sqlite patterns. | FIXED | -| G-2 | **LOW** | — | — | No `.env` or `.sqlite` files found in git history. | OK | OK | - -### 3.6 Helm Chart - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| H-1 | **MEDIUM** | `chart/templates/secret.yaml` | 22 | `randAlphaNum 32` generates a new JWT secret on every `helm upgrade`, invalidating all sessions. | Use `lookup` to preserve existing secret across upgrades. | FIXED | -| H-2 | **MEDIUM** | `chart/values.yaml` | 3 | Default image tag is `latest`. | Use a specific version tag. | DOCUMENTED | -| H-3 | **MEDIUM** | `chart/templates/deployment.yaml` | — | No `securityContext` on pod or container. Runs as root by default. | Add `runAsNonRoot: true`, `runAsUser: 1000`. | FIXED | -| H-4 | **MEDIUM** | `chart/templates/pvc.yaml` | — | PVC always created regardless of `.Values.persistence.enabled`. | Add conditional check. | FIXED | -| H-5 | **LOW** | `chart/values.yaml` | 41 | `resources: {}` — no default resource requests or limits. | Add sensible defaults. | FIXED | - ---- - -## 4. Dependency Hygiene - -### 4.1 npm audit - -| Package | Severity | Description | Status | -|---------|----------|-------------|--------| -| `serialize-javascript` (via vite-plugin-pwa → workbox-build → @rollup/plugin-terser) | **HIGH** | RCE via RegExp.flags / CPU exhaustion DoS | Fix requires `vite-plugin-pwa` major version upgrade. | DOCUMENTED | -| `picomatch` (via @rollup/pluginutils, tinyglobby) | **MODERATE** | ReDoS via extglob quantifiers | `npm audit fix` available. | FIXED | - -**Server:** 0 vulnerabilities. - -### 4.2 Outdated Dependencies (Notable) - -| Package | Current | Latest | Risk | Status | -|---------|---------|--------|------|--------| -| `express` | ^4.18.3 | 5.2.1 | Major version — breaking changes | DOCUMENTED | -| `uuid` | ^9.0.0 | 13.0.0 | Major version | DOCUMENTED | -| `dotenv` | ^16.4.1 | 17.3.1 | Major version | DOCUMENTED | -| `lucide-react` | ^0.344.0 | 1.7.0 | Major version | DOCUMENTED | -| `react` | ^18.2.0 | 19.2.4 | Major version | DOCUMENTED | -| `zustand` | ^4.5.2 | 5.0.12 | Major version | DOCUMENTED | - -> Major version upgrades require manual evaluation and testing. Not applied in this remediation pass. - ---- - -## 5. Documentation & DX - -| # | Severity | File | Description | Recommended Fix | Status | -|---|----------|------|-------------|-----------------|--------| -| X-1 | **MEDIUM** | `server/.env.example` | Missing many env vars documented in README: `OIDC_*`, `FORCE_HTTPS`, `TRUST_PROXY`, `DEMO_MODE`, `TZ`, `ALLOWED_ORIGINS`, `DEBUG`. | Add all configurable env vars. | FIXED | -| X-2 | **MEDIUM** | `server/.env.example` | JWT_SECRET placeholder is `your-super-secret-jwt-key-change-in-production` — easily overlooked. | Use `CHANGEME_GENERATE_WITH_openssl_rand_hex_32`. | FIXED | -| X-3 | **LOW** | `server/.env.example` | `PORT=3001` differs from Docker default of `3000`. | Align to `3000`. | FIXED | - ---- - -## 6. Testing - -| # | Severity | Description | Status | -|---|----------|-------------|--------| -| T-1 | **HIGH** | No test files found anywhere in the repository. Zero test coverage for auth flows, WebSocket handling, SQLite queries, API routes, or React components. | REQUIRES MANUAL REVIEW | -| T-2 | **HIGH** | No test framework configured (no jest, vitest, or similar in dependencies). | REQUIRES MANUAL REVIEW | -| T-3 | **MEDIUM** | No CI step runs tests before building Docker image. | DOCUMENTED | - ---- - -## 7. Remediation Summary - -### Applied Fixes - -- **Immich SSRF prevention** — Added URL validation on save (block private IPs, metadata endpoints, non-HTTP protocols) -- **Immich API key isolation** — Removed `userId` query parameter from asset proxy endpoints; all Immich requests now use authenticated user's own credentials only -- **Immich asset ID validation** — Added alphanumeric pattern validation to prevent path traversal in proxied URLs -- **JWT algorithm pinning** — Added `{ algorithms: ['HS256'] }` to all `jwt.verify()` calls (auth middleware, MFA policy, WebSocket, OIDC, auth routes) -- **OIDC token payload** — Reduced to `{ id }` only, matching auth.ts pattern -- **OIDC redirect URI validation** — Validates against `APP_URL` env var when set -- **WebSocket hardening** — Added `maxPayload: 64KB`, message rate limiting (30 msg/10s), origin validation, improved message validation -- **CSP tightening** — Removed `'unsafe-inline'` from scripts in production, restricted `connectSrc` and `imgSrc` to known domains -- **PWA cache security** — Excluded sensitive API paths from caching, removed opaque response caching for API/uploads, clear caches on logout -- **Service worker cache cleanup on logout** -- **Google API key** — Moved from URL query string to header in maps photo endpoint -- **MCP.md credentials** — Removed hardcoded demo credentials -- **.gitignore** — Added `*.sqlite*` patterns -- **.dockerignore** — Added missing exclusions -- **Dockerfile** — Added HEALTHCHECK instruction -- **Helm chart** — Fixed secret rotation, added securityContext, conditional PVC, resource defaults -- **Vite config** — Disabled source maps in production -- **CDN integrity** — Added SRI hash for Leaflet CSS -- **.env.example** — Complete with all env vars -- **Various code quality fixes** — Removed dead imports, fixed empty catch blocks, fixed auth store interface - -### Requires Manual Review - -- Password change should invalidate existing tokens (S-15) -- TypeScript strict mode should be enabled (Q-8) -- Test suite needs to be created from scratch (T-1, T-2) -- Major dependency upgrades (express 5, React 19, zustand 5, etc.) -- `serialize-javascript` vulnerability fix requires vite-plugin-pwa major upgrade - -### 1.7 Immich Integration - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| I-1 | **CRITICAL** | `server/src/routes/immich.ts` | 38-39, 85, 199, 250, 274 | SSRF via user-controlled `immich_url`. Users can set any URL which is then used in `fetch()` calls, allowing requests to internal metadata endpoints (169.254.169.254), localhost services, etc. | Validate URL on save: require HTTP(S) protocol, block private/internal IPs. | FIXED | -| I-2 | **CRITICAL** | `server/src/routes/immich.ts` | 194-196, 244-246, 269-270 | Asset info/thumbnail/original endpoints accept `userId` query param, allowing any authenticated user to proxy requests through another user's Immich API key. This exposes other users' Immich credentials and photo libraries. | Restrict all Immich proxy endpoints to the authenticated user's own credentials only. | FIXED | -| I-3 | **MEDIUM** | `server/src/routes/immich.ts` | 199, 250, 274 | `assetId` URL parameter used directly in `fetch()` URL construction. Path traversal characters could redirect requests to unintended Immich API endpoints. | Validate assetId matches `[a-zA-Z0-9_-]+` pattern. | FIXED | - -### 1.8 Admin Routes - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| AD-1 | **MEDIUM** | `server/src/routes/admin.ts` | 302-310 | Self-update endpoint runs `git pull` then `npm run build`. While admin-only and `npm install` uses `--ignore-scripts`, `npm run build` executes whatever is in the pulled package.json. A compromised upstream could execute arbitrary code. | Document as accepted risk for self-hosted self-update feature. Users should pin to specific versions. | DOCUMENTED | - -### 1.9 Client-Side XSS - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| X-1 | **CRITICAL** | `client/src/components/Admin/GitHubPanel.tsx` | 66, 106 | `dangerouslySetInnerHTML` with `inlineFormat()` renders GitHub release notes as HTML without escaping. Malicious HTML in release notes could execute scripts. | Escape HTML entities before applying markdown-style formatting. Validate link URLs. | FIXED | -| X-2 | **LOW** | `client/src/components/Map/MapView.tsx` | divIcon | Uses `escAttr()` for HTML sanitization in divIcon strings. Properly mitigated. | OK | OK | - -### 1.10 Route Calculator Bug - -| # | Severity | File | Line(s) | Description | Recommended Fix | Status | -|---|----------|------|---------|-------------|-----------------|--------| -| RC-1 | **HIGH** | `client/src/components/Map/RouteCalculator.ts` | 16 | OSRM URL hardcodes `'driving'` profile, ignoring the `profile` parameter. Walking/cycling routes always return driving results. | Use the `profile` parameter in URL construction. | FIXED | - -### Additional Findings (from exhaustive scan) - -- **MEDIUM** — `server/src/index.ts:121-136`: Upload files (`/uploads/:type/:filename`) served without authentication. UUIDs are unguessable but this is security-through-obscurity. **REQUIRES MANUAL REVIEW** — adding auth would break shared trip image URLs. -- **MEDIUM** — `server/src/routes/oidc.ts:194`: OIDC token exchange error was logging full token response (potentially including access tokens). **FIXED** — now logs only HTTP status. -- **MEDIUM** — `server/src/services/notifications.ts:194-196`: Email body is not HTML-escaped. User-generated content (trip names, usernames) interpolated directly into HTML email template. Potential stored XSS in email clients. **DOCUMENTED** — needs HTML entity escaping. -- **LOW** — `server/src/demo/demo-seed.ts:7-9`: Hardcoded demo credentials (`demo12345`, `admin12345`). Intentional for demo mode but dangerous if DEMO_MODE accidentally left on in production. Already has startup warning. -- **LOW** — `server/src/routes/auth.ts:742`: MFA setup returns plaintext TOTP secret to client. This is standard TOTP enrollment flow — users need the secret for manual entry. Must be served over HTTPS. -- **LOW** — `server/src/routes/auth.ts:473`: Admin settings GET returns API keys in full (not masked). Only accessible to admins. -- **LOW** — `server/src/routes/auth.ts:564`: SMTP password stored as plaintext in `app_settings` table. Masked in API response but unencrypted at rest. - -### Accepted Risks (Documented) - -- WebSocket token in URL query string (browser limitation) -- 24h JWT expiry without refresh tokens (acceptable for self-hosted) -- MFA encryption key derived from JWT_SECRET (noted coupling) -- localStorage for token storage (standard SPA pattern) -- Upload files served without auth (UUID-based obscurity, needed for shared trips) diff --git a/README.md b/README.md index 6f601af..e5d768a 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ services: # - OIDC_ONLY=false # Set to true to disable local password auth entirely (SSO only) # - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users # - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role - # - OIDC_SCOPE=openid email profile groups # Space-separated OIDC scopes to request (must include scopes for any claim used by OIDC_ADMIN_CLAIM) + # - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM) # - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik) # - DEMO_MODE=false # Enable demo mode (resets data hourly) # - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist @@ -295,7 +295,7 @@ trek.yourdomain.com { | `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` | | `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — | | `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — | -| `OIDC_SCOPE` | Space-separated OIDC scopes to request. Must include scopes for any claim used by `OIDC_ADMIN_CLAIM` (e.g. add `groups` for group-based admin mapping) | `openid email profile groups` | +| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` | | `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Useful for providers that expose it at a non-standard path (e.g. Authentik: `https://auth.example.com/application/o/trek/.well-known/openid-configuration`) | — | | **Initial Setup** | | | | `ADMIN_EMAIL` | Email for the first admin account created on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is generated and printed to the server log. Has no effect once any user exists. | `admin@trek.local` | diff --git a/client/package-lock.json b/client/package-lock.json index 7c3d867..8b75767 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-client", - "version": "2.7.2", + "version": "2.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-client", - "version": "2.7.2", + "version": "2.8.4", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", diff --git a/client/package.json b/client/package.json index e9799b5..d90f6ea 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "trek-client", - "version": "2.7.2", + "version": "2.8.4", "private": true, "type": "module", "scripts": { diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 81a28b6..179021c 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -136,6 +136,16 @@ export const packingApi = { deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data), } +export const todoApi = { + list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data), + create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data), + delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data), + reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data), + getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data), + setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data), +} + export const tagsApi = { list: () => apiClient.get('/tags').then(r => r.data), create: (data: Record) => apiClient.post('/tags', data).then(r => r.data), diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 933ca84..3f8aef7 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import DOM from 'react-dom' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' -import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react' +import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react' import { collabApi } from '../../api/client' import { getAuthUrl } from '../../api/authUrl' import { useCanDo } from '../../store/permissionsStore' @@ -100,6 +100,7 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { const [authUrl, setAuthUrl] = useState('') const rawUrl = file?.url || '' useEffect(() => { + setAuthUrl('') if (!rawUrl) return getAuthUrl(rawUrl, 'download').then(setAuthUrl) }, [rawUrl]) @@ -119,7 +120,10 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { {isImage ? ( /* Image lightbox — floating controls */
e.stopPropagation()}> - {file.original_name} + {authUrl + ? {file.original_name} + : + }
{file.original_name}
@@ -487,7 +491,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca const isImage = a.mime_type?.startsWith('image/') return (
- {isImage && } + {isImage && } {(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
{viewingNote.content || ''} + {(viewingNote.attachments || []).length > 0 && ( +
+
{t('files.title')}
+
+ {(viewingNote.attachments || []).map(a => { + const isImage = a.mime_type?.startsWith('image/') + const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?' + return ( +
+ {isImage ? ( + setPreviewFile(a)} + onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} + onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} /> + ) : ( +
setPreviewFile(a)} + style={{ + width: 64, height: 64, borderRadius: 8, cursor: 'pointer', + background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)', + display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, + transition: 'transform 0.12s, box-shadow 0.12s', + }} + onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} + onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}> + {ext} +
+ )} + {a.original_name} +
+ ) + })} +
+
+ )}
, diff --git a/client/src/components/Layout/InAppNotificationBell.tsx b/client/src/components/Layout/InAppNotificationBell.tsx index fcf14cb..0b22038 100644 --- a/client/src/components/Layout/InAppNotificationBell.tsx +++ b/client/src/components/Layout/InAppNotificationBell.tsx @@ -96,7 +96,7 @@ export default function InAppNotificationBell(): React.ReactElement { {t('notifications.title')} {unreadCount > 0 && ( + style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}> {unreadCount} )} @@ -133,7 +133,7 @@ export default function InAppNotificationBell(): React.ReactElement {
{isLoading && notifications.length === 0 ? (
-
+
) : notifications.length === 0 ? (
@@ -154,7 +154,7 @@ export default function InAppNotificationBell(): React.ReactElement { className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)', - color: '#6366f1', + color: 'var(--text-primary)', background: 'transparent', }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 1d92876..e4e1dc9 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -133,7 +133,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: {tripTitle && ( <> / - + {tripTitle} @@ -155,17 +155,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: )} - {/* Dark mode toggle (light ↔ dark, overrides auto) */} + {/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */} - {/* Notification bell */} - {user && } + {/* Notification bell — only in trip view on mobile, everywhere on desktop */} + {user && tripId && } + {user && !tripId && } {/* User menu */} {user && ( diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index da9873e..5155c03 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } from 'lucide-react' +import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen, Info } from 'lucide-react' import apiClient, { addonsApi } from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' @@ -137,7 +137,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const unlinkAlbum = async (linkId: number) => { try { await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/album-links/${linkId}`) - loadAlbumLinks() + await loadAlbumLinks() + await loadPhotos() } catch { toast.error(t('memories.error.unlinkAlbum')) } } @@ -159,6 +160,14 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const [lightboxInfo, setLightboxInfo] = useState(null) const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false) const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('') + const [showMobileInfo, setShowMobileInfo] = useState(false) + const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768) + + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth < 768) + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) // ── Init ────────────────────────────────────────────────────────────────── @@ -885,117 +894,153 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa )} {/* Lightbox */} - {lightboxId && lightboxUserId && ( -
{ if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }} - style={{ - position: 'absolute', inset: 0, zIndex: 100, - background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center', - }}> - -
e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}> - + {lightboxId && lightboxUserId && (() => { + const closeLightbox = () => { + if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) + setLightboxOriginalSrc('') + setLightboxId(null) + setLightboxUserId(null) + setShowMobileInfo(false) + } - {/* Info panel — liquid glass */} - {lightboxInfo && ( -
- {/* Date */} - {lightboxInfo.takenAt && ( + const exifContent = lightboxInfo ? ( + <> + {lightboxInfo.takenAt && ( +
+
Date
+
{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}
+
{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
+
+ )} + {(lightboxInfo.city || lightboxInfo.country) && ( +
+
+ Location +
+
+ {[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')} +
+
+ )} + {lightboxInfo.camera && ( +
+
Camera
+
{lightboxInfo.camera}
+ {lightboxInfo.lens &&
{lightboxInfo.lens}
} +
+ )} + {(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && ( +
+ {lightboxInfo.focalLength && (
-
Date
-
{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}
-
{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
+
Focal
+
{lightboxInfo.focalLength}
)} - - {/* Location */} - {(lightboxInfo.city || lightboxInfo.country) && ( + {lightboxInfo.aperture && (
-
- Location -
-
- {[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')} -
+
Aperture
+
{lightboxInfo.aperture}
)} - - {/* Camera */} - {lightboxInfo.camera && ( + {lightboxInfo.shutter && (
-
Camera
-
{lightboxInfo.camera}
- {lightboxInfo.lens &&
{lightboxInfo.lens}
} +
Shutter
+
{lightboxInfo.shutter}
)} - - {/* Settings */} - {(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && ( -
- {lightboxInfo.focalLength && ( -
-
Focal
-
{lightboxInfo.focalLength}
-
- )} - {lightboxInfo.aperture && ( -
-
Aperture
-
{lightboxInfo.aperture}
-
- )} - {lightboxInfo.shutter && ( -
-
Shutter
-
{lightboxInfo.shutter}
-
- )} - {lightboxInfo.iso && ( -
-
ISO
-
{lightboxInfo.iso}
-
- )} -
- )} - - {/* Resolution & File */} - {(lightboxInfo.width || lightboxInfo.fileName) && ( -
- {lightboxInfo.width && lightboxInfo.height && ( -
{lightboxInfo.width} × {lightboxInfo.height}
- )} - {lightboxInfo.fileSize && ( -
{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB
- )} + {lightboxInfo.iso && ( +
+
ISO
+
{lightboxInfo.iso}
)}
)} + {(lightboxInfo.width || lightboxInfo.fileName) && ( +
+ {lightboxInfo.width && lightboxInfo.height && ( +
{lightboxInfo.width} × {lightboxInfo.height}
+ )} + {lightboxInfo.fileSize && ( +
{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB
+ )} +
+ )} + + ) : null - {lightboxInfoLoading && ( -
-
+ return ( +
+ {/* Close button */} + + + {/* Mobile info toggle button */} + {isMobile && (lightboxInfo || lightboxInfoLoading) && ( + + )} + +
e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}> + + + {/* Desktop info panel — liquid glass */} + {!isMobile && lightboxInfo && ( +
+ {exifContent} +
+ )} + + {!isMobile && lightboxInfoLoading && ( +
+
+
+ )} +
+ + {/* Mobile bottom sheet */} + {isMobile && showMobileInfo && lightboxInfo && ( +
e.stopPropagation()} style={{ + position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 5, + maxHeight: '60vh', overflowY: 'auto', + borderRadius: '16px 16px 0 0', padding: 18, + background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', + border: '1px solid rgba(255,255,255,0.12)', borderBottom: 'none', + color: 'white', display: 'flex', flexDirection: 'column', gap: 14, + }}> + {exifContent}
)}
-
- )} + ) + })()}
) } diff --git a/client/src/components/Notifications/InAppNotificationItem.tsx b/client/src/components/Notifications/InAppNotificationItem.tsx index a791fe7..f0ef4fa 100644 --- a/client/src/components/Notifications/InAppNotificationItem.tsx +++ b/client/src/components/Notifications/InAppNotificationItem.tsx @@ -59,10 +59,6 @@ export default function InAppNotificationItem({ notification, onClose }: Notific borderBottom: '1px solid var(--border-secondary)', }} > - {/* Unread dot */} - {!notification.is_read && ( -
- )}
{/* Sender avatar */} @@ -102,7 +98,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific title={t('notifications.markRead')} className="p-1 rounded transition-colors" style={{ color: 'var(--text-faint)' }} - onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = '#6366f1' }} + onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)' }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }} > @@ -134,7 +130,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors" style={{ background: notification.response === 'positive' - ? '#6366f1' + ? 'var(--text-primary)' : notification.response === 'negative' ? (dark ? '#27272a' : '#f1f5f9') : (dark ? '#27272a' : '#f1f5f9'), diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 2a4c03a..2ef5fdc 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -1,22 +1,33 @@ // Trip PDF via browser print window import { createElement } from 'react' import { getCategoryIcon } from '../shared/categoryIcons' -import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react' -import { mapsApi } from '../../api/client' +import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, LucideIcon } from 'lucide-react' +import { accommodationsApi, mapsApi } from '../../api/client' import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' +function renderLucideIcon(icon:LucideIcon, props = {}) { + if (!_renderToStaticMarkup) return '' + return _renderToStaticMarkup( + createElement(icon, props) + ); +} + const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } function noteIconSvg(iconId) { - if (!_renderToStaticMarkup) return '' const Icon = NOTE_ICON_MAP[iconId] || FileText - return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })) + return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }) } const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } function transportIconSvg(type) { - if (!_renderToStaticMarkup) return '' const Icon = TRANSPORT_ICON_MAP[type] || Ticket - return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' })) + return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' }) +} + +const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound } +function accommodationIconSvg(type) { + const Icon = ACCOMMODATION_ICON_MAP[type] || BedDouble + return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#03398f', className: 'accommodation-icon' }) } // ── SVG inline icons (for chips) ───────────────────────────────────────────── @@ -115,6 +126,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number) const range = longDateRange(sorted, loc) const coverImg = safeImg(trip?.cover_image) + //retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed + const accommodations = await accommodationsApi.list(trip.id); // Pre-fetch place photos from Google const photoMap = await fetchPlacePhotos(assignments) @@ -223,7 +236,41 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor ${place.notes ? `
${escHtml(place.notes)}
` : ''}
` - }).join('') + }).join('') + + const accommodationsForDay = (accommodations.accommodations || []).filter(a => + days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id) + ).sort((a, b) => a.start_day_id - b.start_day_id) + + const accommodationDetails = accommodationsForDay.map(item => { + const isCheckIn = day.id === item.start_day_id + const isCheckOut = day.id === item.end_day_id + const actionLabel = isCheckIn ? tr('reservations.meta.checkIn') + : isCheckOut ? tr('reservations.meta.checkOut') + : tr('reservations.meta.linkAccommodation') + const actionIcon = isCheckIn ? accommodationIconSvg('checkin') + : isCheckOut ? accommodationIconSvg('checkout') + : accommodationIconSvg('accommodation') + const timeStr = isCheckIn ? (item.check_in || '') + : isCheckOut ? (item.check_out || '') + : '' + + return ` +
+
${actionIcon} ${escHtml(actionLabel)}
+ ${timeStr ? `
${accommodationIconSvg('checkin')} ${escHtml(timeStr)}
` : ''} +
${accommodationIconSvg('accommodation')} ${escHtml(item.place_name)}
+ ${item.place_address ? `
${accommodationIconSvg('location')} ${escHtml(item.place_address)}
` : ''} + ${item.notes ? `
${accommodationIconSvg('note')} ${escHtml(item.notes)}
` : ''} + ${isCheckIn && item.confirmation ? `
${accommodationIconSvg('confirmation')} ${escHtml(item.confirmation)}
` : ''} +
` + }).join('') + + const accommodationsHtml = accommodationsForDay.length > 0 + ? `
+
${accommodationDetails}
+
` + : '' return `
@@ -233,8 +280,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor ${day.date ? `${shortDate(day.date, loc)}` : ''} ${cost ? `${cost}` : ''}
-
${itemsHtml}
-
` +
${accommodationsHtml}${itemsHtml}
+
` }).join('') const html = ` @@ -317,6 +364,22 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor .day-cost { font-size: 9px; font-weight: 600; color: rgba(255,255,255,0.65); } .day-body { padding: 12px 28px 6px; } + /* accommodation info */ + .day-accommodations-overview { font-size: 12px; } + .day-accommodations { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; } + .day-accommodations.single { justify-content: center; } + .day-accommodation { + flex: 1 1 45%; min-width: 200px; margin: 4px 0; padding: 10px; + border: 2px solid #e2e8f0; border-radius: 12px; + display: flex; flex-direction: column; + } + .day-accommodation-title { + font-size: 16px; font-weight: 600; text-align: center; + margin-bottom: 4px; align-self: center; + } + .accommodation-center-icon { display: flex; align-items: center; gap: 4px; } + + /* ── Place card ────────────────────────────────── */ .place-card { display: flex; align-items: stretch; diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index b83270e..58e8a75 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -136,6 +136,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const [dragOverDayId, setDragOverDayId] = useState(null) const [hoveredId, setHoveredId] = useState(null) const [transportDetail, setTransportDetail] = useState(null) + const [transportPosVersion, setTransportPosVersion] = useState(0) const [timeConfirm, setTimeConfirm] = useState<{ dayId: number; fromId: number; time: string; // For drag & drop reorder @@ -211,13 +212,67 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise']) + // Determine if a reservation's end_time represents a different date (multi-day) + const getEndDate = (r: Reservation) => { + const endStr = r.reservation_end_time || '' + return endStr.includes('T') ? endStr.split('T')[0] : null + } + + // Get span phase: how a reservation relates to a specific day's date + const getSpanPhase = (r: Reservation, dayDate: string): 'single' | 'start' | 'middle' | 'end' => { + if (!r.reservation_time) return 'single' + const startDate = r.reservation_time.split('T')[0] + const endDate = getEndDate(r) || startDate + if (startDate === endDate) return 'single' + if (dayDate === startDate) return 'start' + if (dayDate === endDate) return 'end' + return 'middle' + } + + // Get the appropriate display time for a reservation on a specific day + const getDisplayTimeForDay = (r: Reservation, dayDate: string): string | null => { + const phase = getSpanPhase(r, dayDate) + if (phase === 'end') return r.reservation_end_time || null + if (phase === 'middle') return null + return r.reservation_time || null + } + + // Get phase label for multi-day badge + const getSpanLabel = (r: Reservation, phase: string): string | null => { + if (phase === 'single') return null + if (r.type === 'flight') return t(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`) + if (r.type === 'car') return t(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`) + return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`) + } + const getTransportForDay = (dayId: number) => { const day = days.find(d => d.id === dayId) if (!day?.date) return [] return reservations.filter(r => { - if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false - const resDate = r.reservation_time.split('T')[0] - return resDate === day.date + if (!r.reservation_time || r.type === 'hotel') return false + const startDate = r.reservation_time.split('T')[0] + const endDate = getEndDate(r) + + if (endDate && endDate !== startDate) { + // Multi-day: show on any day in range (car middle handled elsewhere) + return day.date >= startDate && day.date <= endDate + } else { + // Single-day: show all non-hotel reservations that match this day's date + return startDate === day.date + } + }) + } + + // Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline + const getActiveRentalsForDay = (dayId: number) => { + const day = days.find(d => d.id === dayId) + if (!day?.date) return [] + return reservations.filter(r => { + if (r.type !== 'car' || !r.reservation_time) return false + const startDate = r.reservation_time.split('T')[0] + const endDate = getEndDate(r) + if (!endDate || endDate === startDate) return false + return day.date > startDate && day.date < endDate }) } @@ -279,47 +334,45 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const da = getDayAssignments(dayId) const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order) const transport = getTransportForDay(dayId) + const dayDate = days.find(d => d.id === dayId)?.date || '' // Initialize positions for transports that don't have one yet if (transport.some(r => r.day_plan_position == null)) { initTransportPositions(dayId) } - // Build base list: untimed places + notes sorted by order_index/sort_order - const timedPlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) !== null) - const freePlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) === null) - + // Build base list: ALL places (timed and untimed) + notes sorted by order_index/sort_order + // Places keep their order_index ordering — only transports are inserted based on time. const baseItems = [ - ...freePlaces.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })), + ...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })), ...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })), ].sort((a, b) => a.sortKey - b.sortKey) - // Timed places + transports: compute sortKeys based on time, inserted among base items - const allTimed = [ - ...timedPlaces.map(a => ({ type: 'place' as const, data: a, minutes: parseTimeToMinutes(a.place?.place_time)! })), - ...transport.map(r => ({ type: 'transport' as const, data: r, minutes: parseTimeToMinutes(r.reservation_time) ?? 0 })), - ].sort((a, b) => a.minutes - b.minutes) + // Only transports are inserted among base items based on time/position + const timedTransports = transport.map(r => ({ + type: 'transport' as const, + data: r, + minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayDate)) ?? 0, + })).sort((a, b) => a.minutes - b.minutes) - if (allTimed.length === 0) return baseItems + if (timedTransports.length === 0) return baseItems if (baseItems.length === 0) { - return allTimed.map((item, i) => ({ ...item, sortKey: i })) + return timedTransports.map((item, i) => ({ ...item, sortKey: i })) } - // Insert timed items among base items using time-to-position mapping. - // Each timed item finds the last base place whose order_index corresponds - // to a reasonable position, then gets a fractional sortKey after it. + // Insert transports among base items using persisted position or time-to-position mapping. const result = [...baseItems] - for (let ti = 0; ti < allTimed.length; ti++) { - const timed = allTimed[ti] + for (let ti = 0; ti < timedTransports.length; ti++) { + const timed = timedTransports[ti] const minutes = timed.minutes - // For transports, use persisted position if available - if (timed.type === 'transport' && timed.data.day_plan_position != null) { + // Use persisted position if available + if (timed.data.day_plan_position != null) { result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data }) continue } - // Find insertion position: after the last base item with time <= this item's time + // Find insertion position: after the last base item with time <= this transport's time let insertAfterKey = -Infinity for (const item of result) { if (item.type === 'place') { @@ -350,7 +403,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ return map // getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure // eslint-disable-next-line react-hooks/exhaustive-deps - }, [days, assignments, dayNotes, reservations]) + }, [days, assignments, dayNotes, reservations, transportPosVersion]) const openAddNote = (dayId, e) => { e?.stopPropagation() @@ -449,6 +502,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const res = reservations.find(r => r.id === tu.id) if (res) res.day_plan_position = tu.day_plan_position } + setTransportPosVersion(v => v + 1) await reservationsApi.updatePositions(tripId, transportUpdates) } if (prevAssignmentIds.length) { @@ -968,6 +1022,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ ) }) })()} + {/* Active rental car badges */} + {(() => { + const activeRentals = getActiveRentalsForDay(day.id) + if (activeRentals.length === 0) return null + return activeRentals.map(r => ( + { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.2)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}> + + {r.title} + + )) + })()}
)}
@@ -1010,18 +1075,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const { placeId, assignmentId, noteId, fromDayId } = getDragData(e) // Drop on transport card (detected via dropTargetRef for sync accuracy) if (dropTargetRef.current?.startsWith('transport-')) { - const transportId = Number(dropTargetRef.current.replace('transport-', '')) + const isAfter = dropTargetRef.current.startsWith('transport-after-') + const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-') + const transportId = Number(parts[0]) if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) } else if (assignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (assignmentId) { - handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId) + handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter) } else if (noteId && fromDayId !== day.id) { tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (noteId) { - handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId) + handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null return @@ -1062,8 +1129,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
) : ( merged.map((item, idx) => { - const itemKey = item.type === 'transport' ? `transport-${item.data.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`) + const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`) const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey + const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}-${day.id}` if (item.type === 'place') { const assignment = item.data @@ -1291,6 +1359,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ // Transport booking (flight, train, bus, car, cruise) if (item.type === 'transport') { const res = item.data + const spanPhase = getSpanPhase(res, day.date) + + // Car "active" (middle) days are shown in the day header, skip here + if (res.type === 'car' && spanPhase === 'middle') return null + const TransportIcon = RES_ICONS[res.type] || Ticket const color = '#3b82f6' const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) @@ -1307,25 +1380,37 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ') } + // Multi-day span phase + const spanLabel = getSpanLabel(res, spanPhase) + const displayTime = getDisplayTimeForDay(res, day.date) + return ( - + {showDropLine &&
}
setTransportDetail(res)} - onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`transport-${res.id}`) }} + onDragOver={e => { + e.preventDefault(); e.stopPropagation() + const rect = e.currentTarget.getBoundingClientRect() + const inBottom = e.clientY > rect.top + rect.height / 2 + const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}` + if (dropTargetRef.current !== key) setDropTargetKey(key) + }} onDrop={e => { e.preventDefault(); e.stopPropagation() + const rect = e.currentTarget.getBoundingClientRect() + const insertAfter = e.clientY > rect.top + rect.height / 2 const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e) if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) } else if (fromAssignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (fromAssignmentId) { - handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id) + handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter) } else if (noteId && fromDayId !== day.id) { tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (noteId) { - handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id) + handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null }} @@ -1340,6 +1425,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ background: isTransportHovered ? `${color}12` : `${color}08`, cursor: 'pointer', userSelect: 'none', transition: 'background 0.1s', + opacity: spanPhase === 'middle' ? 0.65 : 1, }} >
+ {spanLabel && ( + + {spanLabel} + + )} {res.title} - {res.reservation_time?.includes('T') && ( + {displayTime?.includes('T') && ( - {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} - {res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`} + {new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} + {spanPhase === 'single' && res.reservation_end_time && (() => { + const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time) + return ` – ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}` + })()} + {meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`} + {meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`} )}
@@ -1368,6 +1467,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ )}
+ {showDropLineAfter &&
} ) } diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 8a376d7..6d7c7ee 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -73,9 +73,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p const [form, setForm] = useState({ title: '', type: 'other', status: 'pending', - reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '', + reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', notes: '', assignment_id: '', accommodation_id: '', meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '', + meta_departure_timezone: '', meta_arrival_timezone: '', meta_train_number: '', meta_platform: '', meta_seat: '', meta_check_in_time: '', meta_check_out_time: '', hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', @@ -95,12 +96,21 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p useEffect(() => { if (reservation) { const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {}) + // Parse end_date from reservation_end_time if it's a full ISO datetime + const rawEnd = reservation.reservation_end_time || '' + let endDate = '' + let endTime = rawEnd + if (rawEnd.includes('T')) { + endDate = rawEnd.split('T')[0] + endTime = rawEnd.split('T')[1]?.slice(0, 5) || '' + } setForm({ title: reservation.title || '', type: reservation.type || 'other', status: reservation.status || 'pending', reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '', - reservation_end_time: reservation.reservation_end_time || '', + reservation_end_time: endTime, + end_date: endDate, location: reservation.location || '', confirmation_number: reservation.confirmation_number || '', notes: reservation.notes || '', @@ -110,6 +120,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p meta_flight_number: meta.flight_number || '', meta_departure_airport: meta.departure_airport || '', meta_arrival_airport: meta.arrival_airport || '', + meta_departure_timezone: meta.departure_timezone || '', + meta_arrival_timezone: meta.arrival_timezone || '', meta_train_number: meta.train_number || '', meta_platform: meta.platform || '', meta_seat: meta.seat || '', @@ -122,9 +134,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p } else { setForm({ title: '', type: 'other', status: 'pending', - reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '', + reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', notes: '', assignment_id: '', accommodation_id: '', meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '', + meta_departure_timezone: '', meta_arrival_timezone: '', meta_train_number: '', meta_platform: '', meta_seat: '', meta_check_in_time: '', meta_check_out_time: '', }) @@ -134,9 +147,21 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p const set = (field, value) => setForm(prev => ({ ...prev, [field]: value })) + // Validate that end datetime is after start datetime + const isEndBeforeStart = (() => { + if (!form.end_date || !form.reservation_time) return false + const startDate = form.reservation_time.split('T')[0] + const startTime = form.reservation_time.split('T')[1] || '00:00' + const endTime = form.reservation_end_time || '00:00' + const startFull = `${startDate}T${startTime}` + const endFull = `${form.end_date}T${endTime}` + return endFull <= startFull + })() + const handleSubmit = async (e) => { e.preventDefault() if (!form.title.trim()) return + if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return } setIsSaving(true) try { const metadata: Record = {} @@ -145,6 +170,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport + if (form.meta_departure_timezone) metadata.departure_timezone = form.meta_departure_timezone + if (form.meta_arrival_timezone) metadata.arrival_timezone = form.meta_arrival_timezone } else if (form.type === 'hotel') { if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time @@ -153,9 +180,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p if (form.meta_platform) metadata.platform = form.meta_platform if (form.meta_seat) metadata.seat = form.meta_seat } + // Combine end_date + end_time into reservation_end_time + let combinedEndTime = form.reservation_end_time + if (form.end_date) { + combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date + } const saveData: Record = { title: form.title, type: form.type, status: form.status, - reservation_time: form.reservation_time, reservation_end_time: form.reservation_end_time, + reservation_time: form.reservation_time, reservation_end_time: combinedEndTime, location: form.location, confirmation_number: form.confirmation_number, notes: form.notes, assignment_id: form.assignment_id || null, @@ -257,10 +289,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
- {/* Assignment Picker + Date (hidden for hotels) */} - {form.type !== 'hotel' && ( -
- {assignmentOptions.length > 0 && ( + {/* Assignment Picker (hidden for hotels) */} + {form.type !== 'hotel' && assignmentOptions.length > 0 && ( +
- )} -
- - { const [d] = (form.reservation_time || '').split('T'); return d || '' })()} - onChange={d => { - const [, t] = (form.reservation_time || '').split('T') - set('reservation_time', d ? (t ? `${d}T${t}` : d) : '') - }} - /> -
)} - {/* Start Time + End Time + Status */} -
- {form.type !== 'hotel' && ( - <> + {/* Start Date/Time + End Date/Time + Status (hidden for hotels) */} + {form.type !== 'hotel' && ( + <> +
+
+ + { const [d] = (form.reservation_time || '').split('T'); return d || '' })()} + onChange={d => { + const [, t] = (form.reservation_time || '').split('T') + set('reservation_time', d ? (t ? `${d}T${t}` : d) : '') + }} + /> +
+
+ + { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()} + onChange={t => { + const [d] = (form.reservation_time || '').split('T') + const date = d || new Date().toISOString().split('T')[0] + set('reservation_time', t ? `${date}T${t}` : date) + }} + /> +
+ {form.type === 'flight' && (
- - { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()} - onChange={t => { - const [d] = (form.reservation_time || '').split('T') - const date = d || new Date().toISOString().split('T')[0] - set('reservation_time', t ? `${date}T${t}` : date) - }} - /> + + set('meta_departure_timezone', e.target.value)} + placeholder="e.g. CET, UTC+1" style={inputStyle} />
-
- - set('reservation_end_time', v)} /> -
- - )} -
- - set('status', value)} - options={[ - { value: 'pending', label: t('reservations.pending') }, - { value: 'confirmed', label: t('reservations.confirmed') }, - ]} - size="sm" - /> + )}
-
+
+
+ + set('end_date', d || '')} + /> +
+
+ + set('reservation_end_time', v)} /> +
+ {form.type === 'flight' && ( +
+ + set('meta_arrival_timezone', e.target.value)} + placeholder="e.g. JST, UTC+9" style={inputStyle} /> +
+ )} +
+ {isEndBeforeStart && ( +
{t('reservations.validation.endBeforeStart')}
+ )} +
+
+ + set('status', value)} + options={[ + { value: 'pending', label: t('reservations.pending') }, + { value: 'confirmed', label: t('reservations.confirmed') }, + ]} + size="sm" + /> +
+
+ + )} {/* Location + Booking Code */}
@@ -422,8 +480,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p />
- {/* Check-in/out times */} -
+ {/* Check-in/out times + Status */} +
set('meta_check_in_time', v)} /> @@ -432,6 +490,18 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p set('meta_check_out_time', v)} />
+
+ + set('status', value)} + options={[ + { value: 'pending', label: t('reservations.pending') }, + { value: 'confirmed', label: t('reservations.confirmed') }, + ]} + size="sm" + /> +
)} @@ -561,7 +631,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p -
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 4d37fab..e517663 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -136,7 +136,12 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo {r.reservation_time && (
{t('reservations.date')}
-
{fmtDate(r.reservation_time)}
+
+ {fmtDate(r.reservation_time)} + {r.reservation_end_time?.includes('T') && r.reservation_end_time.split('T')[0] !== r.reservation_time.split('T')[0] && ( + <> – {fmtDate(r.reservation_end_time)} + )} +
)} {r.reservation_time?.includes('T') && ( diff --git a/client/src/components/Todo/TodoListPanel.tsx b/client/src/components/Todo/TodoListPanel.tsx new file mode 100644 index 0000000..10c2d39 --- /dev/null +++ b/client/src/components/Todo/TodoListPanel.tsx @@ -0,0 +1,778 @@ +import { useState, useMemo, useEffect } from 'react' +import { useTripStore } from '../../store/tripStore' +import { useCanDo } from '../../store/permissionsStore' +import { useToast } from '../shared/Toast' +import { useTranslation } from '../../i18n' +import { tripsApi } from '../../api/client' +import apiClient from '../../api/client' +import CustomSelect from '../shared/CustomSelect' +import { CustomDatePicker } from '../shared/CustomDateTimePicker' +import { formatDate as fmtDate } from '../../utils/formatters' +import { + CheckSquare, Square, Plus, ChevronRight, Flag, + X, Check, Calendar, User, FolderPlus, AlertCircle, ListChecks, Inbox, CheckCheck, Trash2, +} from 'lucide-react' +import type { TodoItem } from '../../types' + +const KAT_COLORS = [ + '#3b82f6', '#a855f7', '#ec4899', '#22c55e', '#f97316', + '#06b6d4', '#ef4444', '#eab308', '#8b5cf6', '#14b8a6', +] + +const PRIO_CONFIG: Record = { + 1: { label: 'P1', color: '#ef4444' }, + 2: { label: 'P2', color: '#f59e0b' }, + 3: { label: 'P3', color: '#3b82f6' }, +} + +function katColor(kat: string, allCategories: string[]) { + const idx = allCategories.indexOf(kat) + if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length] + let h = 0 + for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0 + return KAT_COLORS[Math.abs(h) % KAT_COLORS.length] +} + +type FilterType = 'all' | 'my' | 'overdue' | 'done' | string + +interface Member { id: number; username: string; avatar: string | null } + +export default function TodoListPanel({ tripId, items }: { tripId: number; items: TodoItem[] }) { + const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore() + const canEdit = useCanDo('packing_edit') + const toast = useToast() + const { t, locale } = useTranslation() + const formatDate = (d: string) => fmtDate(d, locale) || d + + const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768) + useEffect(() => { + const mq = window.matchMedia('(max-width: 767px)') + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, []) + + const [filter, setFilter] = useState('all') + const [selectedId, setSelectedId] = useState(null) + const [isAddingNew, setIsAddingNew] = useState(false) + const [sortByPrio, setSortByPrio] = useState(false) + const [addingCategory, setAddingCategory] = useState(false) + const [newCategoryName, setNewCategoryName] = useState('') + const [members, setMembers] = useState([]) + const [currentUserId, setCurrentUserId] = useState(null) + + useEffect(() => { + apiClient.get(`/trips/${tripId}/members`).then(r => { + const owner = r.data?.owner + const mems = r.data?.members || [] + const all = owner ? [owner, ...mems] : mems + setMembers(all) + setCurrentUserId(r.data?.current_user_id || null) + }).catch(() => {}) + }, [tripId]) + + const categories = useMemo(() => { + const cats = new Set() + items.forEach(i => { if (i.category) cats.add(i.category) }) + return Array.from(cats).sort() + }, [items]) + + const today = new Date().toISOString().split('T')[0] + + const filtered = useMemo(() => { + let result: TodoItem[] + if (filter === 'all') result = items.filter(i => !i.checked) + else if (filter === 'done') result = items.filter(i => !!i.checked) + else if (filter === 'my') result = items.filter(i => !i.checked && i.assigned_user_id === currentUserId) + else if (filter === 'overdue') result = items.filter(i => !i.checked && i.due_date && i.due_date < today) + else result = items.filter(i => i.category === filter) + if (sortByPrio) result = [...result].sort((a, b) => { + const ap = a.priority || 99 + const bp = b.priority || 99 + return ap - bp + }) + return result + }, [items, filter, currentUserId, today, sortByPrio]) + + const selectedItem = items.find(i => i.id === selectedId) || null + const totalCount = items.length + const doneCount = items.filter(i => !!i.checked).length + const overdueCount = items.filter(i => !i.checked && i.due_date && i.due_date < today).length + const myCount = currentUserId ? items.filter(i => !i.checked && i.assigned_user_id === currentUserId).length : 0 + + const addCategory = () => { + const name = newCategoryName.trim() + if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return } + addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any) + .then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) }) + .catch(err => toast.error(err instanceof Error ? err.message : 'Error')) + } + + // Get category count (non-done items) + const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length + + // Sidebar filter item + const SidebarItem = ({ id, icon: Icon, label, count, color }: { id: string; icon: any; label: string; count: number; color?: string }) => ( + + ) + + // Filter title + const filterTitle = (() => { + if (filter === 'all') return t('todo.filter.all') + if (filter === 'done') return t('todo.filter.done') + if (filter === 'my') return t('todo.filter.my') + if (filter === 'overdue') return t('todo.filter.overdue') + return filter + })() + + return ( +
+ + {/* ── Left Sidebar ── */} +
+ {/* Progress Card */} + {!isMobile &&
+
+ + {totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0}% + +
+
+
0 ? `${Math.round((doneCount / totalCount) * 100)}%` : '0%', background: '#22c55e', borderRadius: 2, transition: 'width 0.3s' }} /> +
+
+ {doneCount} / {totalCount} {t('todo.completed')} +
+
} + + {/* Smart filters */} + {!isMobile &&
+ {t('todo.sidebar.tasks')} +
} + !i.checked).length} /> + + + + + {/* Sort by priority */} + + + {/* Categories */} + {!isMobile &&
+ {t('todo.sidebar.categories')} +
} + {isMobile &&
} + {categories.map(cat => ( + + ))} + + {canEdit && ( + addingCategory && !isMobile ? ( +
+ setNewCategoryName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCategoryName('') } }} + placeholder={t('todo.newCategory')} + style={{ flex: 1, fontSize: 12, padding: '4px 6px', border: '1px solid var(--border-primary)', borderRadius: 5, background: 'var(--bg-hover)', color: 'var(--text-primary)', fontFamily: 'inherit', minWidth: 0 }} /> + +
+ ) : ( + + ) + )} +
+ + {/* ── Middle: Task List ── */} +
+ {/* Header */} +
+
+

+ {filterTitle} +

+ + {filtered.length} + +
+
+ + {/* Add task */} + {canEdit && ( +
+ +
+ )} + + {/* Task list */} +
+ {filtered.length === 0 ? null : ( + filtered.map(item => { + const done = !!item.checked + const assignedUser = members.find(m => m.id === item.assigned_user_id) + const isOverdue = item.due_date && !done && item.due_date < today + const isSelected = selectedId === item.id + const catColor = item.category ? katColor(item.category, categories) : null + + return ( +
{ setSelectedId(isSelected ? null : item.id); setIsAddingNew(false) }} + style={{ + display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', + borderBottom: '1px solid var(--border-faint)', cursor: 'pointer', + background: isSelected ? 'var(--bg-hover)' : 'transparent', + transition: 'background 0.1s', + }} + onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.02)' }} + onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}> + + {/* Checkbox */} + + + {/* Content */} +
+
+ {item.name} +
+ {/* Description preview */} + {item.description && ( +
+ {item.description} +
+ )} + {/* Inline badges */} + {(item.priority || item.due_date || catColor || assignedUser) && ( +
+ {item.priority > 0 && PRIO_CONFIG[item.priority] && ( + + {PRIO_CONFIG[item.priority].label} + + )} + {item.due_date && ( + + {formatDate(item.due_date)} + + )} + {catColor && ( + + + {item.category} + + )} + {assignedUser && ( + + {assignedUser.avatar ? ( + + ) : ( + + {assignedUser.username.charAt(0).toUpperCase()} + + )} + {assignedUser.username} + + )} +
+ )} +
+ + {/* Chevron */} + +
+ ) + }) + )} +
+
+ + {/* ── Right: Detail Pane ── */} + {selectedItem && !isAddingNew && !isMobile && ( + setSelectedId(null)} + /> + )} + {selectedItem && !isAddingNew && isMobile && ( +
{ if (e.target === e.currentTarget) setSelectedId(null) }} + style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}> +
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}> + setSelectedId(null)} + /> +
+
+ )} + {isAddingNew && !selectedItem && !isMobile && ( + { setIsAddingNew(false); setSelectedId(id) }} + onClose={() => setIsAddingNew(false)} + /> + )} + {isAddingNew && !selectedItem && isMobile && ( +
{ if (e.target === e.currentTarget) setIsAddingNew(false) }} + style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}> +
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}> + { setIsAddingNew(false); setSelectedId(id) }} + onClose={() => setIsAddingNew(false)} + /> +
+
+ )} +
+ ) +} + +// ── Detail Pane (right side) ────────────────────────────────────────────── + +function DetailPane({ item, tripId, categories, members, onClose }: { + item: TodoItem; tripId: number; categories: string[]; members: Member[]; + onClose: () => void; +}) { + const { updateTodoItem, deleteTodoItem } = useTripStore() + const canEdit = useCanDo('packing_edit') + const toast = useToast() + const { t } = useTranslation() + + const [name, setName] = useState(item.name) + const [desc, setDesc] = useState(item.description || '') + const [dueDate, setDueDate] = useState(item.due_date || '') + const [category, setCategory] = useState(item.category || '') + const [assignedUserId, setAssignedUserId] = useState(item.assigned_user_id) + const [priority, setPriority] = useState(item.priority || 0) + const [saving, setSaving] = useState(false) + + // Sync when selected item changes + useEffect(() => { + setName(item.name) + setDesc(item.description || '') + setDueDate(item.due_date || '') + setCategory(item.category || '') + setAssignedUserId(item.assigned_user_id) + setPriority(item.priority || 0) + }, [item.id, item.name, item.description, item.due_date, item.category, item.assigned_user_id, item.priority]) + + const hasChanges = name !== item.name || desc !== (item.description || '') || + dueDate !== (item.due_date || '') || category !== (item.category || '') || + assignedUserId !== item.assigned_user_id || priority !== (item.priority || 0) + + const save = async () => { + if (!name.trim() || !hasChanges) return + setSaving(true) + try { + await updateTodoItem(tripId, item.id, { + name: name.trim(), description: desc || null, + due_date: dueDate || null, category: category || null, + assigned_user_id: assignedUserId, priority, + } as any) + } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') } + setSaving(false) + } + + const handleDelete = async () => { + try { + await deleteTodoItem(tripId, item.id) + onClose() + } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') } + } + + const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' } + const inputStyle: React.CSSProperties = { + width: '100%', fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', + borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', + } + + return ( +
+ {/* Header */} +
+ {t('todo.detail.title')} + +
+ + {/* Form */} +
+ {/* Name */} +
+ setName(e.target.value)} disabled={!canEdit} + style={{ ...inputStyle, fontSize: 15, fontWeight: 600, border: 'none', padding: '4px 0', background: 'transparent' }} + placeholder={t('todo.namePlaceholder')} /> +
+ + {/* Description */} +
+ +