Compare commits

...

35 Commits

Author SHA1 Message Date
jubnl ee31c78db8 fix(maps): null stale proxy image_url entries instead of writing unbacked proxy URLs
Migration 107 and the previous fix both wrote /api/maps/place-photo/<id>/bytes
into places.image_url without ever fetching the photo bytes. photoService
short-circuits on that URL prefix and hits /bytes directly, which 404s because
nothing is on disk.

- Add migration to null proxy image_url rows with no backing google_place_photo_meta
  entry — restores the normal fetch-and-cache flow for affected rows
- Fix the previous legacy-URL migration to null instead of rewrite, so fresh
  installs don't hit the same 404 path

Fixes #770 (follow-up)
2026-04-21 00:46:29 +02:00
jubnl edf14e2ebc test(maps): update getPlacePhoto stubs to use text() instead of json()
mapsService now reads the details response body via .text() before parsing,
so test stubs need text() rather than json().
2026-04-21 00:16:54 +02:00
jubnl 2aad8f465c fix(maps): prevent server crash when legacy Google photo URLs are stored as placeIds
Migration 107 only rewrote image_url rows matching /places/%/photos/%; URLs using
the /place-photos/ or /places/<opaque> paths survived the upgrade and were passed
verbatim to the Places API, producing a malformed request whose empty/HTML response
body threw SyntaxError before detailsRes.ok was checked. The resulting rejection was
leaked by placePhotoCache.setInFlight via an unhandled .finally() chain, triggering
Node 22's default unhandledRejection=throw and terminating the process.

- placePhotoCache: add .catch() after .finally() to prevent unhandled rejection crash
- mapsService: reject URL-shaped placeIds early; read response as text before JSON.parse
- migrations: add migration to rewrite remaining googleusercontent/places.googleapis URLs
- MapView/MapViewGL: prefer stable proxy URL form of image_url before google_place_id

Fixes #770
2026-04-21 00:13:35 +02:00
jubnl 16b81a8356 fix(bookings): preserve accommodation dates when place is unlinked or missing
- Remove NOT NULL constraint on day_accommodations.place_id (migration)
  and change ON DELETE CASCADE → SET NULL so deleting a place no longer
  cascades to the accommodation row
- Switch listAccommodations / getAccommodationWithPlace to LEFT JOIN so
  accommodations without a linked place are visible to the modal
- Relax create/update guards in reservationService to only require
  start_day_id + end_day_id, not place_id; place_id remains optional
- Client save guard now sends create_accommodation whenever FROM/TO days
  are set, regardless of whether a hotel place was selected
- Add re-hydration useEffect in ReservationModal to back-fill hotel
  fields from the accommodations prop when it arrives after modal opens
  (race between isOpen and the tripAccommodations fetch)
- Fix demo-seed TDZ crash: move db Proxy declaration before DEMO_MODE
  block so circular require in demo-reset resolves correctly
- Sidebar accommodation badge falls back to reservation title when
  place_name is null; click/cursor disabled for placeless accommodations
- listAccommodations now joins reservations to expose reservation_title
2026-04-20 23:09:05 +02:00
Maurice 5984adb2ea Merge pull request #768 from mauriceboe/fix/ui-pre-release-bugs
fix: pre-release UI bug batch (#759 #760 #761 #763 #764)
2026-04-20 22:18:53 +02:00
Maurice f8eb1915fe fix(map): render transport reservations on Mapbox GL
ReservationOverlay was Leaflet-only: react-leaflet components, L.divIcon,
panes, useMap/useMapEvents. When the user switched the planner map to
Mapbox GL, the entire feature disappeared — no polylines, no endpoint
badges, no clickable IATA labels.

Add a matching overlay for the Mapbox renderer:

- New reservationsMapbox.ts with an imperative `ReservationMapboxOverlay`
  class — mapbox-gl is imperative, so a React component wrapper would
  fight its own lifecycle every render. The manager owns one GeoJSON
  source + line layer for the arcs, one HTML `mapboxgl.Marker` per
  endpoint badge, and one per flight stats label. It cleans itself up
  when the map is rebuilt (style/token/3d toggle) or unmounted.
- Geometry helpers (great-circle arc, antimeridian split, haversine,
  tz-aware duration math, label formatting) are copied from the Leaflet
  overlay so both renderers produce the same lines. Great-circle is
  useful even on the Mapbox globe because the mercator projection mode
  still draws the short-way line, and the antimeridian split prevents
  a NYC↔Tokyo flight from wrapping halfway around the planet.
- Flights / cruises get geodesic arcs; trains / cars get straight
  lines. All four types get clickable endpoint badges with the
  matching lucide icon; only flights render the rotating mid-arc stats
  label (IATA → IATA · distance · duration) — same rule as the Leaflet
  overlay.
- The stats label's rotation is recomputed on every `render` event by
  projecting two points straddling the arc midpoint, which keeps it
  parallel to the arc as the camera rotates/zooms on the globe.
- Visibility thresholds mirror the Leaflet overlay (per-type min pixel
  distance before a line / endpoint label is worth drawing).
- MapViewGL now accepts the `reservations`, `visibleConnectionIds`,
  `showReservationStats`, `onReservationClick` props that the Leaflet
  MapView already took. `visibleConnectionIds` is honoured the same way
  — the per-booking toggle in DayPlanSidebar controls which routes
  appear, so switching the renderer doesn't lose that UX.
- Added a `mapReady` gate so the overlay can only add its source/layer
  once the map's `load` handler has attached the other trip sources;
  the gate resets on every style rebuild.
2026-04-20 22:09:19 +02:00
jubnl b556c636eb fix: tighten 401 redirect allowlist and add reset-password paths
Replaced loose includes()/startsWith() path checks with exact equality
for static routes and strict prefix matching for dynamic-token routes.
Added /forgot-password and /reset-password to the allowlist so the
password-reset flow is usable without auth. Extracted isAuthPublicPath
as a pure testable function with 14 unit tests covering regressions.
2026-04-20 21:55:15 +02:00
Maurice b20db1428d fix: pre-release UI bug batch
- Budget table column alignment: the NAME data cell had
  `display: flex` directly on the <td>, which pulled it out of the
  table-layout and desynced the column widths between data rows and the
  AddItemRow. Moved the flex wrapper into a <div> inside the cell.
  Closes #759
- Packing list: template-apply and bulk-import handlers called
  `window.location.reload()` to refresh the list, which re-rendered the
  whole trip loading screen. Both flows now merge the returned items
  into the trip store instead. Closes #760
- Journey timeline: move-up / move-down arrows were rendered on
  skeleton suggestions — skeletons are places from the linked trip and
  don't participate in sort order. Skip canReorder when
  entry.type === 'skeleton'. Closes #763
- Journey public view: the synthetic `[Trip Photos]` and `Gallery`
  entries produced by syncTripPhotos were leaking into the public
  timeline and map. The owner view already strips these in
  JourneyDetailPage — apply the same filter on JourneyPublicPage.
  Gallery photos still come from every entry so a shared gallery keeps
  showing the trip-synced photos. Closes #764
- Journey thumbnails: public gallery grid was loading the original
  asset for every tile. `photoUrl()` now takes an optional kind and the
  grid requests `thumbnail`; the lightbox still opens the original.
  Synology thumbnail default bumped from `sm` (240px) to `m` (320px)
  because `sm` looked pixelated on retina. Closes #761
2026-04-20 21:53:45 +02:00
Julien G. 4a5a59cb78 Merge pull request #766 from mauriceboe/security/audit-fixes-batch-1
security: internal audit — batch 1
2026-04-20 21:41:00 +02:00
jubnl 20bf9c2312 security: close SEC-H4/H6 gaps from second-pass review
- SEC-H6: remove conditional audience check in mcp/index.ts — audience is
  now always enforced against the mcpResource URL. Add migration to revoke
  pre-existing oauth_tokens with audience=NULL so dead rows don't linger.
- SEC-H4: validate doc.issuer against config.issuer inside discover() to
  prevent a MITM'd discovery doc from supplying a crafted expected issuer.
  verifyIdToken caller now passes config.issuer as ground truth, not
  doc.issuer.
- tests: cover three new OIDC callback failure paths (no_id_token,
  id_token_invalid, subject_mismatch) and two idempotency caps (key length
  >128 chars returns 400, body >256 KiB skips caching).
2026-04-20 21:35:30 +02:00
Maurice 9f57ab4517 security: address second-pass audit findings
- CI-C1 false positive: actions/{checkout,setup-node,upload-artifact}
  @v6 do exist (v6.0.0 releases published Oct-Dec 2025). Restore the
  @v6 refs — the earlier batch-1 commit downgraded them unnecessarily.
- Widen idempotency_keys primary key to (key, user_id, method, path)
  via new migration. Batch 1 widened the middleware lookup but left
  the table PK at (key, user_id), so `INSERT OR IGNORE` silently
  skipped the second endpoint that reused a key — the cache was
  never populated for it and a replay re-ran the handler. The
  migration rebuilds the table preserving existing rows (the old
  narrower PK guarantees no conflicts against the new looser key).
- HSTS: keep `includeSubDomains` OFF by default. Enabling it for
  every NODE_ENV=production install would break apex-domain setups
  where siblings still serve HTTP. Operators who want the stricter
  policy opt in with HSTS_INCLUDE_SUBDOMAINS=true.
- Extend the idempotency unit tests to cover the (method, path)
  dimension — same user+key on different path no longer replays.
2026-04-20 21:04:09 +02:00
Maurice 292e443dbe security: address silent-failure review findings on top of batch 1
Second-pass fixes caught by a self-review after the initial commit — each
one would have undermined a fix from the previous commit.

- mfaPolicy now goes through `verifyJwtAndLoadUser` too. Without this,
  a JWT stolen before a password reset still satisfied `require_mfa`
  until its natural 24h expiry, defeating the whole point of the
  password_version bump.
- Drop the `?? keys[0]` fallback in OIDC JWKS key selection. When the
  token carries a `kid` that is not in the current JWKS, refuse
  outright instead of picking an arbitrary key and letting the
  signature check produce a generic failure — the real failure mode
  deserves a specific error code.
- Tighten OAuth DCR custom-scheme rule so `javascript:`, `data:`,
  `vbscript:`, `file:`, `blob:`, `about:`, `chrome:` are all rejected.
  Previously the catch-all "not http/https" check admitted them; the
  authorize flow later 302s the browser to whatever is registered,
  which with a `javascript:` URI would execute attacker script on
  redirect. Also require the private-use scheme body to be reverse-DNS
  (contain a dot), matching RFC 8252 §7.1.
- permanentDeleteFile / emptyTrash only delete the trip_files row when
  the on-disk unlink actually succeeded. Previously Promise.all
  swallowed individual unlink failures and DELETE ran unconditionally,
  so a permission / ENOSPC failure would orphan bytes on disk.
- restoreFromZip also invalidates the permissions cache in the outer
  catch. If extraction threw before the DB swap even started, the
  cache wasn't stale, but belt-and-braces is cheap and guarantees no
  failed-restore path leaves stale cache behind.
2026-04-20 20:44:57 +02:00
Maurice 2d0414b4a3 security: internal audit — batch 1
Fixes the critical + high + medium findings from our internal security
review. Bundled into one PR because the changes overlap heavily (JWT
verification unifies across three call sites; backup-code hashing and
demo-email handling cross-cut several services); splitting them out
would mean redundant reviews of the same files.

Critical
- CI-C1 — .github/workflows/test.yml: restore actions/{checkout,setup-
  node,upload-artifact} to @v4. The @v6 refs don't exist, so the test
  workflow was errorring before a single test ran.
- SEC-C1 — mfaPolicy now extracts the token via extractToken() (cookie-
  first, Bearer fallback). Previously it only read Authorization, so
  every cookie-authenticated SPA session bypassed require_mfa entirely.
- SEC-C2/C4/C6 — all JWT verification paths (MCP bearer, file download,
  photo route) now go through the shared verifyJwtAndLoadUser that
  checks password_version. resetPassword additionally deletes every
  mcp_tokens row and marks outstanding oauth_tokens revoked, so a
  password reset invalidates ALL credential classes — not just the
  cookie JWT.

High
- SEC-H2 — reset email URL is built from server-side APP_URL /
  ALLOWED_ORIGINS (via existing getAppUrl()), not request headers.
  Closes the host-header-injection vector into reset links.
- SEC-H3 — OIDC findOrCreateUser wraps the invite-redemption UPDATE +
  user INSERT in a transaction. The UPDATE is the capacity check; if
  a concurrent callback takes the last slot, the whole transaction
  aborts with registration_disabled instead of double-creating users.
- SEC-H4 — new verifyIdToken() performs full JWT signature
  verification via the provider's JWKS (Node's crypto.createPublicKey
  accepts JWK directly — no extra dependency), plus iss/aud/exp
  checks. The callback also rejects the login when userinfo.sub does
  not match id_token.sub.
- SEC-H5 — OAuth DCR now validates redirect_uris against an allowlist
  of schemes: https, http-loopback, or a private custom scheme. Plain
  http://non-loopback is rejected.
- SEC-H6 — oauthService audience defaults to mcpResource when the
  `resource` parameter is missing, so tokens are always audience-bound
  to /mcp instead of being issued with audience=null.
- SEC-H7 — HSTS is enabled any time NODE_ENV=production (previously
  required FORCE_HTTPS=true), includeSubDomains defaults on and can
  be disabled with HSTS_INCLUDE_SUBDOMAINS=false.
- SEC-H8 — trek_session cookie Secure flag is also driven by
  req.secure (which Express resolves from X-Forwarded-Proto once
  trust proxy is set), so instances behind a TLS-terminating proxy
  get Secure cookies without needing FORCE_HTTPS.

Medium
- SEC-M1 — permanentDeleteFile / emptyTrash / avatar unlink now use
  fs.promises.rm with { force: true } (one async op vs the previous
  existsSync + unlinkSync pair per file).
- SEC-M2 — invalidatePermissionsCache() is called inside restoreFromZip
  so a restored DB with different permission rows is honoured
  immediately.
- SEC-M3 + C1 — idempotency store bounds the key at 128 chars, caches
  only responses ≤ 256 KiB, and scopes the lookup by (key, user_id,
  method, path) rather than (key, user_id). Same key replayed against
  a different endpoint no longer returns a stale unrelated body.
- SEC-M4 — share_tokens gets an expires_at column; new tokens default
  to 90-day TTL, expired tokens are denied at lookup. Existing tokens
  stay NULL = no expiry so already-published links don't break.
- SEC-M5 — /uploads/photos/:filename now resolves the photo to its
  trip_id and requires the share token to cover THAT trip. Previously
  any share token for any trip would unlock any photo filename.
- SEC-M6 — BLOCKED_EXTENSIONS is the single source of truth shared
  between fileService and collab uploads. The '*' allowed_file_types
  wildcard now still rejects executables/scripts.
- SEC-M7 — single DEMO_EMAILS constant (services/demo.ts) used by
  demoUploadBlock, mfaPolicy, and every demo-mode guard in
  authService. The old demoUploadBlock only matched 'demo@nomad.app'
  so the seed 'demo@trek.app' could in fact upload in demo mode.
- SEC-M8 — MFA backup codes are now bcrypt-hashed at rest
  (hashBackupCodeBcrypt). matchBackupCode accepts both bcrypt and
  legacy SHA-256 hex hashes, so existing installs keep working until
  the user regenerates codes via enableMfa.
- SEC-M9 — document the "security via UUID v4 filename" model for
  /uploads/avatars|covers|journey. Requires no code change but
  captures the decision so future reviewers don't re-flag it.
- SEC-M10 — already covered by the resetPassword revocation logic
  above: mcp_tokens DELETE + oauth_tokens UPDATE … SET revoked_at.

Performance
- PERF-H1 — new migration adds the indexes flagged in the audit:
  trips(user_id), trips(created_at DESC), photos(day_id),
  photos(place_id), reservations(day_id), share_tokens(token), plus
  conditional day_accommodations and notifications indexes depending
  on which columns are present.

Tests
- tests/integration/oidc.test.ts now mocks verifyIdToken and passes
  an id_token in the exchangeCodeForToken stub for the three flows
  that exercise a successful callback. The three remaining failures
  tests pointed out were all pre-existing (file-upload flakes +
  notificationPreferences event_types count drift), none introduced
  by this PR.
2026-04-20 20:36:52 +02:00
Maurice e612de9143 Merge pull request #757 from mauriceboe/feat/todo-due-reminders
feat(notifications): reminders for todos with upcoming due dates
2026-04-20 17:43:59 +02:00
Maurice c857d38bcd test(notifications): bump event_types count to 9 after adding todo_due 2026-04-20 17:38:25 +02:00
Maurice d7a71c0572 feat(notifications): reminders for todos with upcoming due dates
Todos already support a due_date field but nothing notifies the user
when a deadline is approaching — you'd only remember if you happened
to look at the Lists tab. This wires a reminder into the existing
notification pipeline so due-date todos behave like trip-start
reminders.

Details:
- New `todo_due` event type alongside trip_reminder; all four channels
  (in-app, email, webhook, ntfy) supported and toggleable per user in
  Settings > Notifications.
- New daily scheduler task (9 AM local TZ) queries unchecked todos
  whose due_date is within the next 3 days. Each todo gets at most
  one reminder per 24 hours, tracked via a new todo_items.reminded_at
  column (migration 116).
- If the todo has an assigned user, only that user is reminded; if
  not, every member of the trip gets the notification.
- Strings added in all 15 UI languages and for all notification
  carriers.
- Gated by app_settings.notify_todo_due (default on) so admins can
  disable it globally.
2026-04-20 17:31:25 +02:00
Julien G. 58c061e653 Merge pull request #756 from mauriceboe/fix/planner-drag-drop-jank
fix(planner): eliminate drag-and-drop jank in trip planner
2026-04-20 17:23:06 +02:00
Maurice 22d1d06d39 docs(readme): point hero GIF URL at renamed trek-media repo 2026-04-20 17:17:08 +02:00
jubnl 290f566daa fix(planner): eliminate drag-and-drop jank in trip planner
- Suppress trek-stagger animation on the day list while a drag is active
  so nth-child delays (0–320 ms) no longer re-fire on every hover change
- Replace sibling drop-indicator <div> injections with borderTop/borderBottom
  on the target row to prevent nth-child index shifts during drag
- Dedup setDragOverDayId calls in onDragOver handlers so setState is only
  invoked when the active day actually changes
- Move initTransportPositions out of getMergedItems (render path) into a
  useEffect to stop mid-drag setState cascades
2026-04-20 17:16:57 +02:00
Maurice 8ca2507050 Merge pull request #755 from mauriceboe/fix/readme-hero-gif-external
docs(readme): move hero GIF to external release asset
2026-04-20 17:11:07 +02:00
Maurice 9c666a0aaf docs(readme): move hero GIF to external release asset
Moves the 91 MB product-tour GIF out of the repo entirely. Standard
clones and CI checkouts no longer pull it — even LFS-aware clients
previously downloaded the blob on checkout, which made `git pull`
noticeably slower for everyone.

The file now lives as a release asset on a separate repo and is
referenced from README via its GitHub Fastly-backed download URL.
Removes the LFS tracking entry from .gitattributes.
2026-04-20 17:09:02 +02:00
Julien G. b3f2f7308a Merge pull request #748 from mauriceboe/docs/wiki
Docs/wiki
2026-04-20 16:50:50 +02:00
Maurice af9b31c1ff Merge pull request #754 from mauriceboe/fix/journey-gallery-picker-safari
fix(journey): repair gallery picker grid collapsing in Safari (#717)
2026-04-20 16:47:54 +02:00
jubnl d7d1493289 docs(wiki): document self-service password reset feature
Update Password-Reset.md and Login-and-Registration.md to reflect the
email-based forgot-password flow added in feat(auth): 51387b0, including
the SMTP-less console fallback, MFA gate, session invalidation, rate
limits, and security properties.
2026-04-20 16:43:53 +02:00
Maurice 54e042b736 fix(journey): repair gallery picker grid collapsing in Safari (#717)
The 'From Gallery' picker on the journey entry editor used `aspect-square`
on grid items inside an overflow-scrolling container. Safari (desktop and
iOS) collapses the computed height of aspect-ratio boxes in this layout,
which stacked every thumbnail at y=0 — making selection impossible.

Swap to the classic padding-top spacer pattern (`paddingTop: '100%'` on
the cell + absolutely positioned image) which is bulletproof across
browsers and preserves the 5/6-column grid on mobile/desktop.
2026-04-20 16:43:21 +02:00
Julien G. 0ba31847eb Merge pull request #753 from mauriceboe/dev
Dev
2026-04-20 16:36:34 +02:00
Maurice 26ab39dc21 Merge pull request #752 from mauriceboe/feat/readme-redesign
docs(readme): Apple-style redesign — animated hero, feature tiles, product tour
2026-04-20 16:27:59 +02:00
Maurice 00be0eab05 docs(readme): Apple-style redesign — animated hero, feature tiles, gallery
- Animated TREK logo (light + dark variants) via <picture> + prefers-color-scheme
- 60-second product tour GIF (91MB, 1100x619, 10fps) stored via Git LFS so
  standard clones don't pull it by default
- 9 feature tiles as composite SVG grids: 3x3 on desktop, 2x4 on mobile
- 8 fresh screenshots captured from dev.pakulat.org
- Feature details folded into a collapsible 2-column table
- Environment variables moved behind a collapsible
- Roadmap badge added next to Live Demo / Docker / Discord
- Removed redundant Community section and footer
2026-04-20 16:25:38 +02:00
Maurice ed97bb1deb Merge pull request #750 from mauriceboe/feat/password-reset
feat(auth): password reset via email with MFA + session invalidation
2026-04-20 14:16:17 +02:00
Maurice 51387b0af1 feat(auth): add email-based password reset with MFA + session invalidation
Adds /auth/forgot-password and /auth/reset-password endpoints plus two new
client pages. When SMTP is configured the user receives a branded, i18n-aware
reset email; when it isn't the reset link is logged to the server console in
a clearly-fenced block so self-hosters can relay it manually.

Security properties:
- 256-bit cryptographically-random tokens, only SHA-256 hashes stored in DB
- 60 min expiry, single-use, prior unconsumed tokens auto-invalidated
- Enumeration-safe: /forgot-password always responds {ok:true} with a minimum
  latency pad so timing doesn't leak account existence
- Per-IP rate limit (3/15min on forgot, 5/15min on reset) + per-email throttle
- If the user has MFA enabled, a valid TOTP or backup code is required at
  reset-complete time — a compromised mailbox alone cannot take over a
  2FA-protected account
- New users.password_version column + JWT "pv" claim: bumping it on reset
  invalidates every live session immediately
- Full audit-log coverage (user.password_reset_request/_success/_fail)
- Forgot-page shows a visible hint when SMTP is unconfigured

Migration 115 adds users.password_version and password_reset_tokens
(user_id, token_hash UNIQUE, expires_at, consumed_at, created_ip).
2026-04-20 14:06:42 +02:00
jubnl 1559ed12bd fix(wiki): update mapbox scopes and url 2026-04-20 10:18:44 +02:00
jubnl c1b9d11173 docs: add full wiki with 74 pages, assets, and CI workflow
Adds the complete TREK documentation wiki covering installation,
trip planning, admin panel, MCP/AI integration, addons, and operations.

Also fixes encrypt-at-rest gaps: mapbox_access_token, Synology
credentials, per-user webhook/ntfy tokens, and photo passphrases
are now rotated by migrate-encryption.ts and stored encrypted via
settingsService.
2026-04-20 10:11:53 +02:00
Julien G. 2ab8b401fb Merge pull request #747 from mauriceboe/fix/mcp-oauth-protected-resource-rfc8707
fix(mcp): RFC 9728 PRM, RFC 8707 audience binding, collab sub-feature gating, z.record Zod v4 fix
2026-04-20 08:04:23 +02:00
jubnl 49af7a8b0d fix(mcp): fix z.record() Zod v4 API compat in transport tool schemas
Zod v4 changed z.record(valueType) to z.record(keyType, valueType).
The single-arg form now sets keyType, leaving valueType as undefined.
This caused tools/list to throw 'Cannot read properties of undefined
(reading _zod)' when the SDK tried to serialize the metadata field to
JSON Schema, silently returning an error for every tools/list call and
making all MCP tools invisible in claude.ai.
2026-04-20 07:57:40 +02:00
jubnl dd90c6d424 fix(mcp): add RFC 9728 PRM, RFC 8707 audience binding, and collab sub-feature gating
Root cause: claude.ai's MCP connector (spec 2025-06-18) requires the resource server
to publish Protected Resource Metadata and return WWW-Authenticate on 401s to bind
the /mcp endpoint to its AS. Without these, it silently shows no tools after OAuth.

- Add /.well-known/oauth-protected-resource (RFC 9728) with addon gating
- Emit WWW-Authenticate: Bearer resource_metadata=... on 401/auth-failure 403s
- Open CORS (origin: *) on both .well-known/* endpoints per RFC 8414/9728
- Accept resource parameter at authorize + token endpoints (RFC 8707)
- Store audience on oauth_tokens; validate on every MCP request
- Refresh tokens inherit audience; add resource_parameter_supported to AS metadata
- DB migration: ADD COLUMN audience TEXT to oauth_tokens
- Gate collab MCP tools/resources by chat/notes/polls sub-features individually
- Invalidate MCP sessions when collab sub-features are toggled in admin
- Update test mocks and MCP.md
2026-04-20 07:34:38 +02:00
221 changed files with 9041 additions and 530 deletions
-2
View File
@@ -1,6 +1,5 @@
# Normalize line endings to LF on commit
* text=auto eol=lf
# Explicitly enforce LF for source files
*.ts text eol=lf
*.tsx text eol=lf
@@ -14,7 +13,6 @@
*.yaml text eol=lf
*.py text eol=lf
*.sh text eol=lf
# Binary files — no line ending conversion
*.png binary
*.jpg binary
+2
View File
@@ -6,6 +6,8 @@ on:
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'wiki/**'
- '.github/workflows/wiki.yml'
workflow_dispatch:
inputs:
bump:
+26
View File
@@ -0,0 +1,26 @@
name: Deploy Wiki
on:
push:
branches: [main]
paths:
- 'wiki/**'
- '.github/workflows/wiki.yml'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: wiki-deploy
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
strategy: init
+3 -1
View File
@@ -58,4 +58,6 @@ coverage
*.tgz
.scannerwork
test-data
test-data
.run
+5 -4
View File
@@ -53,10 +53,11 @@ management required — just provide the server URL:
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
**What happens automatically:**
1. The client fetches `/.well-known/oauth-authorization-server` to discover the TREK authorization server.
2. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
3. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
4. The client receives a short-lived access token and a rotating refresh token — no re-authorization needed.
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
> discovery to work correctly.
+273 -217
View File
@@ -1,121 +1,160 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
<img src="client/public/logo-light.svg" alt="TREK" height="60" />
</picture>
<br />
<em>Your Trips. Your Plan.</em>
</p>
<div align="center">
<p align="center">
<a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
<a href="https://github.com/mauriceboe/TREK/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/TREK" alt="Last Commit" /></a>
</p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
</picture>
<p align="center">
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
<br />
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try TREK without installing. Resets hourly.
</p>
### Your trips. Your plan. Your server.
![TREK Screenshot](docs/screenshot.png)
![TREK Screenshot 2](docs/screenshot-2.png)
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
<br />
<a href="https://demo-nomad.pakulat.org"><img alt="Live Demo" src="https://img.shields.io/badge/Live_Demo-try_it_now-111827?style=for-the-badge" /></a>
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" /></a>
&nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-community-5865F2?style=for-the-badge&logo=discord&logoColor=white" /></a>
&nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-board-0EA5E9?style=for-the-badge&logo=trello&logoColor=white" /></a>
<br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
</div>
---
<div align="center">
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
</div>
<br />
<div align="center">
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
</div>
---
## What you get
<picture>
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
</picture>
<details>
<summary>More Screenshots</summary>
<summary><b>See all features</b></summary>
| | |
|---|---|
| ![Plan Detail](docs/screenshot-plan-detail.png) | ![Bookings](docs/screenshot-bookings.png) |
| ![Budget](docs/screenshot-budget.png) | ![Packing List](docs/screenshot-packing.png) |
| ![Collab](docs/screenshot-collab.png) | |
<table>
<tr>
<td width="50%" valign="top">
#### 🧭 Trip planning
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
- **Route optimisation** — auto-sort places and export to Google Maps
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
- **Category filter** — show only matching pins on the map
</td>
<td width="50%" valign="top">
#### 🧳 Travel management
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
- **Packing lists** — categories, templates, user assignment, progress tracking
- **Bag tracking** — optional weight tracking with iOS-style distribution
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
- **PDF export** — full trip plan as PDF with cover page, images, notes
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 👥 Collaboration
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
- **Multi-user trips** — invite members with role-based access
- **Invite links** — one-time or reusable links with expiry
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
- **2FA** — TOTP + backup codes
- **Collab suite** — group chat, shared notes, polls, day check-ins
</td>
<td width="50%" valign="top">
#### 📱 Mobile & PWA
- **Installable** — iOS and Android, straight from the browser, no App Store needed
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
- **Native feel** — fullscreen standalone, themed status bar, splash screen
- **Touch optimised** — mobile-specific layouts with safe-area handling
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧩 Addons (admin-toggleable)
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Collab** — chat, notes, polls, day-by-day attendance
- **Journey** — magazine-style travel journal with entries, photos, maps, moods
- **Dashboard widgets** — currency converter and timezone clocks
</td>
<td width="50%" valign="top">
#### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
- **Granular scopes** — 24 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
</td>
</tr>
<tr>
<td colspan="2" valign="top">
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
</td>
</tr>
</table>
</details>
## Features
<br />
### Trip Planning
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
- **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
- **Route Optimization** — Auto-optimize place order and export to Google Maps
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
- **Map Category Filter** — Filter places by category and see only matching pins on the map
### Travel Management
- **Reservations & Bookings** — Track flights, accommodations, restaurants with status, confirmation numbers, and file attachments
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
- **Packing Lists** — Category-based checklists with user assignment, packing templates, and progress tracking
- **Packing Templates** — Create reusable packing templates in the admin panel with categories and items, apply to any trip
- **Bag Tracking** — Optional weight tracking and bag assignment for packing items with iOS-style weight distribution (admin-toggleable)
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
### Mobile & PWA
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox
- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen
- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling
### Collaboration
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
- **Invite Links** — Create one-time registration links with configurable max uses and expiry for easy onboarding
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
- **Two-Factor Authentication (MFA)** — TOTP-based 2FA with QR code setup, works with Google Authenticator, Authy, etc.
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
### Addons (modular, admin-toggleable)
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
- **Atlas** — Interactive world map with visited countries, bucket list with planned travel dates, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
### AI / MCP Integration
- **MCP Server** — Built-in [Model Context Protocol](MCP.md) server with OAuth 2.1 authentication exposes 80+ tools and 27 resources so AI assistants (Claude, Cursor, etc.) can read and modify your trips
- **Granular Scopes** — 24 OAuth scopes across 13 permission groups let you control exactly what data your AI client can access
- **Full Trip Automation** — AI can create trips, plan itineraries, build packing lists, manage budgets, send collab messages, mark countries visited, and more in a single conversation
- **Prompts** — Pre-built `trip-summary`, `packing-list`, and `budget-overview` prompts give AI clients instant structured context
- **Addon-Aware** — Atlas, Collab, and Vacay features are exposed automatically when those addons are enabled
### Customization & Admin
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Indonesian, Arabic (with RTL support)
- **Admin Panel** — User management, invite links, packing templates, global categories, addon management, API keys, backups, and GitHub release history
- **Auto-Backups** — Scheduled backups with configurable interval and retention
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
## Tech Stack
- **Backend**: Node.js 22 + Express + SQLite (`better-sqlite3`)
- **Frontend**: React 18 + Vite + Tailwind CSS
- **PWA**: vite-plugin-pwa + Workbox
- **Real-Time**: WebSocket (`ws`)
- **State**: Zustand
- **Auth**: JWT + OAuth 2.1 + OIDC + TOTP (MFA)
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
- **Weather**: Open-Meteo API (free, no key required)
- **Icons**: lucide-react
## Helm (Kubernetes)
A hosted Helm repository is available:
```sh
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](charts/README.md) for configuration options.
## Quick Start
## Get started in 30 seconds
```bash
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
@@ -123,19 +162,40 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
The app runs on port `3000`. The first user to register becomes the admin.
Open `http://localhost:3000`. The first user to register becomes admin.
### Install as App (PWA)
<div align="center">
TREK works as a Progressive Web App — no App Store needed:
&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>&nbsp;&nbsp;·&nbsp;&nbsp;
1. Open your TREK instance in the browser (HTTPS required)
2. **iOS**: Share button → "Add to Home Screen"
3. **Android**: Menu → "Install app" or "Add to Home Screen"
4. TREK launches fullscreen with its own icon, just like a native app
</div>
<br />
## Tech stack
<div align="center">
![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat-square&logo=node.js&logoColor=white)
![Express](https://img.shields.io/badge/Express-000000?style=flat-square&logo=express&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white)
![React](https://img.shields.io/badge/React_18-61DAFB?style=flat-square&logo=react&logoColor=black)
![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat-square&logo=vite&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white)
![Tailwind](https://img.shields.io/badge/Tailwind-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white)
![Leaflet](https://img.shields.io/badge/Leaflet-199900?style=flat-square&logo=leaflet&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat-square&logo=docker&logoColor=white)
</div>
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
<br />
<h2 id="docker-compose-production">Docker Compose (production)</h2>
<details>
<summary>Docker Compose (recommended for production)</summary>
<summary>Full compose example with secure defaults</summary>
```yaml
services:
@@ -158,30 +218,19 @@ services:
environment:
- NODE_ENV=production
- PORT=3000
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
# - OIDC_ONLY=false # Set to true to force SSO-only login (disables password login and registration). Equivalent to toggling those off in Admin > Settings, but takes priority over any DB setting and cannot be changed at runtime.
# - 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 # 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
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
- TZ=${TZ:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-info}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- APP_URL=${APP_URL:-} # required for OIDC + email links
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
# - TRUST_PROXY=1
# - OIDC_ISSUER=https://auth.example.com
# - OIDC_CLIENT_ID=trek
# - OIDC_CLIENT_SECRET=supersecret
# - OIDC_DISPLAY_NAME=SSO
# - OIDC_ADMIN_CLAIM=groups
# - OIDC_ADMIN_VALUE=app-trek-admins
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
@@ -194,29 +243,49 @@ services:
start_period: 15s
```
This example is aimed at reverse-proxy deployments where nginx, Caddy, Traefik, or a similar proxy terminates TLS in front of TREK. The three HTTPS-related variables work together:
- **`FORCE_HTTPS`** is 100% optional. When set to `true` it does four things: adds an HTTP-to-HTTPS 301 redirect, sends an HSTS header (`max-age=31536000`), adds the CSP `upgrade-insecure-requests` directive, and forces the session cookie `secure` flag on. It only makes sense behind a TLS-terminating proxy.
- **`TRUST_PROXY`** tells Express how many proxies sit in front of TREK so it can read the real client IP from `X-Forwarded-For` and the protocol from `X-Forwarded-Proto`. Without it, `FORCE_HTTPS` redirects will loop because Express never sees the request as secure. In production (`NODE_ENV=production`) this defaults to `1` automatically; in development it is off unless explicitly set.
- **`COOKIE_SECURE`** is normally auto-derived — the session cookie is marked `secure` whenever `NODE_ENV=production` or `FORCE_HTTPS=true`. Setting `COOKIE_SECURE=false` is an escape hatch that disables the `secure` flag even in production (e.g. testing over plain HTTP on a LAN). Do not disable it in real deployments.
If you access TREK directly on `http://<host>:3000` with no reverse proxy, leave `FORCE_HTTPS` unset (or remove it) and remove `TRUST_PROXY` to avoid redirect loops to a non-existent HTTPS endpoint.
Then:
```bash
docker compose up -d
```
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details>
### Updating
<br />
**Docker Compose** (recommended):
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
```bash
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
TREK works as a Progressive Web App — no App Store needed.
1. Open TREK in the browser (HTTPS required)
2. **iOS**: Share ▸ *Add to Home Screen*
3. **Android**: Menu ▸ *Install app* (or *Add to Home Screen*)
TREK then launches fullscreen with its own icon, just like a native app.
<br />
## Updating
**Docker Compose:**
```bash
docker compose pull && docker compose up -d
```
**Docker Run** — use the same volume paths from your original `docker run` command:
**Docker run**reuse the original volume paths:
```bash
docker pull mauriceboe/trek
@@ -224,27 +293,23 @@ docker rm -f trek
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
```
> **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
### Rotating the Encryption Key
<h3>Rotating the Encryption Key</h3>
If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app:
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
```bash
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
```
The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails.
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key".
<h2 id="reverse-proxy">Reverse Proxy</h2>
### Reverse Proxy (recommended)
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
> **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
<details>
<summary>Nginx</summary>
@@ -260,8 +325,19 @@ server {
listen 443 ssl http2;
server_name trek.yourdomain.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;
client_max_body_size 50m;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
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;
}
location /ws {
proxy_pass http://localhost:3000;
@@ -269,21 +345,6 @@ server {
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_read_timeout 86400;
# File uploads are capped at 50 MB; backup restore ZIPs can include the full
# uploads directory and may exceed that — raise this value if restores fail.
client_max_body_size 500m;
}
location / {
proxy_pass http://localhost:3000;
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;
}
}
```
@@ -293,17 +354,24 @@ server {
<details>
<summary>Caddy</summary>
Caddy handles WebSocket upgrades automatically:
```
```caddy
trek.yourdomain.com {
reverse_proxy localhost:3000
}
```
Caddy handles TLS and WebSockets automatically.
</details>
## Environment Variables
<br />
## Environment variables
<details>
<summary><b>Full reference</b></summary>
<br />
| Variable | Description | Default |
|----------|-------------|---------|
@@ -313,58 +381,46 @@ trek.yourdomain.com {
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language shown on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback when no match is found. Supported values: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Activates automatically in production (defaults to `1`); off in development unless explicitly set. Must be set for `FORCE_HTTPS` redirects to work correctly. | `1` (when active) |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| **OIDC / SSO** | | |
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login and password registration, regardless of the granular toggles in Admin > Settings. The first SSO login becomes admin. Use when you want this enforced at the infrastructure level and not overridable via the UI. | `false` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The 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. **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` |
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
| **Initial setup** | | |
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
## Optional API Keys
</details>
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
### Google Maps (Place Search & Photos)
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a project and enable the **Places API (New)**
3. Create an API key under Credentials
4. In TREK: Admin Panel → Settings → Google Maps
## Building from Source
```bash
git clone https://github.com/mauriceboe/TREK.git
cd TREK
docker build -t trek .
```
<br />
## Data & Backups
- **Database**: SQLite, stored in `./data/travel.db`
- **Uploads**: Stored in `./uploads/`
- **Logs**: `./data/logs/trek.log` (auto-rotated)
- **Backups**: Create and restore via Admin Panel
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
- **Database** SQLite, stored in `./data/travel.db`
- **Uploads** — stored in `./uploads/`
- **Logs** `./data/logs/trek.log` (auto-rotated)
- **Backups** — create and restore via Admin Panel
- **Auto-Backups** — configurable schedule and retention in Admin Panel
<br />
## License
[AGPL-3.0](LICENSE)
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
+8 -1
View File
@@ -4,6 +4,8 @@ import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { useAddonStore } from './store/addonStore'
import LoginPage from './pages/LoginPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage'
import FilesPage from './pages/FilesPage'
@@ -197,7 +199,10 @@ export default function App() {
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage])
const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register')
const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register')
|| location.pathname.startsWith('/forgot-password')
|| location.pathname.startsWith('/reset-password')
return (
<TranslationProvider>
@@ -210,6 +215,8 @@ export default function App() {
<Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
<Route path="/register" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route
+11 -2
View File
@@ -62,13 +62,20 @@ apiClient.interceptors.request.use(
(error) => Promise.reject(error)
)
export function isAuthPublicPath(pathname: string): boolean {
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
const publicPrefixes = ['/shared/', '/public/']
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
}
// Response interceptor - handle 401, 403 MFA, 429 rate limit
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) {
const currentPath = window.location.pathname + window.location.search
const { pathname } = window.location
if (!isAuthPublicPath(pathname)) {
const currentPath = pathname + window.location.search
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
}
}
@@ -114,6 +121,8 @@ export const authApi = {
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
mcpTokens: {
+23 -22
View File
@@ -900,29 +900,30 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<td style={{ ...td, display: 'flex', alignItems: 'center', gap: 4 }}>
{canEdit && (
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
<GripVertical size={12} />
<td style={td}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{canEdit && (
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
<GripVertical size={12} />
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{hasMultipleMembers && (
<div className="sm:hidden" style={{ marginTop: 4 }}>
<BudgetMemberChips
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
readOnly={!canEdit}
/>
</div>
)}
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{/* Mobile: larger chips under name since Persons column is hidden */}
{hasMultipleMembers && (
<div className="sm:hidden" style={{ marginTop: 4 }}>
<BudgetMemberChips
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
readOnly={!canEdit}
/>
</div>
)}
</div>
</td>
<td style={{ ...td, textAlign: 'center' }}>
+5 -1
View File
@@ -477,7 +477,11 @@ export const MapView = memo(function MapView({
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
if (!cached && !isLoading(cacheKey)) {
const photoId = place.image_url || place.google_place_id || place.osm_id
const photoId =
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|| place.google_place_id
|| place.osm_id
|| place.image_url
if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
}
+66 -2
View File
@@ -8,9 +8,10 @@ import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '..
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
import { ReservationMapboxOverlay } from './reservationsMapbox'
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place } from '../../types'
import type { Place, Reservation } from '../../types'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -44,6 +45,10 @@ interface Props {
rightWidth?: number
hasInspector?: boolean
hasDayDetail?: boolean
reservations?: Reservation[]
visibleConnectionIds?: number[]
showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -139,17 +144,28 @@ export function MapViewGL({
rightWidth = 0,
hasInspector = false,
hasDayDetail = false,
reservations = [],
visibleConnectionIds = [],
showReservationStats = false,
onReservationClick,
}: Props) {
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const [mapReady, setMapReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
// Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
@@ -228,6 +244,10 @@ export function MapViewGL({
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
// Signal that sources/layers are attached so overlay effects can
// safely add their own sources. Style rebuilds reset this via the
// cleanup below.
setMapReady(true)
})
map.on('click', (e) => {
@@ -299,12 +319,17 @@ export function MapViewGL({
canvas.removeEventListener('auxclick', onAuxClick)
markersRef.current.forEach(m => m.remove())
markersRef.current.clear()
if (reservationOverlayRef.current) {
reservationOverlayRef.current.destroy()
reservationOverlayRef.current = null
}
if (locationMarkerRef.current) {
locationMarkerRef.current.destroy()
locationMarkerRef.current = null
}
try { map.remove() } catch { /* noop */ }
mapRef.current = null
setMapReady(false)
}
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
@@ -341,7 +366,11 @@ export function MapViewGL({
}
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
if (!cached && !isLoading(cacheKey)) {
const photoId = place.image_url || place.google_place_id || place.osm_id
const photoId =
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|| place.google_place_id
|| place.osm_id
|| place.image_url
if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
}
@@ -434,6 +463,41 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features })
}, [places])
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
// circle arcs for flights/cruises, straight lines for trains/cars,
// clickable endpoint badges, rotating mid-arc stats label for flights.
// The overlay is a small imperative manager that owns its own source,
// layer, and HTML markers; it lives next to the map for the map's
// lifetime and is rebuilt when the style/token/3d effect rebuilds.
//
// `visibleConnectionIds` is driven by the per-reservation toggle in
// DayPlanSidebar — nothing is rendered until the user enables a
// booking's route, matching the Leaflet MapView's behaviour.
const visibleReservations = useMemo(() => {
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
const set = new Set(visibleConnectionIds)
return reservations.filter(r => set.has(r.id))
}, [reservations, visibleConnectionIds])
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
if (!reservationOverlayRef.current) {
reservationOverlayRef.current = new ReservationMapboxOverlay(map, {
showConnections: true,
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
}
reservationOverlayRef.current.update(visibleReservations, {
showConnections: true,
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
// Fit bounds on fitKey change — matches the Leaflet BoundsController
const paddingOpts = useMemo(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
@@ -0,0 +1,388 @@
// Mapbox GL counterpart to ReservationOverlay.tsx.
//
// react-leaflet is component-driven, mapbox-gl is imperative — so instead of
// a React component, this exports a small manager class the MapViewGL wires
// up next to its other sources/layers. The geometry logic (great-circle arcs,
// antimeridian split, duration math) mirrors the Leaflet overlay so both
// renderers produce the same visual result on the globe or a flat projection.
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car } from 'lucide-react'
import type { Reservation, ReservationEndpoint } from '../../types'
export const RESERVATION_SOURCE_ID = 'trek-reservations'
export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
type TransportType = 'flight' | 'train' | 'cruise' | 'car'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car']
const TRANSPORT_COLOR = '#3b82f6'
const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
flight: { icon: Plane, geodesic: true },
train: { icon: Train, geodesic: false },
cruise: { icon: Ship, geodesic: true },
car: { icon: Car, geodesic: false },
}
// ── geometry helpers (ported from ReservationOverlay.tsx) ────────────────
const toRad = (d: number) => d * Math.PI / 180
const toDeg = (r: number) => r * 180 / Math.PI
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
if (d === 0) return [a, b]
const pts: [number, number][] = []
for (let i = 0; i <= steps; i++) {
const f = i / steps
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
const lng = Math.atan2(y, x)
pts.push([toDeg(lat), toDeg(lng)])
}
return pts
}
function splitAntimeridian(points: [number, number][]): [number, number][][] {
const segments: [number, number][][] = []
let cur: [number, number][] = []
for (let i = 0; i < points.length; i++) {
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
if (cur.length > 1) segments.push(cur)
cur = []
}
cur.push(points[i])
}
if (cur.length > 1) segments.push(cur)
return segments
}
function haversineKm(a: [number, number], b: [number, number]): number {
const R = 6371
const dLat = toRad(b[0] - a[0])
const dLng = toRad(b[1] - a[1])
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}
function parseInTz(isoLocal: string, tz: string): number {
const [datePart, timePart] = isoLocal.split('T')
const [y, mo, d] = datePart.split('-').map(Number)
const [h, mi] = (timePart || '00:00').split(':').map(Number)
const guess = Date.UTC(y, mo - 1, d, h, mi)
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
const parts = Object.fromEntries(fmt.formatToParts(new Date(guess)).filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second))
return guess - (asUtc - guess)
}
function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fallbackStart: string | null, fallbackEnd: string | null): string | null {
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd
if (!start || !end) return null
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`
if (!start.includes('T') || !end.includes('T')) return null
const fromTz = from.timezone || to.timezone
const toTz = to.timezone || fromTz
let startMs: number, endMs: number
if (fromTz && toTz) {
startMs = parseInTz(start, fromTz)
endMs = parseInTz(end, toTz)
} else {
startMs = new Date(start).getTime()
endMs = new Date(end).getTime()
}
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null
if (endMs <= startMs) endMs += 24 * 60 * 60000
const minutes = Math.round((endMs - startMs) / 60000)
if (minutes <= 0 || minutes > 48 * 60) return null
const h = Math.floor(minutes / 60)
const m = minutes % 60
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
const cleanName = (name: string) => name.replace(/\s*\([^)]*\)/g, '').trim()
// ── item building ─────────────────────────────────────────────────────────
interface TransportItem {
res: Reservation
from: ReservationEndpoint
to: ReservationEndpoint
type: TransportType
arcs: [number, number][][]
primaryArc: [number, number][]
mainLabel: string | null
subLabel: string | null
}
function buildItems(reservations: Reservation[]): TransportItem[] {
const out: TransportItem[] = []
for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
const eps = r.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (!from || !to) continue
const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic
const arcs = isGeo
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, type, arcs, primaryArc, mainLabel, subLabel })
}
return out
}
// ── DOM helpers for HTML markers ──────────────────────────────────────────
function endpointMarkerHtml(type: TransportType, label: string | null): string {
const { icon: IconCmp } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''
return `<div style="
display:inline-flex;align-items:center;justify-content:center;gap:4px;
padding:0 8px;border-radius:999px;
background:${TRANSPORT_COLOR};box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1.5px solid #fff;color:#fff;
font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
box-sizing:border-box;height:22px;white-space:nowrap;cursor:pointer;
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`
}
function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
const estWidth = Math.max(
mainLabel ? mainLabel.length * 6.5 : 0,
subLabel ? subLabel.length * 5.5 : 0,
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
padding:0 11px;border-radius:999px;
background:rgba(17,24,39,0.92);color:#fff;
box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1px solid ${TRANSPORT_COLOR}aa;
font-family:-apple-system,system-ui,'SF Pro Text',sans-serif;
white-space:nowrap;box-sizing:border-box;pointer-events:none;
transform-origin:center;will-change:transform;
">${main}${sub}</div>`
return { html, width: estWidth, height }
}
// ── overlay manager ──────────────────────────────────────────────────────
export interface ReservationOverlayOptions {
showConnections: boolean
showStats: boolean
showEndpointLabels: boolean
onEndpointClick?: (reservationId: number) => void
}
export class ReservationMapboxOverlay {
private map: mapboxgl.Map
private items: TransportItem[] = []
private opts: ReservationOverlayOptions
private endpointMarkers: mapboxgl.Marker[] = []
private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
private rerender: () => void
private destroyed = false
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
this.map = map
this.opts = opts
this.rerender = () => { if (!this.destroyed) this.render() }
this.setupLayer()
map.on('zoomend', this.rerender)
map.on('moveend', this.rerender)
map.on('render', this.updateStatsRotation)
}
update(reservations: Reservation[], opts: ReservationOverlayOptions) {
this.opts = opts
this.items = buildItems(reservations)
this.render()
}
destroy() {
this.destroyed = true
this.map.off('zoomend', this.rerender)
this.map.off('moveend', this.rerender)
this.map.off('render', this.updateStatsRotation)
this.endpointMarkers.forEach(m => m.remove())
this.endpointMarkers = []
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
try {
if (this.map.getLayer(RESERVATION_LINE_LAYER_ID)) this.map.removeLayer(RESERVATION_LINE_LAYER_ID)
if (this.map.getSource(RESERVATION_SOURCE_ID)) this.map.removeSource(RESERVATION_SOURCE_ID)
} catch { /* map already gone */ }
}
private setupLayer() {
const map = this.map
if (map.getSource(RESERVATION_SOURCE_ID)) return
map.addSource(RESERVATION_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({
id: RESERVATION_LINE_LAYER_ID,
type: 'line',
source: RESERVATION_SOURCE_ID,
paint: {
'line-color': TRANSPORT_COLOR,
'line-width': 2.5,
// Confirmed = solid + 0.75; pending = dashed + 0.55.
'line-opacity': ['case', ['==', ['get', 'status'], 'confirmed'], 0.75, 0.55] as any,
'line-dasharray': ['case', ['==', ['get', 'status'], 'confirmed'], ['literal', [1, 0]], ['literal', [3, 3]]] as any,
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
private render() {
const map = this.map
if (!this.map.getSource(RESERVATION_SOURCE_ID)) return
const show = this.opts.showConnections
// Visible filter: require the on-screen pixel distance between
// endpoints to exceed a type-specific minimum, same as the Leaflet
// overlay, so tiny no-op transport lines don't clutter the map.
const visibleItems = show ? this.items.filter(item => {
try {
const fromPx = map.project([item.from.lng, item.from.lat])
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return dist >= minPx
} catch { return true }
}) : []
// Label visibility threshold is higher than line visibility, to keep
// endpoint text from overlapping on very short lines.
const labelVisibleIds = new Set<number>()
if (show) {
for (const item of visibleItems) {
try {
const fromPx = map.project([item.from.lng, item.from.lat])
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
if (dist >= minPx) labelVisibleIds.add(item.res.id)
} catch { /* ignore */ }
}
}
// ── line features ───────────────────────────────────────────────
const features = visibleItems.flatMap(item => item.arcs.map(seg => ({
type: 'Feature' as const,
properties: {
resId: item.res.id,
type: item.type,
status: item.res.status ?? 'pending',
},
geometry: {
type: 'LineString' as const,
coordinates: seg.map(([lat, lng]) => [lng, lat]),
},
})))
const src = map.getSource(RESERVATION_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined
src?.setData({ type: 'FeatureCollection', features })
// ── endpoint markers ────────────────────────────────────────────
this.endpointMarkers.forEach(m => m.remove())
this.endpointMarkers = []
if (show) {
for (const item of visibleItems) {
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
for (const ep of [item.from, item.to]) {
const label = showLabel ? (ep.code || cleanName(ep.name)) : null
const el = document.createElement('div')
el.innerHTML = endpointMarkerHtml(item.type, label)
const inner = el.firstElementChild as HTMLElement | null
const node = inner ?? el
node.title = ep.name || ''
if (this.opts.onEndpointClick) {
node.addEventListener('click', (ev) => {
ev.stopPropagation()
this.opts.onEndpointClick?.(item.res.id)
})
}
const marker = new mapboxgl.Marker({ element: node, anchor: 'center' })
.setLngLat([ep.lng, ep.lat])
.addTo(map)
this.endpointMarkers.push(marker)
}
}
}
// ── stats label (flights only) ──────────────────────────────────
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
if (show && this.opts.showStats) {
for (const item of visibleItems) {
if (item.type !== 'flight') continue
if (!labelVisibleIds.has(item.res.id)) continue
if (!item.mainLabel && !item.subLabel) continue
const arc = item.primaryArc
if (arc.length < 2) continue
const mid = arc[Math.floor(arc.length / 2)]!
const { html, width, height } = buildStatsHtml(item.mainLabel, item.subLabel)
const el = document.createElement('div')
el.style.cssText = `width:${width}px;height:${height}px;pointer-events:none;`
el.innerHTML = html
const marker = new mapboxgl.Marker({ element: el, anchor: 'center' })
.setLngLat([mid[1], mid[0]])
.addTo(map)
this.statsMarkers.push({ marker, arc })
}
}
// Prime rotation once so labels don't flash horizontal on first paint.
this.updateStatsRotation()
}
// Match the Leaflet overlay's "rotate the label along the arc" look.
// We pick a short segment straddling the arc midpoint, measure the
// screen angle between those two projected points, and clamp it to
// [-90°, 90°] so text never renders upside-down.
private updateStatsRotation = () => {
if (this.destroyed) return
for (const entry of this.statsMarkers) {
const { marker, arc } = entry
if (arc.length < 2) continue
const midIdx = Math.floor(arc.length / 2)
const a = arc[Math.max(0, midIdx - 2)]!
const b = arc[Math.min(arc.length - 1, midIdx + 2)]!
try {
const pa = this.map.project([a[1], a[0]])
const pb = this.map.project([b[1], b[0]])
let angle = Math.atan2(pb.y - pa.y, pb.x - pa.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
const el = marker.getElement()
const inner = el.querySelector('.trek-stats-inner') as HTMLElement | null
if (inner) inner.style.transform = `rotate(${angle}deg)`
} catch { /* map not ready / projection failure */ }
}
}
}
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'
import { Package } from 'lucide-react'
import { adminApi, packingApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
@@ -43,9 +44,9 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
setApplying(true)
try {
const data = await packingApi.applyTemplate(tripId, templateId)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
toast.success(t('packing.templateApplied', { count: data.count }))
setOpen(false)
window.location.reload()
} catch {
toast.error(t('packing.templateError'))
} finally {
@@ -959,10 +959,9 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
setApplyingTemplate(true)
try {
const data = await packingApi.applyTemplate(tripId, templateId)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
toast.success(t('packing.templateApplied', { count: data.count }))
setShowTemplateDropdown(false)
// Reload packing items
window.location.reload()
} catch {
toast.error(t('packing.templateError'))
} finally {
@@ -1020,10 +1019,10 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return }
try {
const result = await packingApi.bulkImport(tripId, parsed)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(result.items || [])] }))
toast.success(t('packing.importSuccess', { count: result.count }))
setImportText('')
setShowImportModal(false)
window.location.reload()
} catch { toast.error(t('packing.importError')) }
}
@@ -336,6 +336,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return () => document.removeEventListener('dragend', cleanup)
}, [])
// Initialize missing transport positions outside of render to avoid setState-during-render
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { days.forEach(day => initTransportPositions(day.id)) }, [days, reservations])
const toggleDay = (dayId, e) => {
e.stopPropagation()
setExpandedDays(prev => {
@@ -490,11 +494,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
const transport = getTransportForDay(dayId)
// Initialize positions for transports that don't have one yet
if (transport.some(r => r.day_plan_position == null)) {
initTransportPositions(dayId)
}
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
const baseItems = [
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
@@ -1117,7 +1116,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
{/* Tagesliste */}
<div className="scroll-container trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
@@ -1135,7 +1134,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
onDrop={e => handleDropOnDay(e, day.id)}
style={{
@@ -1236,9 +1235,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)'
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)'
return (
<span key={acc.id} onClick={e => { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: (acc as any).place_id ? 'pointer' : 'default' }}>
<Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</span>
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
</span>
)
})
@@ -1349,7 +1348,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
>
{merged.length === 0 && !dayNoteUi ? (
<div
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
onDrop={e => handleDropOnDay(e, day.id)}
style={{ padding: '16px', textAlign: 'center', borderRadius: 8,
background: dragOverDayId === day.id ? 'rgba(17,24,39,0.05)' : 'transparent',
@@ -1409,7 +1408,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return (
<React.Fragment key={`place-${assignment.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
draggable={canEditDays}
onDragStart={e => {
@@ -1499,6 +1497,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
borderLeft: lockedIds.has(assignment.id)
? '3px solid #dc2626'
: '3px solid transparent',
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
transition: 'background 0.15s, border-color 0.15s',
opacity: isDraggingThis ? 0.4 : 1,
}}
@@ -1722,7 +1721,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return (
<React.Fragment key={`transport-${res.id}-${day.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
onClick={() => canEditDays && onEditTransport?.(res)}
onDragOver={e => {
@@ -1771,6 +1769,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
margin: '1px 8px',
borderRadius: 6,
border: `1px solid ${color}33`,
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
borderBottom: showDropLineAfter ? '2px solid var(--text-primary)' : undefined,
background: `${color}08`,
cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none',
transition: 'background 0.1s',
@@ -1844,7 +1844,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
)
})()}
</div>
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
</React.Fragment>
)
}
@@ -1855,7 +1854,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const noteIdx = idx
return (
<React.Fragment key={`note-${note.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
draggable={canEditDays}
onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
@@ -1911,6 +1909,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
margin: '1px 8px',
borderRadius: 6,
border: '1px solid var(--border-faint)',
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
background: 'var(--bg-hover)',
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
@@ -143,6 +143,18 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
}
}, [reservation, isOpen, selectedDayId, defaultAssignmentId])
// Re-hydrate hotel day range when the accommodations prop arrives after the modal opens
// (race: tripAccommodations fetch may complete after isOpen fires, leaving hotel fields empty)
useEffect(() => {
if (!isOpen || !reservation || reservation.type !== 'hotel' || !reservation.accommodation_id) return
const acc = accommodations.find(a => a.id == reservation.accommodation_id)
if (!acc) return
setForm(prev => {
if (prev.hotel_place_id !== '' || prev.hotel_start_day !== '' || prev.hotel_end_day !== '') return prev
return { ...prev, hotel_place_id: acc.place_id, hotel_start_day: acc.start_day_id, hotel_end_day: acc.end_day_id }
})
}, [accommodations, isOpen, reservation])
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const isEndBeforeStart = (() => {
@@ -193,9 +205,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = {
place_id: form.hotel_place_id,
place_id: form.hotel_place_id || null,
start_day_id: form.hotel_start_day,
end_day_id: form.hotel_end_day,
check_in: form.meta_check_in_time || null,
@@ -25,6 +25,7 @@ const EVENT_LABEL_KEYS: Record<string, string> = {
trip_invite: 'settings.notifyTripInvite',
booking_change: 'settings.notifyBookingChange',
trip_reminder: 'settings.notifyTripReminder',
todo_due: 'settings.notifyTodoDue',
vacay_invite: 'settings.notifyVacayInvite',
photos_shared: 'settings.notifyPhotosShared',
collab_message: 'settings.notifyCollabMessage',
+25
View File
@@ -204,6 +204,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'دعوات الرحلات',
'settings.notifyBookingChange': 'تغييرات الحجز',
'settings.notifyTripReminder': 'تذكيرات الرحلات',
'settings.notifyTodoDue': 'مهمة مستحقة',
'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
@@ -463,6 +464,28 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
'login.usernameRequired': 'اسم المستخدم مطلوب',
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
'login.forgotPassword': 'نسيت كلمة المرور؟',
'login.forgotPasswordTitle': 'إعادة تعيين كلمة المرور',
'login.forgotPasswordBody': 'أدخل عنوان البريد الإلكتروني المسجَّل. إذا كان الحساب موجودًا، سنرسل رابط إعادة التعيين.',
'login.forgotPasswordSubmit': 'إرسال الرابط',
'login.forgotPasswordSentTitle': 'تحقق من بريدك',
'login.forgotPasswordSentBody': 'إذا كان هناك حساب مرتبط بهذا البريد، فإن الرابط في الطريق. تنتهي صلاحيته خلال 60 دقيقة.',
'login.forgotPasswordSmtpHintOff': 'ملاحظة: لم يقم المسؤول بتكوين SMTP، لذا سيتم كتابة رابط إعادة التعيين في وحدة تحكم الخادم بدلاً من إرساله عبر البريد الإلكتروني.',
'login.backToLogin': 'العودة إلى تسجيل الدخول',
'login.newPassword': 'كلمة المرور الجديدة',
'login.confirmPassword': 'تأكيد كلمة المرور الجديدة',
'login.passwordsDontMatch': 'كلمتا المرور غير متطابقتين',
'login.mfaCode': 'رمز 2FA',
'login.resetPasswordTitle': 'ضبط كلمة مرور جديدة',
'login.resetPasswordBody': 'اختر كلمة مرور قوية لم تستخدمها هنا من قبل. 8 أحرف على الأقل.',
'login.resetPasswordMfaBody': 'أدخل رمز 2FA أو رمز النسخ الاحتياطي لإتمام إعادة التعيين.',
'login.resetPasswordSubmit': 'إعادة تعيين كلمة المرور',
'login.resetPasswordVerify': 'تحقق وأعد التعيين',
'login.resetPasswordSuccessTitle': 'تم تحديث كلمة المرور',
'login.resetPasswordSuccessBody': 'يمكنك الآن تسجيل الدخول بكلمة المرور الجديدة.',
'login.resetPasswordInvalidLink': 'رابط إعادة تعيين غير صالح',
'login.resetPasswordInvalidLinkBody': 'هذا الرابط مفقود أو تالف. اطلب رابطًا جديدًا للمتابعة.',
'login.resetPasswordFailed': 'فشلت إعادة التعيين. ربما انتهت صلاحية الرابط.',
// Register
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
@@ -1973,6 +1996,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}',
'notif.trip_reminder.title': 'تذكير بالرحلة',
'notif.trip_reminder.text': 'رحلتك {trip} تقترب!',
'notif.todo_due.title': 'مهمة مستحقة',
'notif.todo_due.text': '{todo} في {trip} مستحقة في {due}',
'notif.vacay_invite.title': 'دعوة دمج الإجازة',
'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة',
'notif.photos_shared.title': 'تمت مشاركة الصور',
+25
View File
@@ -199,6 +199,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Convites de viagem',
'settings.notifyBookingChange': 'Alterações de reserva',
'settings.notifyTripReminder': 'Lembretes de viagem',
'settings.notifyTodoDue': 'Tarefa com vencimento',
'settings.notifyVacayInvite': 'Convites de fusão Vacay',
'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)',
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
@@ -458,6 +459,28 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'Falha no login OIDC',
'login.usernameRequired': 'Nome de usuário é obrigatório',
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
'login.forgotPassword': 'Esqueceu a senha?',
'login.forgotPasswordTitle': 'Redefinir sua senha',
'login.forgotPasswordBody': 'Digite o e-mail cadastrado. Se houver uma conta, enviaremos um link de redefinição.',
'login.forgotPasswordSubmit': 'Enviar link',
'login.forgotPasswordSentTitle': 'Verifique seu e-mail',
'login.forgotPasswordSentBody': 'Se houver uma conta para esse e-mail, o link está a caminho. Ele expira em 60 minutos.',
'login.forgotPasswordSmtpHintOff': 'Observação: seu administrador não configurou SMTP, então o link de redefinição será gravado no console do servidor em vez de ser enviado por e-mail.',
'login.backToLogin': 'Voltar ao login',
'login.newPassword': 'Nova senha',
'login.confirmPassword': 'Confirmar nova senha',
'login.passwordsDontMatch': 'As senhas não coincidem',
'login.mfaCode': 'Código 2FA',
'login.resetPasswordTitle': 'Definir uma nova senha',
'login.resetPasswordBody': 'Escolha uma senha forte que você ainda não tenha usado aqui. Mínimo de 8 caracteres.',
'login.resetPasswordMfaBody': 'Digite seu código 2FA ou um código de backup para concluir a redefinição.',
'login.resetPasswordSubmit': 'Redefinir senha',
'login.resetPasswordVerify': 'Verificar e redefinir',
'login.resetPasswordSuccessTitle': 'Senha atualizada',
'login.resetPasswordSuccessBody': 'Agora você pode entrar com sua nova senha.',
'login.resetPasswordInvalidLink': 'Link de redefinição inválido',
'login.resetPasswordInvalidLinkBody': 'Este link está ausente ou corrompido. Solicite um novo para continuar.',
'login.resetPasswordFailed': 'Falha na redefinição. O link pode ter expirado.',
// Register
'register.passwordMismatch': 'As senhas não coincidem',
@@ -1913,6 +1936,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}',
'notif.trip_reminder.title': 'Lembrete de viagem',
'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!',
'notif.todo_due.title': 'Tarefa com vencimento',
'notif.todo_due.text': '{todo} em {trip} vence em {due}',
'notif.vacay_invite.title': 'Convite Vacay Fusion',
'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias',
'notif.photos_shared.title': 'Fotos compartilhadas',
+25
View File
@@ -200,6 +200,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Pozvánky na cesty',
'settings.notifyBookingChange': 'Změny rezervací',
'settings.notifyTripReminder': 'Připomínky cest',
'settings.notifyTodoDue': 'Úkol se blíží',
'settings.notifyVacayInvite': 'Pozvánky k propojení Vacay',
'settings.notifyPhotosShared': 'Sdílené fotky (Immich)',
'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)',
@@ -458,6 +459,28 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo',
'login.usernameRequired': 'Uživatelské jméno je povinné',
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
'login.forgotPassword': 'Zapomenuté heslo?',
'login.forgotPasswordTitle': 'Obnovení hesla',
'login.forgotPasswordBody': 'Zadej e-mail použitý při registraci. Pokud účet existuje, pošleme odkaz pro obnovení.',
'login.forgotPasswordSubmit': 'Odeslat odkaz',
'login.forgotPasswordSentTitle': 'Zkontroluj e-mail',
'login.forgotPasswordSentBody': 'Pokud k tomuto e-mailu existuje účet, odkaz je na cestě. Platnost vyprší za 60 minut.',
'login.forgotPasswordSmtpHintOff': 'Upozornění: správce nemá nakonfigurovaný SMTP, takže se odkaz pro obnovení zapíše do konzole serveru místo odeslání e-mailem.',
'login.backToLogin': 'Zpět na přihlášení',
'login.newPassword': 'Nové heslo',
'login.confirmPassword': 'Potvrď nové heslo',
'login.passwordsDontMatch': 'Hesla se neshodují',
'login.mfaCode': 'Kód 2FA',
'login.resetPasswordTitle': 'Nastavit nové heslo',
'login.resetPasswordBody': 'Vyber silné heslo, které jsi tu ještě nepoužil. Minimálně 8 znaků.',
'login.resetPasswordMfaBody': 'Zadej 2FA kód nebo záložní kód pro dokončení obnovení.',
'login.resetPasswordSubmit': 'Obnovit heslo',
'login.resetPasswordVerify': 'Ověřit a obnovit',
'login.resetPasswordSuccessTitle': 'Heslo aktualizováno',
'login.resetPasswordSuccessBody': 'Nyní se můžeš přihlásit novým heslem.',
'login.resetPasswordInvalidLink': 'Neplatný odkaz',
'login.resetPasswordInvalidLinkBody': 'Odkaz chybí nebo je poškozený. Pro pokračování si vyžádej nový.',
'login.resetPasswordFailed': 'Obnovení se nezdařilo. Odkaz mohl vypršet.',
// Registrace (Register)
'register.passwordMismatch': 'Hesla se neshodují',
@@ -1918,6 +1941,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} aktualizoval rezervaci v {trip}',
'notif.trip_reminder.title': 'Připomínka výletu',
'notif.trip_reminder.text': 'Váš výlet {trip} se blíží!',
'notif.todo_due.title': 'Úkol se blíží',
'notif.todo_due.text': '{todo} ve výletě {trip} má termín {due}',
'notif.vacay_invite.title': 'Pozvánka Vacay Fusion',
'notif.vacay_invite.text': '{actor} vás pozval ke spojení dovolenkových plánů',
'notif.photos_shared.title': 'Fotky sdíleny',
+25
View File
@@ -204,6 +204,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Trip-Einladungen',
'settings.notifyBookingChange': 'Buchungsänderungen',
'settings.notifyTripReminder': 'Trip-Erinnerungen',
'settings.notifyTodoDue': 'Aufgabe bald fällig',
'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)',
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
@@ -463,6 +464,28 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen',
'login.usernameRequired': 'Benutzername ist erforderlich',
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
'login.forgotPassword': 'Passwort vergessen?',
'login.forgotPasswordTitle': 'Passwort zurücksetzen',
'login.forgotPasswordBody': 'Gib die E-Mail-Adresse deines Kontos ein. Falls ein Konto existiert, schicken wir dir einen Reset-Link.',
'login.forgotPasswordSubmit': 'Reset-Link senden',
'login.forgotPasswordSentTitle': 'Prüfe deine E-Mails',
'login.forgotPasswordSentBody': 'Falls ein Konto mit dieser Adresse existiert, ist ein Reset-Link unterwegs. Er läuft in 60 Minuten ab.',
'login.forgotPasswordSmtpHintOff': 'Hinweis: Der Administrator hat SMTP nicht konfiguriert. Der Reset-Link wird statt per E-Mail in die Server-Konsole geschrieben.',
'login.backToLogin': 'Zurück zur Anmeldung',
'login.newPassword': 'Neues Passwort',
'login.confirmPassword': 'Neues Passwort bestätigen',
'login.passwordsDontMatch': 'Passwörter stimmen nicht überein',
'login.mfaCode': '2FA-Code',
'login.resetPasswordTitle': 'Neues Passwort festlegen',
'login.resetPasswordBody': 'Wähle ein starkes Passwort, das du hier noch nicht verwendet hast. Mindestens 8 Zeichen.',
'login.resetPasswordMfaBody': 'Gib deinen 2FA-Code oder einen Backup-Code ein, um den Reset abzuschließen.',
'login.resetPasswordSubmit': 'Passwort zurücksetzen',
'login.resetPasswordVerify': 'Prüfen & zurücksetzen',
'login.resetPasswordSuccessTitle': 'Passwort aktualisiert',
'login.resetPasswordSuccessBody': 'Du kannst dich jetzt mit deinem neuen Passwort anmelden.',
'login.resetPasswordInvalidLink': 'Ungültiger Reset-Link',
'login.resetPasswordInvalidLinkBody': 'Dieser Link fehlt oder ist beschädigt. Fordere einen neuen an, um fortzufahren.',
'login.resetPasswordFailed': 'Zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.',
// Register
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
@@ -1923,6 +1946,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert',
'notif.trip_reminder.title': 'Reiseerinnerung',
'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!',
'notif.todo_due.title': 'Aufgabe fällig',
'notif.todo_due.text': '{todo} in {trip} ist am {due} fällig',
'notif.vacay_invite.title': 'Vacay Fusion-Einladung',
'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen',
'notif.photos_shared.title': 'Fotos geteilt',
+25
View File
@@ -204,6 +204,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Trip invitations',
'settings.notifyBookingChange': 'Booking changes',
'settings.notifyTripReminder': 'Trip reminders',
'settings.notifyTodoDue': 'Todo due soon',
'settings.notifyVacayInvite': 'Vacay fusion invitations',
'settings.notifyPhotosShared': 'Shared photos (Immich)',
'settings.notifyCollabMessage': 'Chat messages (Collab)',
@@ -522,6 +523,28 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'OIDC login failed',
'login.usernameRequired': 'Username is required',
'login.passwordMinLength': 'Password must be at least 8 characters',
'login.forgotPassword': 'Forgot password?',
'login.forgotPasswordTitle': 'Reset your password',
'login.forgotPasswordBody': 'Enter the email address you signed up with. If an account exists, we\'ll send a reset link.',
'login.forgotPasswordSubmit': 'Send reset link',
'login.forgotPasswordSentTitle': 'Check your email',
'login.forgotPasswordSentBody': 'If an account exists for that email, a reset link is on its way. It expires in 60 minutes.',
'login.forgotPasswordSmtpHintOff': 'Heads up: your administrator hasn\'t configured SMTP, so the reset link will be written to the server console instead of being emailed.',
'login.backToLogin': 'Back to sign in',
'login.newPassword': 'New password',
'login.confirmPassword': 'Confirm new password',
'login.passwordsDontMatch': 'Passwords don\'t match',
'login.mfaCode': '2FA code',
'login.resetPasswordTitle': 'Set a new password',
'login.resetPasswordBody': 'Pick a strong password you havent used here before. Minimum 8 characters.',
'login.resetPasswordMfaBody': 'Enter your 2FA code or a backup code to complete the reset.',
'login.resetPasswordSubmit': 'Reset password',
'login.resetPasswordVerify': 'Verify & reset',
'login.resetPasswordSuccessTitle': 'Password updated',
'login.resetPasswordSuccessBody': 'You can now sign in with your new password.',
'login.resetPasswordInvalidLink': 'Invalid reset link',
'login.resetPasswordInvalidLinkBody': 'This link is missing or broken. Request a new one to continue.',
'login.resetPasswordFailed': 'Reset failed. The link may have expired.',
// Register
'register.passwordMismatch': 'Passwords do not match',
@@ -1926,6 +1949,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} updated a booking in {trip}',
'notif.trip_reminder.title': 'Trip Reminder',
'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!',
'notif.todo_due.title': 'To-do due',
'notif.todo_due.text': '{todo} in {trip} is due on {due}',
'notif.vacay_invite.title': 'Vacay Fusion Invite',
'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans',
'notif.photos_shared.title': 'Photos Shared',
+25
View File
@@ -200,6 +200,7 @@ const es: Record<string, string> = {
'settings.notifyTripInvite': 'Invitaciones de viaje',
'settings.notifyBookingChange': 'Cambios en reservas',
'settings.notifyTripReminder': 'Recordatorios de viaje',
'settings.notifyTodoDue': 'Tarea próxima',
'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay',
'settings.notifyPhotosShared': 'Fotos compartidas (Immich)',
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
@@ -450,6 +451,28 @@ const es: Record<string, string> = {
'login.oidcFailed': 'Error de inicio de sesión OIDC',
'login.usernameRequired': 'El nombre de usuario es obligatorio',
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
'login.forgotPassword': '¿Olvidaste tu contraseña?',
'login.forgotPasswordTitle': 'Restablecer tu contraseña',
'login.forgotPasswordBody': 'Introduce la dirección de correo con la que te registraste. Si existe una cuenta, enviaremos un enlace.',
'login.forgotPasswordSubmit': 'Enviar enlace',
'login.forgotPasswordSentTitle': 'Revisa tu correo',
'login.forgotPasswordSentBody': 'Si existe una cuenta con ese correo, el enlace de restablecimiento está en camino. Caduca en 60 minutos.',
'login.forgotPasswordSmtpHintOff': 'Nota: tu administrador no ha configurado SMTP, así que el enlace de restablecimiento se escribirá en la consola del servidor en lugar de enviarse por correo.',
'login.backToLogin': 'Volver al inicio de sesión',
'login.newPassword': 'Nueva contraseña',
'login.confirmPassword': 'Confirmar nueva contraseña',
'login.passwordsDontMatch': 'Las contraseñas no coinciden',
'login.mfaCode': 'Código 2FA',
'login.resetPasswordTitle': 'Establecer una nueva contraseña',
'login.resetPasswordBody': 'Elige una contraseña segura que no hayas usado aquí antes. Mínimo 8 caracteres.',
'login.resetPasswordMfaBody': 'Introduce tu código 2FA o un código de respaldo para completar el restablecimiento.',
'login.resetPasswordSubmit': 'Restablecer contraseña',
'login.resetPasswordVerify': 'Verificar y restablecer',
'login.resetPasswordSuccessTitle': 'Contraseña actualizada',
'login.resetPasswordSuccessBody': 'Ya puedes iniciar sesión con tu nueva contraseña.',
'login.resetPasswordInvalidLink': 'Enlace de restablecimiento no válido',
'login.resetPasswordInvalidLinkBody': 'Este enlace falta o está roto. Solicita uno nuevo para continuar.',
'login.resetPasswordFailed': 'Restablecimiento fallido. El enlace puede haber caducado.',
'login.oidc.tokenFailed': 'La autenticación falló.',
'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.',
'login.demoFailed': 'Falló el acceso a la demo',
@@ -1923,6 +1946,8 @@ const es: Record<string, string> = {
'notif.booking_change.text': '{actor} actualizó una reserva en {trip}',
'notif.trip_reminder.title': 'Recordatorio de viaje',
'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!',
'notif.todo_due.title': 'Tarea pendiente',
'notif.todo_due.text': '{todo} en {trip} vence el {due}',
'notif.vacay_invite.title': 'Invitación Vacay Fusion',
'notif.vacay_invite.text': '{actor} te invitó a fusionar planes de vacaciones',
'notif.photos_shared.title': 'Fotos compartidas',
+25
View File
@@ -199,6 +199,7 @@ const fr: Record<string, string> = {
'settings.notifyTripInvite': 'Invitations de voyage',
'settings.notifyBookingChange': 'Modifications de réservation',
'settings.notifyTripReminder': 'Rappels de voyage',
'settings.notifyTodoDue': 'Tâche à échéance',
'settings.notifyVacayInvite': 'Invitations de fusion Vacay',
'settings.notifyPhotosShared': 'Photos partagées (Immich)',
'settings.notifyCollabMessage': 'Messages de chat (Collab)',
@@ -451,6 +452,28 @@ const fr: Record<string, string> = {
'login.oidcFailed': 'Échec de connexion OIDC',
'login.usernameRequired': 'Le nom d\'utilisateur est obligatoire',
'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères',
'login.forgotPassword': 'Mot de passe oublié ?',
'login.forgotPasswordTitle': 'Réinitialiser votre mot de passe',
'login.forgotPasswordBody': 'Entrez l\'adresse e-mail associée à votre compte. Si un compte existe, nous enverrons un lien de réinitialisation.',
'login.forgotPasswordSubmit': 'Envoyer le lien',
'login.forgotPasswordSentTitle': 'Vérifiez vos e-mails',
'login.forgotPasswordSentBody': 'Si un compte existe pour cette adresse, un lien de réinitialisation est en route. Il expire dans 60 minutes.',
'login.forgotPasswordSmtpHintOff': 'Remarque : votre administrateur n\'a pas configuré SMTP. Le lien de réinitialisation sera écrit dans la console du serveur au lieu d\'être envoyé par e-mail.',
'login.backToLogin': 'Retour à la connexion',
'login.newPassword': 'Nouveau mot de passe',
'login.confirmPassword': 'Confirmer le nouveau mot de passe',
'login.passwordsDontMatch': 'Les mots de passe ne correspondent pas',
'login.mfaCode': 'Code 2FA',
'login.resetPasswordTitle': 'Définir un nouveau mot de passe',
'login.resetPasswordBody': 'Choisissez un mot de passe fort que vous n\'avez pas encore utilisé ici. 8 caractères minimum.',
'login.resetPasswordMfaBody': 'Entrez votre code 2FA ou un code de secours pour finaliser la réinitialisation.',
'login.resetPasswordSubmit': 'Réinitialiser',
'login.resetPasswordVerify': 'Vérifier et réinitialiser',
'login.resetPasswordSuccessTitle': 'Mot de passe mis à jour',
'login.resetPasswordSuccessBody': 'Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.',
'login.resetPasswordInvalidLink': 'Lien de réinitialisation invalide',
'login.resetPasswordInvalidLinkBody': 'Ce lien est manquant ou invalide. Demandez-en un nouveau pour continuer.',
'login.resetPasswordFailed': 'Échec de la réinitialisation. Le lien a peut-être expiré.',
'login.oidc.tokenFailed': 'L\'authentification a échoué.',
'login.oidc.invalidState': 'Session invalide. Veuillez réessayer.',
'login.demoFailed': 'Échec de la connexion démo',
@@ -1917,6 +1940,8 @@ const fr: Record<string, string> = {
'notif.booking_change.text': '{actor} a mis à jour une réservation dans {trip}',
'notif.trip_reminder.title': 'Rappel de voyage',
'notif.trip_reminder.text': 'Votre voyage {trip} approche !',
'notif.todo_due.title': 'Tâche à échéance',
'notif.todo_due.text': '{todo} dans {trip} est due le {due}',
'notif.vacay_invite.title': 'Invitation Vacay Fusion',
'notif.vacay_invite.text': '{actor} vous invite à fusionner les plans de vacances',
'notif.photos_shared.title': 'Photos partagées',
+25
View File
@@ -199,6 +199,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Utazási meghívók',
'settings.notifyBookingChange': 'Foglalási változások',
'settings.notifyTripReminder': 'Utazási emlékeztetők',
'settings.notifyTodoDue': 'Teendő esedékes',
'settings.notifyVacayInvite': 'Vacay összevonási meghívók',
'settings.notifyPhotosShared': 'Megosztott fotók (Immich)',
'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)',
@@ -458,6 +459,28 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'OIDC bejelentkezés sikertelen',
'login.usernameRequired': 'A felhasználónév kötelező',
'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
'login.forgotPassword': 'Elfelejtetted a jelszavad?',
'login.forgotPasswordTitle': 'Jelszó visszaállítása',
'login.forgotPasswordBody': 'Írd be a regisztrációnál használt e-mail-címet. Ha létezik fiók, küldünk egy visszaállítási linket.',
'login.forgotPasswordSubmit': 'Link küldése',
'login.forgotPasswordSentTitle': 'Nézd meg az e-mailjeidet',
'login.forgotPasswordSentBody': 'Ha létezik fiók ehhez az e-mailhez, a visszaállítási link úton van. 60 perc után lejár.',
'login.forgotPasswordSmtpHintOff': 'Megjegyzés: a rendszergazda nem konfigurálta az SMTP-t, ezért a visszaállítási link e-mail helyett a szerverkonzolba kerül.',
'login.backToLogin': 'Vissza a bejelentkezéshez',
'login.newPassword': 'Új jelszó',
'login.confirmPassword': 'Új jelszó megerősítése',
'login.passwordsDontMatch': 'A jelszavak nem egyeznek',
'login.mfaCode': '2FA-kód',
'login.resetPasswordTitle': 'Új jelszó beállítása',
'login.resetPasswordBody': 'Válassz erős jelszót, amit itt még nem használtál. Minimum 8 karakter.',
'login.resetPasswordMfaBody': 'Add meg a 2FA-kódodat vagy egy tartalék kódot a visszaállítás befejezéséhez.',
'login.resetPasswordSubmit': 'Jelszó visszaállítása',
'login.resetPasswordVerify': 'Ellenőrzés és visszaállítás',
'login.resetPasswordSuccessTitle': 'Jelszó frissítve',
'login.resetPasswordSuccessBody': 'Mostantól bejelentkezhetsz az új jelszavaddal.',
'login.resetPasswordInvalidLink': 'Érvénytelen visszaállítási link',
'login.resetPasswordInvalidLinkBody': 'A link hiányzik vagy sérült. A folytatáshoz kérj egy újat.',
'login.resetPasswordFailed': 'A visszaállítás nem sikerült. A link lehet, hogy lejárt.',
// Regisztráció
'register.passwordMismatch': 'A jelszavak nem egyeznek',
@@ -1915,6 +1938,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} frissített egy foglalást a(z) {trip} utazásban',
'notif.trip_reminder.title': 'Utazás emlékeztető',
'notif.trip_reminder.text': 'A(z) {trip} utazás hamarosan kezdődik!',
'notif.todo_due.title': 'Teendő esedékes',
'notif.todo_due.text': '{todo} ({trip}) határideje: {due}',
'notif.vacay_invite.title': 'Vacay Fusion meghívó',
'notif.vacay_invite.text': '{actor} meghívott a nyaralási tervek összevonásához',
'notif.photos_shared.title': 'Fotók megosztva',
+25
View File
@@ -202,6 +202,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Undangan perjalanan',
'settings.notifyBookingChange': 'Perubahan pemesanan',
'settings.notifyTripReminder': 'Pengingat perjalanan',
'settings.notifyTodoDue': 'Tugas jatuh tempo',
'settings.notifyVacayInvite': 'Undangan Vacay fusion',
'settings.notifyPhotosShared': 'Foto dibagikan (Immich)',
'settings.notifyCollabMessage': 'Pesan chat (Collab)',
@@ -520,6 +521,28 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'Login OIDC gagal',
'login.usernameRequired': 'Nama pengguna wajib diisi',
'login.passwordMinLength': 'Kata sandi minimal 8 karakter',
'login.forgotPassword': 'Lupa kata sandi?',
'login.forgotPasswordTitle': 'Setel ulang kata sandi',
'login.forgotPasswordBody': 'Masukkan alamat email akunmu. Jika akun ada, kami akan mengirim tautan reset.',
'login.forgotPasswordSubmit': 'Kirim tautan',
'login.forgotPasswordSentTitle': 'Periksa email kamu',
'login.forgotPasswordSentBody': 'Jika ada akun dengan email tersebut, tautannya sedang dikirim. Berlaku 60 menit.',
'login.forgotPasswordSmtpHintOff': 'Catatan: administrator belum mengonfigurasi SMTP, jadi tautan reset akan ditulis ke konsol server alih-alih dikirim lewat email.',
'login.backToLogin': 'Kembali ke login',
'login.newPassword': 'Kata sandi baru',
'login.confirmPassword': 'Konfirmasi kata sandi baru',
'login.passwordsDontMatch': 'Kata sandi tidak cocok',
'login.mfaCode': 'Kode 2FA',
'login.resetPasswordTitle': 'Tetapkan kata sandi baru',
'login.resetPasswordBody': 'Pilih kata sandi kuat yang belum pernah kamu pakai di sini. Minimal 8 karakter.',
'login.resetPasswordMfaBody': 'Masukkan kode 2FA atau kode cadangan untuk menyelesaikan reset.',
'login.resetPasswordSubmit': 'Setel ulang kata sandi',
'login.resetPasswordVerify': 'Verifikasi & setel ulang',
'login.resetPasswordSuccessTitle': 'Kata sandi diperbarui',
'login.resetPasswordSuccessBody': 'Sekarang kamu bisa login dengan kata sandi baru.',
'login.resetPasswordInvalidLink': 'Tautan tidak valid',
'login.resetPasswordInvalidLinkBody': 'Tautan hilang atau rusak. Minta tautan baru untuk melanjutkan.',
'login.resetPasswordFailed': 'Reset gagal. Tautan mungkin sudah kedaluwarsa.',
// Register
'register.passwordMismatch': 'Kata sandi tidak cocok',
@@ -1924,6 +1947,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} memperbarui pemesanan di {trip}',
'notif.trip_reminder.title': 'Pengingat Perjalanan',
'notif.trip_reminder.text': 'Perjalananmu {trip} akan segera dimulai!',
'notif.todo_due.title': 'Tugas jatuh tempo',
'notif.todo_due.text': '{todo} di {trip} jatuh tempo pada {due}',
'notif.vacay_invite.title': 'Undangan Vacay Fusion',
'notif.vacay_invite.text': '{actor} mengundangmu untuk menggabungkan rencana liburan',
'notif.photos_shared.title': 'Foto Dibagikan',
+25
View File
@@ -199,6 +199,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Inviti di viaggio',
'settings.notifyBookingChange': 'Modifiche alle prenotazioni',
'settings.notifyTripReminder': 'Promemoria di viaggio',
'settings.notifyTodoDue': 'Attività in scadenza',
'settings.notifyVacayInvite': 'Inviti fusione Vacay',
'settings.notifyPhotosShared': 'Foto condivise (Immich)',
'settings.notifyCollabMessage': 'Messaggi chat (Collab)',
@@ -458,6 +459,28 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'Accesso OIDC non riuscito',
'login.usernameRequired': 'Il nome utente è obbligatorio',
'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
'login.forgotPassword': 'Password dimenticata?',
'login.forgotPasswordTitle': 'Reimposta la password',
'login.forgotPasswordBody': 'Inserisci lindirizzo email del tuo account. Se esiste un account, invieremo un link per reimpostarla.',
'login.forgotPasswordSubmit': 'Invia link',
'login.forgotPasswordSentTitle': 'Controlla la tua email',
'login.forgotPasswordSentBody': 'Se esiste un account con questa email, il link è in arrivo. Scade tra 60 minuti.',
'login.forgotPasswordSmtpHintOff': 'Nota: il tuo amministratore non ha configurato SMTP, quindi il link di reset verrà scritto nella console del server invece di essere inviato via email.',
'login.backToLogin': 'Torna allaccesso',
'login.newPassword': 'Nuova password',
'login.confirmPassword': 'Conferma nuova password',
'login.passwordsDontMatch': 'Le password non corrispondono',
'login.mfaCode': 'Codice 2FA',
'login.resetPasswordTitle': 'Imposta una nuova password',
'login.resetPasswordBody': 'Scegli una password robusta che non hai già usato qui. Minimo 8 caratteri.',
'login.resetPasswordMfaBody': 'Inserisci il codice 2FA o un codice di backup per completare il reset.',
'login.resetPasswordSubmit': 'Reimposta password',
'login.resetPasswordVerify': 'Verifica e reimposta',
'login.resetPasswordSuccessTitle': 'Password aggiornata',
'login.resetPasswordSuccessBody': 'Ora puoi accedere con la nuova password.',
'login.resetPasswordInvalidLink': 'Link di reset non valido',
'login.resetPasswordInvalidLinkBody': 'Il link è mancante o danneggiato. Richiedine uno nuovo per continuare.',
'login.resetPasswordFailed': 'Reset non riuscito. Il link potrebbe essere scaduto.',
// Register
'register.passwordMismatch': 'Le password non corrispondono',
@@ -1918,6 +1941,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} ha aggiornato una prenotazione in {trip}',
'notif.trip_reminder.title': 'Promemoria viaggio',
'notif.trip_reminder.text': 'Il tuo viaggio {trip} si avvicina!',
'notif.todo_due.title': 'Attività in scadenza',
'notif.todo_due.text': '{todo} in {trip} scade il {due}',
'notif.vacay_invite.title': 'Invito Vacay Fusion',
'notif.vacay_invite.text': '{actor} ti ha invitato a fondere i piani vacanza',
'notif.photos_shared.title': 'Foto condivise',
+25
View File
@@ -199,6 +199,7 @@ const nl: Record<string, string> = {
'settings.notifyTripInvite': 'Reisuitnodigingen',
'settings.notifyBookingChange': 'Boekingswijzigingen',
'settings.notifyTripReminder': 'Reisherinneringen',
'settings.notifyTodoDue': 'Taak verloopt',
'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen',
'settings.notifyPhotosShared': 'Gedeelde foto\'s (Immich)',
'settings.notifyCollabMessage': 'Chatberichten (Collab)',
@@ -451,6 +452,28 @@ const nl: Record<string, string> = {
'login.oidcFailed': 'OIDC-aanmelding mislukt',
'login.usernameRequired': 'Gebruikersnaam is vereist',
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
'login.forgotPassword': 'Wachtwoord vergeten?',
'login.forgotPasswordTitle': 'Wachtwoord resetten',
'login.forgotPasswordBody': 'Voer het e-mailadres van je account in. Als er een account bestaat, sturen we een resetlink.',
'login.forgotPasswordSubmit': 'Resetlink verzenden',
'login.forgotPasswordSentTitle': 'Controleer je e-mail',
'login.forgotPasswordSentBody': 'Als er een account bestaat met dit adres, is de resetlink onderweg. Hij verloopt over 60 minuten.',
'login.forgotPasswordSmtpHintOff': 'Let op: de beheerder heeft SMTP niet ingesteld. De resetlink wordt naar de serverconsole geschreven in plaats van via e-mail verzonden.',
'login.backToLogin': 'Terug naar inloggen',
'login.newPassword': 'Nieuw wachtwoord',
'login.confirmPassword': 'Nieuw wachtwoord bevestigen',
'login.passwordsDontMatch': 'Wachtwoorden komen niet overeen',
'login.mfaCode': '2FA-code',
'login.resetPasswordTitle': 'Nieuw wachtwoord instellen',
'login.resetPasswordBody': 'Kies een sterk wachtwoord dat je hier nog niet hebt gebruikt. Minimaal 8 tekens.',
'login.resetPasswordMfaBody': 'Voer je 2FA-code of een back-upcode in om de reset te voltooien.',
'login.resetPasswordSubmit': 'Wachtwoord resetten',
'login.resetPasswordVerify': 'Verifiëren en resetten',
'login.resetPasswordSuccessTitle': 'Wachtwoord bijgewerkt',
'login.resetPasswordSuccessBody': 'Je kunt nu inloggen met je nieuwe wachtwoord.',
'login.resetPasswordInvalidLink': 'Ongeldige resetlink',
'login.resetPasswordInvalidLinkBody': 'Deze link ontbreekt of is ongeldig. Vraag een nieuwe aan om door te gaan.',
'login.resetPasswordFailed': 'Resetten mislukt. De link is mogelijk verlopen.',
'login.oidc.tokenFailed': 'Authenticatie mislukt.',
'login.oidc.invalidState': 'Ongeldige sessie. Probeer het opnieuw.',
'login.demoFailed': 'Demo-login mislukt',
@@ -1917,6 +1940,8 @@ const nl: Record<string, string> = {
'notif.booking_change.text': '{actor} heeft een boeking bijgewerkt in {trip}',
'notif.trip_reminder.title': 'Reisherinnering',
'notif.trip_reminder.text': 'Je reis {trip} komt eraan!',
'notif.todo_due.title': 'Taak verloopt',
'notif.todo_due.text': '{todo} in {trip} verloopt op {due}',
'notif.vacay_invite.title': 'Vacay Fusion-uitnodiging',
'notif.vacay_invite.text': '{actor} nodigt je uit om vakantieplannen te fuseren',
'notif.photos_shared.title': 'Foto\'s gedeeld',
+25
View File
@@ -182,6 +182,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Zaproszenia do podróży',
'settings.notifyBookingChange': 'Zmiany w rezerwacjach',
'settings.notifyTripReminder': 'Przypomnienia o podróżach',
'settings.notifyTodoDue': 'Zadanie z terminem',
'settings.notifyVacayInvite': 'Zaproszenia do połączenia kalendarzy',
'settings.notifyPhotosShared': 'Udostępnione zdjęcia (Immich)',
'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)',
@@ -425,6 +426,28 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'Logowanie OIDC nie powiodło się',
'login.usernameRequired': 'Nazwa użytkownika jest wymagana',
'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
'login.forgotPassword': 'Nie pamiętasz hasła?',
'login.forgotPasswordTitle': 'Zresetuj hasło',
'login.forgotPasswordBody': 'Wpisz adres e-mail użyty przy rejestracji. Jeśli konto istnieje, wyślemy link do resetu.',
'login.forgotPasswordSubmit': 'Wyślij link',
'login.forgotPasswordSentTitle': 'Sprawdź swoją pocztę',
'login.forgotPasswordSentBody': 'Jeśli istnieje konto dla tego adresu, link jest już w drodze. Wygaśnie za 60 minut.',
'login.forgotPasswordSmtpHintOff': 'Uwaga: administrator nie skonfigurował SMTP, więc link resetujący zostanie zapisany w konsoli serwera zamiast wysłania e-mailem.',
'login.backToLogin': 'Wróć do logowania',
'login.newPassword': 'Nowe hasło',
'login.confirmPassword': 'Potwierdź nowe hasło',
'login.passwordsDontMatch': 'Hasła nie są zgodne',
'login.mfaCode': 'Kod 2FA',
'login.resetPasswordTitle': 'Ustaw nowe hasło',
'login.resetPasswordBody': 'Wybierz silne hasło, którego tu jeszcze nie używałeś. Minimum 8 znaków.',
'login.resetPasswordMfaBody': 'Wpisz kod 2FA lub kod zapasowy, aby zakończyć reset.',
'login.resetPasswordSubmit': 'Zresetuj hasło',
'login.resetPasswordVerify': 'Zweryfikuj i zresetuj',
'login.resetPasswordSuccessTitle': 'Hasło zaktualizowane',
'login.resetPasswordSuccessBody': 'Możesz się teraz zalogować nowym hasłem.',
'login.resetPasswordInvalidLink': 'Nieprawidłowy link',
'login.resetPasswordInvalidLinkBody': 'Brakuje linku lub jest uszkodzony. Poproś o nowy, aby kontynuować.',
'login.resetPasswordFailed': 'Reset nie powiódł się. Link mógł wygasnąć.',
// Register
'register.passwordMismatch': 'Hasła nie są identyczne',
@@ -1907,6 +1930,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} zaktualizował rezerwację w {trip}',
'notif.trip_reminder.title': 'Przypomnienie o podróży',
'notif.trip_reminder.text': 'Twoja podróż {trip} zbliża się!',
'notif.todo_due.title': 'Zadanie z terminem',
'notif.todo_due.text': '{todo} w {trip} — termin {due}',
'notif.vacay_invite.title': 'Zaproszenie Vacay Fusion',
'notif.vacay_invite.text': '{actor} zaprosił Cię do połączenia planów urlopowych',
'notif.photos_shared.title': 'Zdjęcia udostępnione',
+25
View File
@@ -199,6 +199,7 @@ const ru: Record<string, string> = {
'settings.notifyTripInvite': 'Приглашения в поездку',
'settings.notifyBookingChange': 'Изменения бронирований',
'settings.notifyTripReminder': 'Напоминания о поездке',
'settings.notifyTodoDue': 'Задача к сроку',
'settings.notifyVacayInvite': 'Приглашения слияния Vacay',
'settings.notifyPhotosShared': 'Общие фото (Immich)',
'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
@@ -451,6 +452,28 @@ const ru: Record<string, string> = {
'login.oidcFailed': 'Ошибка входа через OIDC',
'login.usernameRequired': 'Имя пользователя обязательно',
'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
'login.forgotPassword': 'Забыли пароль?',
'login.forgotPasswordTitle': 'Сброс пароля',
'login.forgotPasswordBody': 'Введите e-mail, с которым вы регистрировались. Если аккаунт найдём — отправим ссылку для сброса.',
'login.forgotPasswordSubmit': 'Отправить ссылку',
'login.forgotPasswordSentTitle': 'Проверьте почту',
'login.forgotPasswordSentBody': 'Если аккаунт существует, ссылка для сброса уже летит к вам. Она действительна 60 минут.',
'login.forgotPasswordSmtpHintOff': 'Обратите внимание: администратор не настроил SMTP, поэтому ссылка для сброса будет записана в консоль сервера, а не отправлена по почте.',
'login.backToLogin': 'Вернуться ко входу',
'login.newPassword': 'Новый пароль',
'login.confirmPassword': 'Подтвердите новый пароль',
'login.passwordsDontMatch': 'Пароли не совпадают',
'login.mfaCode': 'Код 2FA',
'login.resetPasswordTitle': 'Задайте новый пароль',
'login.resetPasswordBody': 'Выберите надёжный пароль, который вы здесь ещё не использовали. Минимум 8 символов.',
'login.resetPasswordMfaBody': 'Введите код 2FA или резервный код, чтобы завершить сброс.',
'login.resetPasswordSubmit': 'Сбросить пароль',
'login.resetPasswordVerify': 'Проверить и сбросить',
'login.resetPasswordSuccessTitle': 'Пароль обновлён',
'login.resetPasswordSuccessBody': 'Теперь вы можете войти с новым паролем.',
'login.resetPasswordInvalidLink': 'Неверная ссылка сброса',
'login.resetPasswordInvalidLinkBody': 'Ссылка отсутствует или повреждена. Запросите новую, чтобы продолжить.',
'login.resetPasswordFailed': 'Сброс не удался. Возможно, срок действия ссылки истёк.',
'login.oidc.tokenFailed': 'Аутентификация не удалась.',
'login.oidc.invalidState': 'Недействительная сессия. Попробуйте снова.',
'login.demoFailed': 'Ошибка демо-входа',
@@ -1914,6 +1937,8 @@ const ru: Record<string, string> = {
'notif.booking_change.text': '{actor} обновил бронирование в {trip}',
'notif.trip_reminder.title': 'Напоминание о поездке',
'notif.trip_reminder.text': 'Ваша поездка {trip} скоро начнётся!',
'notif.todo_due.title': 'Задача к сроку',
'notif.todo_due.text': '{todo} в {trip} — срок {due}',
'notif.vacay_invite.title': 'Приглашение Vacay Fusion',
'notif.vacay_invite.text': '{actor} приглашает вас объединить планы отпуска',
'notif.photos_shared.title': 'Фото опубликованы',
+25
View File
@@ -199,6 +199,7 @@ const zh: Record<string, string> = {
'settings.notifyTripInvite': '旅行邀请',
'settings.notifyBookingChange': '预订变更',
'settings.notifyTripReminder': '旅行提醒',
'settings.notifyTodoDue': '待办事项即将到期',
'settings.notifyVacayInvite': 'Vacay 融合邀请',
'settings.notifyPhotosShared': '共享照片 (Immich)',
'settings.notifyCollabMessage': '聊天消息 (Collab)',
@@ -451,6 +452,28 @@ const zh: Record<string, string> = {
'login.oidcFailed': 'OIDC 登录失败',
'login.usernameRequired': '用户名为必填项',
'login.passwordMinLength': '密码至少需要8个字符',
'login.forgotPassword': '忘记密码?',
'login.forgotPasswordTitle': '重置密码',
'login.forgotPasswordBody': '输入您注册时使用的邮箱地址。若账户存在,我们将发送重置链接。',
'login.forgotPasswordSubmit': '发送重置链接',
'login.forgotPasswordSentTitle': '请查看邮箱',
'login.forgotPasswordSentBody': '若该邮箱存在账户,重置链接正在发送中。链接将在 60 分钟后失效。',
'login.forgotPasswordSmtpHintOff': '提示:管理员未配置 SMTP,重置链接将被写入服务器控制台,而不是通过电子邮件发送。',
'login.backToLogin': '返回登录',
'login.newPassword': '新密码',
'login.confirmPassword': '确认新密码',
'login.passwordsDontMatch': '两次输入的密码不一致',
'login.mfaCode': '二步验证码',
'login.resetPasswordTitle': '设置新密码',
'login.resetPasswordBody': '请选择您在此处未使用过的强密码。至少 8 位。',
'login.resetPasswordMfaBody': '输入您的二步验证码或备用代码以完成重置。',
'login.resetPasswordSubmit': '重置密码',
'login.resetPasswordVerify': '验证并重置',
'login.resetPasswordSuccessTitle': '密码已更新',
'login.resetPasswordSuccessBody': '您现在可以使用新密码登录了。',
'login.resetPasswordInvalidLink': '无效的重置链接',
'login.resetPasswordInvalidLinkBody': '此链接已丢失或损坏。请重新申请以继续。',
'login.resetPasswordFailed': '重置失败。链接可能已过期。',
'login.oidc.tokenFailed': '认证失败。',
'login.oidc.invalidState': '会话无效,请重试。',
'login.demoFailed': '演示登录失败',
@@ -1914,6 +1937,8 @@ const zh: Record<string, string> = {
'notif.booking_change.text': '{actor} 更新了 {trip} 中的预订',
'notif.trip_reminder.title': '旅行提醒',
'notif.trip_reminder.text': '您的旅行 {trip} 即将开始!',
'notif.todo_due.title': '待办事项即将到期',
'notif.todo_due.text': '{trip} 中的 {todo} 将于 {due} 到期',
'notif.vacay_invite.title': 'Vacay 融合邀请',
'notif.vacay_invite.text': '{actor} 邀请您合并假期计划',
'notif.photos_shared.title': '照片已分享',
+25
View File
@@ -199,6 +199,7 @@ const zhTw: Record<string, string> = {
'settings.notifyTripInvite': '旅行邀請',
'settings.notifyBookingChange': '預訂變更',
'settings.notifyTripReminder': '旅行提醒',
'settings.notifyTodoDue': '待辦事項即將到期',
'settings.notifyVacayInvite': 'Vacay 融合邀請',
'settings.notifyPhotosShared': '共享照片 (Immich)',
'settings.notifyCollabMessage': '聊天訊息 (Collab)',
@@ -510,6 +511,28 @@ const zhTw: Record<string, string> = {
'login.oidcFailed': 'OIDC 登入失敗',
'login.usernameRequired': '使用者名稱為必填',
'login.passwordMinLength': '密碼至少需要8個字元',
'login.forgotPassword': '忘記密碼?',
'login.forgotPasswordTitle': '重設密碼',
'login.forgotPasswordBody': '請輸入您註冊時使用的電子郵件。若帳號存在,我們將傳送重設連結。',
'login.forgotPasswordSubmit': '傳送重設連結',
'login.forgotPasswordSentTitle': '請查看您的電子郵件',
'login.forgotPasswordSentBody': '若此電子郵件存在帳號,重設連結正在傳送中。連結將於 60 分鐘後失效。',
'login.forgotPasswordSmtpHintOff': '提醒:管理員尚未設定 SMTP,重設連結將寫入伺服器控制台,而非透過電子郵件寄送。',
'login.backToLogin': '返回登入',
'login.newPassword': '新密碼',
'login.confirmPassword': '確認新密碼',
'login.passwordsDontMatch': '兩次輸入的密碼不一致',
'login.mfaCode': '2FA 驗證碼',
'login.resetPasswordTitle': '設定新密碼',
'login.resetPasswordBody': '請選擇您在此處尚未使用過的強密碼。至少 8 個字元。',
'login.resetPasswordMfaBody': '請輸入您的 2FA 驗證碼或備用代碼以完成重設。',
'login.resetPasswordSubmit': '重設密碼',
'login.resetPasswordVerify': '驗證並重設',
'login.resetPasswordSuccessTitle': '密碼已更新',
'login.resetPasswordSuccessBody': '您現在可以使用新密碼登入。',
'login.resetPasswordInvalidLink': '無效的重設連結',
'login.resetPasswordInvalidLinkBody': '此連結遺失或已損壞。請重新申請以繼續。',
'login.resetPasswordFailed': '重設失敗。連結可能已過期。',
'login.oidc.tokenFailed': '認證失敗。',
'login.oidc.invalidState': '會話無效,請重試。',
'login.demoFailed': '演示登入失敗',
@@ -2173,6 +2196,8 @@ const zhTw: Record<string, string> = {
'notif.booking_change.text': '{actor} 更新了 {trip} 中的預訂',
'notif.trip_reminder.title': '旅行提醒',
'notif.trip_reminder.text': '你的旅行 {trip} 即將開始!',
'notif.todo_due.title': '待辦事項即將到期',
'notif.todo_due.text': '{trip} 中的 {todo} 將於 {due} 到期',
'notif.vacay_invite.title': 'Vacay Fusion 邀請',
'notif.vacay_invite.text': '{actor} 邀請你合併假期計畫',
'notif.photos_shared.title': '照片已分享',
+151
View File
@@ -0,0 +1,151 @@
import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { Mail, ArrowLeft, CheckCircle2, Terminal } from 'lucide-react'
import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 12px 11px 38px', borderRadius: 12,
border: '1px solid #e5e7eb', fontSize: 14, fontFamily: 'inherit',
outline: 'none', transition: 'border-color 120ms',
background: 'white', color: '#111827',
}
const ForgotPasswordPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [smtpConfigured, setSmtpConfigured] = useState<boolean | null>(null)
useEffect(() => {
// Probe whether SMTP is configured so we can warn the user up-front
// that the link will land in the server console instead of their
// inbox. Null while pending — hint is hidden until we know.
authApi.getAppConfig?.()
.then((cfg: any) => {
const hasEmail = !!cfg?.available_channels?.email
setSmtpConfigured(hasEmail)
})
.catch(() => setSmtpConfigured(null))
}, [])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return
setIsLoading(true)
try {
await authApi.forgotPassword({ email: email.trim() })
} catch {
// Enumeration-safe: success UX regardless of server outcome.
}
setSubmitted(true)
setIsLoading(false)
}
return (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(180deg, #f9fafb, #ffffff)', padding: 24, fontFamily: 'inherit',
}}>
<div style={{
width: '100%', maxWidth: 420, background: 'white', borderRadius: 20,
boxShadow: '0 12px 40px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.04)',
padding: '32px 28px',
}}>
<button type="button" onClick={() => navigate('/login')} style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
color: '#6b7280', fontSize: 13, fontFamily: 'inherit', marginBottom: 22,
}}>
<ArrowLeft size={14} />{t('login.backToLogin')}
</button>
{submitted ? (
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<div style={{
width: 56, height: 56, borderRadius: '50%', background: '#ecfdf5',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: '#059669', marginBottom: 16,
}}>
<CheckCircle2 size={28} />
</div>
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 10px 0' }}>
{t('login.forgotPasswordSentTitle')}
</h1>
<p style={{ fontSize: 14, color: '#4b5563', lineHeight: 1.55, margin: 0 }}>
{t('login.forgotPasswordSentBody')}
</p>
{smtpConfigured === false && (
<div style={{
marginTop: 18, padding: '12px 14px',
background: '#fffbeb', border: '1px solid #fde68a',
borderRadius: 10, textAlign: 'left',
display: 'flex', alignItems: 'flex-start', gap: 10,
}}>
<Terminal size={16} style={{ color: '#92400e', marginTop: 1, flexShrink: 0 }} />
<p style={{ fontSize: 12.5, color: '#92400e', lineHeight: 1.55, margin: 0 }}>
{t('login.forgotPasswordSmtpHintOff')}
</p>
</div>
)}
<button type="button" onClick={() => navigate('/login')} style={{
marginTop: 24, padding: '11px 22px', background: '#111827', color: 'white',
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
cursor: 'pointer', fontFamily: 'inherit',
}}>{t('login.backToLogin')}</button>
</div>
) : (
<>
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 8px 0' }}>
{t('login.forgotPasswordTitle')}
</h1>
<p style={{ fontSize: 13.5, color: '#6b7280', lineHeight: 1.55, margin: '0 0 16px 0' }}>
{t('login.forgotPasswordBody')}
</p>
{smtpConfigured === false && (
<div style={{
padding: '10px 12px', marginBottom: 18,
background: '#fffbeb', border: '1px solid #fde68a',
borderRadius: 10, display: 'flex', alignItems: 'flex-start', gap: 10,
}}>
<Terminal size={15} style={{ color: '#92400e', marginTop: 1, flexShrink: 0 }} />
<p style={{ fontSize: 12.5, color: '#92400e', lineHeight: 1.5, margin: 0 }}>
{t('login.forgotPasswordSmtpHintOff')}
</p>
</div>
)}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
{t('common.email')}
</label>
<div style={{ position: 'relative' }}>
<Mail size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="email" value={email}
onChange={(e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
required placeholder={t('login.emailPlaceholder')} style={inputBase}
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
/>
</div>
</div>
<button type="submit" disabled={isLoading} style={{
width: '100%', padding: '12px', background: '#111827', color: 'white',
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
cursor: isLoading ? 'default' : 'pointer', fontFamily: 'inherit',
opacity: isLoading ? 0.7 : 1, transition: 'opacity 0.15s',
}}>
{isLoading ? t('login.signingIn') : t('login.forgotPasswordSubmit')}
</button>
</form>
</>
)}
</div>
</div>
)
}
export default ForgotPasswordPage
+12 -4
View File
@@ -595,7 +595,11 @@ export default function JourneyDetailPage() {
</div>
{entries.map((entry, idx) => {
const canReorder = !isMobile && canEditEntries && entries.length > 1
// Skeletons are just "suggested" places pulled
// from the linked trip — they aren't real
// journey entries until the user edits them,
// so reordering them does not make sense.
const canReorder = !isMobile && canEditEntries && entries.length > 1 && entry.type !== 'skeleton'
const move = (direction: -1 | 1) => {
if (!current) return
const target = idx + direction
@@ -2312,7 +2316,10 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
)}
</div>
{/* Gallery picker — directly below buttons */}
{/* Gallery picker directly below buttons. Safari collapses
`aspect-square` items inside an overflow-scroll grid, so
the square is enforced with a padding-top spacer + an
absolutely positioned image (works across all browsers). */}
{showGalleryPick && (
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
@@ -2330,9 +2337,10 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
setPhotos(prev => [...prev, gp])
}
}}
className="aspect-square rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
className="relative w-full rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
style={{ paddingTop: '100%' }}
>
<img src={photoUrl(gp)} alt="" className="w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</div>
))}
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
+16 -5
View File
@@ -36,8 +36,8 @@ interface PublicPhoto {
caption?: string | null
}
function photoUrl(p: PublicPhoto, shareToken: string): string {
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/original`
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
}
function formatDate(d: string): { weekday: string; month: string; day: number } {
@@ -84,9 +84,20 @@ export default function JourneyPublicPage() {
const journey = data?.journey || {}
const stats = data?.stats || {}
const groupedEntries = useMemo(() => groupByDate(entries), [entries])
// `[Trip Photos]` and `Gallery` are synthetic photo-only containers
// produced by the trip→journey sync. They have no story and no
// location, and the owner view strips them from the timeline the
// same way (JourneyDetailPage.tsx). Gallery keeps their photos.
const timelineEntries = useMemo(
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
[entries],
)
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo(() => entries.filter(e => e.location_lat && e.location_lng), [entries])
const mapEntries = useMemo(
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
[timelineEntries],
)
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
// Set default view based on permissions
@@ -312,7 +323,7 @@ export default function JourneyPublicPage() {
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })}
>
<img src={photoUrl(photo, token!)} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div>
))}
</div>
+11
View File
@@ -781,6 +781,17 @@ export default function LoginPage(): React.ReactElement {
}} />
</button>
</div>
{mode === 'login' && (
<div style={{ textAlign: 'right', marginTop: 6 }}>
<button type="button" onClick={() => navigate('/forgot-password')} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
}}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.color = '#111827' }}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.color = '#6b7280' }}
>{t('login.forgotPassword')}</button>
</div>
)}
</div>
)}
+205
View File
@@ -0,0 +1,205 @@
import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Lock, KeyRound, CheckCircle2, AlertTriangle, Eye, EyeOff } from 'lucide-react'
import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { getApiErrorMessage } from '../types'
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 44px 11px 38px', borderRadius: 12,
border: '1px solid #e5e7eb', fontSize: 14, fontFamily: 'inherit',
outline: 'none', transition: 'border-color 120ms',
background: 'white', color: '#111827',
}
const ResetPasswordPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [params] = useSearchParams()
const token = params.get('token') || ''
const [pw, setPw] = useState('')
const [pw2, setPw2] = useState('')
const [showPw, setShowPw] = useState(false)
const [mfaCode, setMfaCode] = useState('')
const [mfaRequired, setMfaRequired] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
if (!token) setError(t('login.resetPasswordInvalidLink'))
}, [token, t])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return
setError('')
if (!token) return
if (pw.length < 8) { setError(t('login.passwordMinLength')); return }
if (pw !== pw2) { setError(t('login.passwordsDontMatch')); return }
setIsLoading(true)
try {
const res = await authApi.resetPassword({
token,
new_password: pw,
...(mfaRequired && mfaCode ? { mfa_code: mfaCode.trim() } : {}),
})
if (res.mfa_required) {
setMfaRequired(true)
setIsLoading(false)
return
}
if (res.success) {
setSuccess(true)
}
} catch (err) {
setError(getApiErrorMessage(err, t('login.resetPasswordFailed')))
}
setIsLoading(false)
}
const shell = (inner: React.ReactNode) => (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(180deg, #f9fafb, #ffffff)', padding: 24, fontFamily: 'inherit',
}}>
<div style={{
width: '100%', maxWidth: 440, background: 'white', borderRadius: 20,
boxShadow: '0 12px 40px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.04)',
padding: '32px 28px',
}}>{inner}</div>
</div>
)
if (success) {
return shell(
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<div style={{
width: 56, height: 56, borderRadius: '50%', background: '#ecfdf5',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: '#059669', marginBottom: 16,
}}><CheckCircle2 size={28} /></div>
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 10px 0' }}>
{t('login.resetPasswordSuccessTitle')}
</h1>
<p style={{ fontSize: 14, color: '#4b5563', lineHeight: 1.55, margin: 0 }}>
{t('login.resetPasswordSuccessBody')}
</p>
<button type="button" onClick={() => navigate('/login')} style={{
marginTop: 24, padding: '11px 22px', background: '#111827', color: 'white',
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
cursor: 'pointer', fontFamily: 'inherit',
}}>{t('login.signIn')}</button>
</div>
)
}
if (!token) {
return shell(
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<div style={{
width: 56, height: 56, borderRadius: '50%', background: '#fef2f2',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: '#dc2626', marginBottom: 16,
}}><AlertTriangle size={28} /></div>
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 10px 0' }}>
{t('login.resetPasswordInvalidLink')}
</h1>
<p style={{ fontSize: 14, color: '#4b5563', lineHeight: 1.55, margin: 0 }}>
{t('login.resetPasswordInvalidLinkBody')}
</p>
<button type="button" onClick={() => navigate('/forgot-password')} style={{
marginTop: 24, padding: '11px 22px', background: '#111827', color: 'white',
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
cursor: 'pointer', fontFamily: 'inherit',
}}>{t('login.forgotPasswordSubmit')}</button>
</div>
)
}
return shell(
<>
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 8px 0' }}>
{t('login.resetPasswordTitle')}
</h1>
<p style={{ fontSize: 13.5, color: '#6b7280', lineHeight: 1.55, margin: '0 0 22px 0' }}>
{mfaRequired ? t('login.resetPasswordMfaBody') : t('login.resetPasswordBody')}
</p>
{error && (
<div style={{
padding: '10px 12px', background: '#fef2f2', border: '1px solid #fecaca',
borderRadius: 10, color: '#991b1b', fontSize: 13, marginBottom: 14,
}}>{error}</div>
)}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{!mfaRequired && (
<>
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
{t('login.newPassword')}
</label>
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type={showPw ? 'text' : 'password'} value={pw}
onChange={(e: ChangeEvent<HTMLInputElement>) => setPw(e.target.value)}
required placeholder="••••••••" style={inputBase}
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
/>
<button type="button" onClick={() => setShowPw(v => !v)} style={{
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#9ca3af',
}}>{showPw ? <EyeOff size={16} /> : <Eye size={16} />}</button>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
{t('login.confirmPassword')}
</label>
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type={showPw ? 'text' : 'password'} value={pw2}
onChange={(e: ChangeEvent<HTMLInputElement>) => setPw2(e.target.value)}
required placeholder="••••••••" style={inputBase}
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
/>
</div>
</div>
</>
)}
{mfaRequired && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
{t('login.mfaCode')}
</label>
<div style={{ position: 'relative' }}>
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="text" inputMode="numeric" value={mfaCode}
onChange={(e: ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value)}
required placeholder="123456 or backup-code" style={{ ...inputBase, paddingRight: 12 }}
autoFocus
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
/>
</div>
</div>
)}
<button type="submit" disabled={isLoading} style={{
width: '100%', padding: '12px', background: '#111827', color: 'white',
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
cursor: isLoading ? 'default' : 'pointer', fontFamily: 'inherit',
opacity: isLoading ? 0.7 : 1,
}}>
{isLoading ? '…' : (mfaRequired ? t('login.resetPasswordVerify') : t('login.resetPasswordSubmit'))}
</button>
</form>
</>
)
}
export default ResetPasswordPage
@@ -0,0 +1,71 @@
// FE-CLIENT-INTERCEPTOR-001 to FE-CLIENT-INTERCEPTOR-012
import { describe, it, expect } from 'vitest'
import { isAuthPublicPath } from '../../../src/api/client'
describe('FE-CLIENT-INTERCEPTOR: 401 AUTH_REQUIRED redirect allowlist', () => {
describe('exact-match public paths — no redirect', () => {
it('FE-CLIENT-INTERCEPTOR-001: /login', () => {
expect(isAuthPublicPath('/login')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-002: /register', () => {
expect(isAuthPublicPath('/register')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-003: /forgot-password', () => {
expect(isAuthPublicPath('/forgot-password')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-004: /reset-password', () => {
expect(isAuthPublicPath('/reset-password')).toBe(true)
})
})
describe('prefix-match public paths — no redirect', () => {
it('FE-CLIENT-INTERCEPTOR-005: /shared/:token', () => {
expect(isAuthPublicPath('/shared/abc123token')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-006: /public/journey/:token', () => {
expect(isAuthPublicPath('/public/journey/xyz789')).toBe(true)
})
})
describe('paths that matched via includes() before fix — must redirect', () => {
it('FE-CLIENT-INTERCEPTOR-007: /admin/login', () => {
expect(isAuthPublicPath('/admin/login')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-008: /admin/register', () => {
expect(isAuthPublicPath('/admin/register')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-009: /some-login-page', () => {
expect(isAuthPublicPath('/some-login-page')).toBe(false)
})
})
describe('paths that matched via loose startsWith before fix — must redirect', () => {
it('FE-CLIENT-INTERCEPTOR-010: /reset-password-extra', () => {
expect(isAuthPublicPath('/reset-password-extra')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-011: /forgot-password-extra', () => {
expect(isAuthPublicPath('/forgot-password-extra')).toBe(false)
})
})
describe('private app paths — must redirect', () => {
it('FE-CLIENT-INTERCEPTOR-012: /dashboard', () => {
expect(isAuthPublicPath('/dashboard')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-013: /trips/123', () => {
expect(isAuthPublicPath('/trips/123')).toBe(false)
})
it('FE-CLIENT-INTERCEPTOR-014: / (root)', () => {
expect(isAuthPublicPath('/')).toBe(false)
})
})
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+146
View File
@@ -0,0 +1,146 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 820" role="img" aria-label="TREK feature tiles">
<g transform="translate(0 0)">
<defs>
<clipPath id="clip-planner"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-planner)">
<rect width="520" height="260" fill="#FFE8D9"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#B5492D" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#B5492D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#B5492D">TRIP PLANNER</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Drag and drop</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#B5492D">day by day</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#7C3B2A">Reorder, move across days, optimise</text>
</g>
</g>
<g transform="translate(540 0)">
<defs>
<clipPath id="clip-maps"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-maps)">
<rect width="520" height="260" fill="#DDEDFB"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#1E5AA8" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M1 6l7-3 8 3 7-3v15l-7 3-8-3-7 3zM8 3v15M16 6v15"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#1E5AA8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6l7-3 8 3 7-3v15l-7 3-8-3-7 3zM8 3v15M16 6v15"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#1E5AA8">MAPS</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">See it all</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1E5AA8">on the map</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#3B4D66">Leaflet + Mapbox GL, 3D buildings</text>
</g>
</g>
<g transform="translate(1080 0)">
<defs>
<clipPath id="clip-collab"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-collab)">
<rect width="520" height="260" fill="#D6F0E3"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#1F7A4E" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#1F7A4E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#1F7A4E">COLLAB</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Plan together</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1F7A4E">in real time</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#325646">WebSocket sync, chat, polls, notes</text>
</g>
</g>
<g transform="translate(0 280)">
<defs>
<clipPath id="clip-budget"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-budget)">
<rect width="520" height="260" fill="#FDF0C7"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#8A6A1E" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M20 12V8a2 2 0 0 0-2-2H4a2 2 0 0 1 0-4h14v4"/><path d="M2 6v14a2 2 0 0 0 2 2h18v-8H16a2 2 0 0 1 0-4h6"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#8A6A1E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12V8a2 2 0 0 0-2-2H4a2 2 0 0 1 0-4h14v4"/><path d="M2 6v14a2 2 0 0 0 2 2h18v-8H16a2 2 0 0 1 0-4h6"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#8A6A1E">BUDGET</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Track costs</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#8A6A1E">per person</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#6B5523">Pie chart, multi-currency, splits</text>
</g>
</g>
<g transform="translate(540 280)">
<defs>
<clipPath id="clip-packing"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-packing)">
<rect width="520" height="260" fill="#E4DEF6"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#5443A5" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#5443A5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#5443A5">PACKING</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Lists, sorted.</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#5443A5">by category</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#4A3E72">Templates, bag tracking, weights</text>
</g>
</g>
<g transform="translate(1080 280)">
<defs>
<clipPath id="clip-journal"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-journal)">
<rect width="520" height="260" fill="#FBDAE4"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#9A2F58" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#9A2F58" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#9A2F58">JOURNEY</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">A journal for</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#9A2F58">every trip</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#6E3345">Magazine entries, photos, maps</text>
</g>
</g>
<g transform="translate(0 560)">
<defs>
<clipPath id="clip-vacay"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-vacay)">
<rect width="520" height="260" fill="#D4DCF7"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#2F3FA4" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#2F3FA4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#2F3FA4">VACAY</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Vacation days,</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#2F3FA4">visualised</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#3B4471">Calendar, 100+ country holidays</text>
</g>
</g>
<g transform="translate(540 560)">
<defs>
<clipPath id="clip-mcp"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-mcp)">
<rect width="520" height="260" fill="#CFEDE6"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#116E64" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M8 16h.01M16 16h.01"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#116E64" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M8 16h.01M16 16h.01"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#116E64">AI / MCP</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Let AI plan</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#116E64">your trips</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#2C524C">80+ tools, OAuth 2.1, Claude-ready</text>
</g>
</g>
<g transform="translate(1080 560)">
<defs>
<clipPath id="clip-selfhost"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-selfhost)">
<rect width="520" height="260" fill="#E4E6EB"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#2D3544" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#2D3544" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#2D3544">SELF-HOSTED</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Runs on</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#2D3544">your server</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#414958">Docker, SQLite, AGPL — your data, yours</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

+130
View File
@@ -0,0 +1,130 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1060 1100" role="img" aria-label="TREK feature tiles">
<g transform="translate(0 0)">
<defs>
<clipPath id="clip-planner"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-planner)">
<rect width="520" height="260" fill="#FFE8D9"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#B5492D" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#B5492D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#B5492D">TRIP PLANNER</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Drag and drop</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#B5492D">day by day</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#7C3B2A">Reorder, move across days, optimise</text>
</g>
</g>
<g transform="translate(540 0)">
<defs>
<clipPath id="clip-maps"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-maps)">
<rect width="520" height="260" fill="#DDEDFB"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#1E5AA8" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M1 6l7-3 8 3 7-3v15l-7 3-8-3-7 3zM8 3v15M16 6v15"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#1E5AA8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6l7-3 8 3 7-3v15l-7 3-8-3-7 3zM8 3v15M16 6v15"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#1E5AA8">MAPS</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">See it all</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1E5AA8">on the map</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#3B4D66">Leaflet + Mapbox GL, 3D buildings</text>
</g>
</g>
<g transform="translate(0 280)">
<defs>
<clipPath id="clip-collab"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-collab)">
<rect width="520" height="260" fill="#D6F0E3"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#1F7A4E" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#1F7A4E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#1F7A4E">COLLAB</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Plan together</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1F7A4E">in real time</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#325646">WebSocket sync, chat, polls, notes</text>
</g>
</g>
<g transform="translate(540 280)">
<defs>
<clipPath id="clip-budget"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-budget)">
<rect width="520" height="260" fill="#FDF0C7"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#8A6A1E" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M20 12V8a2 2 0 0 0-2-2H4a2 2 0 0 1 0-4h14v4"/><path d="M2 6v14a2 2 0 0 0 2 2h18v-8H16a2 2 0 0 1 0-4h6"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#8A6A1E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12V8a2 2 0 0 0-2-2H4a2 2 0 0 1 0-4h14v4"/><path d="M2 6v14a2 2 0 0 0 2 2h18v-8H16a2 2 0 0 1 0-4h6"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#8A6A1E">BUDGET</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Track costs</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#8A6A1E">per person</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#6B5523">Pie chart, multi-currency, splits</text>
</g>
</g>
<g transform="translate(0 560)">
<defs>
<clipPath id="clip-packing"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-packing)">
<rect width="520" height="260" fill="#E4DEF6"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#5443A5" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#5443A5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#5443A5">PACKING</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Lists, sorted.</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#5443A5">by category</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#4A3E72">Templates, bag tracking, weights</text>
</g>
</g>
<g transform="translate(540 560)">
<defs>
<clipPath id="clip-journal"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-journal)">
<rect width="520" height="260" fill="#FBDAE4"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#9A2F58" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#9A2F58" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#9A2F58">JOURNEY</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">A journal for</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#9A2F58">every trip</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#6E3345">Magazine entries, photos, maps</text>
</g>
</g>
<g transform="translate(0 840)">
<defs>
<clipPath id="clip-vacay"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-vacay)">
<rect width="520" height="260" fill="#D4DCF7"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#2F3FA4" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#2F3FA4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#2F3FA4">VACAY</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Vacation days,</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#2F3FA4">visualised</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#3B4471">Calendar, 100+ country holidays</text>
</g>
</g>
<g transform="translate(540 840)">
<defs>
<clipPath id="clip-mcp"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-mcp)">
<rect width="520" height="260" fill="#CFEDE6"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#116E64" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M8 16h.01M16 16h.01"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#116E64" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M8 16h.01M16 16h.01"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#116E64">AI / MCP</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Let AI plan</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#116E64">your trips</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#2C524C">80+ tools, OAuth 2.1, Claude-ready</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

-7
View File
@@ -1,7 +0,0 @@
# Release Notes
## v2.9.11
### Bug Fixes
- **OIDC-only mode: resolved login/logout loop** — When password authentication is disabled, logging out no longer triggers an immediate re-authentication loop. After logout, users land on the login page and must manually click "Sign in with {provider}" to start the OIDC flow. Also fixed a secondary loop that could occur on the OIDC callback page under React 18 StrictMode, where the auth code exchange would be interrupted before completing, causing the app to redirect back to the identity provider instead of landing on the dashboard. (#491)
+35 -4
View File
@@ -237,8 +237,8 @@ async function main() {
}
db.transaction(() => {
// --- app_settings: oidc_client_secret, smtp_pass ---
for (const key of ['oidc_client_secret', 'smtp_pass']) {
// --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token ---
for (const key of ['oidc_client_secret', 'smtp_pass', 'admin_webhook_url', 'admin_ntfy_token']) {
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
if (!row?.value) continue;
const newVal = migrateApiKeyValue(row.value, `app_settings.${key}`);
@@ -247,8 +247,8 @@ async function main() {
}
}
// --- users: maps_api_key, openweather_api_key, immich_api_key ---
const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key'];
// --- users: api key columns + synology credentials ---
const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did'];
const users = db.prepare('SELECT id FROM users').all() as { id: number }[];
for (const user of users) {
@@ -271,6 +271,37 @@ async function main() {
}
}
}
// --- settings: per-user encrypted keys ---
const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token'];
const settingRows = db.prepare(
`SELECT user_id, key, value FROM settings WHERE key IN (${encryptedSettingKeys.map(() => '?').join(', ')})`
).all(...encryptedSettingKeys) as { user_id: number; key: string; value: string }[];
for (const row of settingRows) {
if (!row.value) continue;
const newVal = migrateApiKeyValue(row.value, `settings[user=${row.user_id}].${row.key}`);
if (newVal !== null) {
db.prepare('UPDATE settings SET value = ? WHERE user_id = ? AND key = ?').run(newVal, row.user_id, row.key);
}
}
// --- trip_album_links: passphrase ---
const albumLinks = db.prepare('SELECT id, passphrase FROM trip_album_links WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
for (const row of albumLinks) {
const newVal = migrateApiKeyValue(row.passphrase, `trip_album_links[${row.id}].passphrase`);
if (newVal !== null) {
db.prepare('UPDATE trip_album_links SET passphrase = ? WHERE id = ?').run(newVal, row.id);
}
}
// --- trek_photos: passphrase ---
const photos = db.prepare('SELECT id, passphrase FROM trek_photos WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
for (const row of photos) {
const newVal = migrateApiKeyValue(row.passphrase, `trek_photos[${row.id}].passphrase`);
if (newVal !== null) {
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ?').run(newVal, row.id);
}
}
})();
db.close();
+61 -13
View File
@@ -5,11 +5,9 @@ import cookieParser from 'cookie-parser';
import path from 'node:path';
import fs from 'node:fs';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from './config';
import { logDebug, logWarn, logError } from './services/auditLog';
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import { authenticate } from './middleware/auth';
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
import { db } from './db/database';
import authRoutes from './routes/auth';
@@ -76,7 +74,25 @@ export function createApp(): express.Application {
}
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
// HSTS is worth enabling any time we're serving production traffic,
// not only when FORCE_HTTPS is set. Self-hosters behind Traefik /
// Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the
// proxy handles the redirect for them), and the previous "HSTS off by
// default" meant those instances never advertised HSTS at all.
//
// `includeSubDomains` stays OFF by default on purpose: an instance
// running on an apex domain would otherwise force HTTPS on every
// sibling subdomain the same operator may still be running over plain
// HTTP. Operators who want the stricter policy opt in with
// `HSTS_INCLUDE_SUBDOMAINS=true`.
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true';
// RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config
app.use(
['/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource'],
cors({ origin: '*', credentials: false }),
);
app.use(cors({ origin: corsOrigin, credentials: true }));
app.use(helmet({
contentSecurityPolicy: {
@@ -107,7 +123,7 @@ export function createApp(): express.Application {
}
},
crossOriginEmbedderPolicy: false,
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
}));
if (shouldForceHttps) {
@@ -156,12 +172,33 @@ export function createApp(): express.Application {
});
}
// Static: avatars, covers, and journey photos
// Static: avatars, covers, and journey photos.
//
// Security model (audit SEC-M9): these paths are unauthenticated by
// design. All filenames are server-chosen UUID v4 (see `uuid()` in
// the multer storage config for avatars / covers / journey uploads),
// which gives each asset >122 bits of namespace entropy — not
// guessable via enumeration. An attacker would need to have already
// seen the URL (email, shared journey, etc.) to request the file.
//
// Moving these behind auth would also break:
// - Unauthenticated trip-card rendering on public share links
// - Journey public-share pages (/public/journey/:token)
// - Email-embedded avatars
//
// The `/uploads/photos/...` route below is DIFFERENT: photo URLs are
// not embedded in unauthenticated UI contexts, so that endpoint IS
// gated (session JWT with pv, or a share token scoped to the photo's
// trip).
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey')));
// Photos require auth or valid share token
// Photos require either a valid logged-in session (via JWT with the
// password_version gate) OR a share token that covers the SPECIFIC
// photo's trip. Previously any share token for any trip could request
// any photo filename by UUID — fine in practice because UUIDs are
// unguessable, but the auth model was wrong.
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
const safeName = path.basename(req.params.filename);
const filePath = path.join(__dirname, '../uploads/photos', safeName);
@@ -169,17 +206,28 @@ export function createApp(): express.Application {
if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) {
return res.status(403).send('Forbidden');
}
// existsSync here is cheap and avoids a sendFile error frame; kept
// sync because the handler is already short-lived.
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
const authHeader = req.headers.authorization;
const token = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
if (!token) return res.status(401).send('Authentication required');
const rawToken = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
if (!rawToken) return res.status(401).send('Authentication required');
try {
jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
} catch {
const shareRow = db.prepare('SELECT id FROM share_tokens WHERE token = ?').get(token);
if (!shareRow) return res.status(401).send('Authentication required');
// JWT session path (with pv check).
const user = verifyJwtAndLoadUser(rawToken);
if (user) return res.sendFile(resolved);
// Share-token path: require the token to cover the exact trip the
// photo belongs to. Expired tokens fall through to 401.
const photo = db.prepare('SELECT trip_id FROM photos WHERE filename = ?').get(safeName) as { trip_id: number } | undefined;
if (!photo) return res.status(401).send('Authentication required');
const share = db.prepare(
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).get(rawToken) as { trip_id: number } | undefined;
if (!share || share.trip_id !== photo.trip_id) {
return res.status(401).send('Authentication required');
}
res.sendFile(resolved);
});
+9 -9
View File
@@ -35,15 +35,6 @@ function initDb(): void {
initDb();
if (process.env.DEMO_MODE === 'true') {
try {
const { seedDemoData } = require('../demo/demo-seed');
seedDemoData(_db);
} catch (err: unknown) {
console.error('[Demo] Seed error:', err instanceof Error ? err.message : err);
}
}
const db = new Proxy({} as Database.Database, {
get(_, prop: string | symbol) {
if (!_db) throw new Error('Database connection is not available (restore in progress?)');
@@ -56,6 +47,15 @@ const db = new Proxy({} as Database.Database, {
},
});
if (process.env.DEMO_MODE === 'true') {
try {
const { seedDemoData } = require('../demo/demo-seed');
seedDemoData(_db);
} catch (err: unknown) {
console.error('[Demo] Seed error:', err instanceof Error ? err.message : err);
}
}
function closeDb(): void {
if (_db) {
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
+179
View File
@@ -1767,6 +1767,185 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('no such table') && !err.message?.includes('FOREIGN KEY')) throw err;
}
},
// Migration: RFC 8707 resource indicators — audience-bind OAuth tokens to /mcp
() => {
try { db.exec('ALTER TABLE oauth_tokens ADD COLUMN audience TEXT'); }
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
// Migration: password reset — add password_version for session
// invalidation, and a token table keyed by SHA-256 hash (raw tokens
// never hit the DB).
() => {
try { db.exec('ALTER TABLE users ADD COLUMN password_version INTEGER NOT NULL DEFAULT 0'); }
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
db.exec(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
consumed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
`);
},
// Migration: todo due-date reminders — track when we last sent a
// reminder for each todo so we don't spam the same notification
// every day the scheduler runs.
() => {
try { db.exec('ALTER TABLE todo_items ADD COLUMN reminded_at DATETIME'); }
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
// Migration: security audit batch 1 — columns + indexes required
// by several fixes bundled into one PR.
// - share_tokens.expires_at: public share links now get a 90-day
// TTL by default; existing rows stay NULL (= no expiry) to avoid
// silently breaking already-published links.
// - Missing indexes on high-cardinality query paths (see PERF-H1
// in the audit): every listTrips() used to full-scan trips on
// user_id, and notifications/photos/reservations had similar
// gaps.
() => {
try { db.exec('ALTER TABLE share_tokens ADD COLUMN expires_at TEXT'); }
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
db.exec(`
CREATE INDEX IF NOT EXISTS idx_trips_user_id ON trips(user_id);
CREATE INDEX IF NOT EXISTS idx_trips_created_at ON trips(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_photos_day_id ON photos(day_id);
CREATE INDEX IF NOT EXISTS idx_photos_place_id ON photos(place_id);
CREATE INDEX IF NOT EXISTS idx_reservations_day_id ON reservations(day_id);
CREATE INDEX IF NOT EXISTS idx_share_tokens_token ON share_tokens(token);
`);
try {
// day_accommodations may have either start_day_id/end_day_id or a
// single day_id depending on how far the schema has evolved;
// build whichever index makes sense for the live columns.
const cols = db.prepare("PRAGMA table_info('day_accommodations')").all() as Array<{ name: string }>;
const names = new Set(cols.map((c) => c.name));
if (names.has('start_day_id')) db.exec('CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id)');
if (names.has('end_day_id')) db.exec('CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id)');
} catch { /* table may not exist on very old installs */ }
try {
// notifications schema has varied; probe before indexing.
const cols = db.prepare("PRAGMA table_info('notifications')").all() as Array<{ name: string }>;
const names = new Set(cols.map((c) => c.name));
if (names.has('target') && names.has('scope')) {
db.exec('CREATE INDEX IF NOT EXISTS idx_notifications_target_scope ON notifications(target, scope)');
}
} catch { /* notifications table may not exist on very old installs */ }
},
// Migration: widen idempotency_keys primary key to (key, user_id,
// method, path). The middleware lookup was widened in the same audit
// batch so a reused X-Idempotency-Key against a different endpoint
// does not replay the cached body of an unrelated request. The old
// PK was only (key, user_id), so the `INSERT OR IGNORE` on the
// second endpoint silently skipped — the cache never stored request
// B's response and replays re-executed the handler. Rebuild the
// table with the widened PK, preserving existing rows (the old PK
// guarantees no conflicts in the new, strictly looser unique key).
() => {
const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'idempotency_keys'").get();
if (!hasTable) return;
db.exec(`
CREATE TABLE idempotency_keys_new (
key TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
method TEXT NOT NULL,
path TEXT NOT NULL,
status_code INTEGER NOT NULL,
response_body TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
PRIMARY KEY (key, user_id, method, path)
);
INSERT INTO idempotency_keys_new (key, user_id, method, path, status_code, response_body, created_at)
SELECT key, user_id, method, path, status_code, response_body, created_at FROM idempotency_keys;
DROP TABLE idempotency_keys;
ALTER TABLE idempotency_keys_new RENAME TO idempotency_keys;
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
`);
},
// SEC-H6: revoke all OAuth tokens issued before audience binding was
// enforced. mcp/index.ts now unconditionally checks audience; tokens
// with audience=null would be permanently rejected by the check, so
// removing them here avoids leaving dead rows and makes the intent clear.
() => {
const hasCol = db.prepare("SELECT name FROM pragma_table_info('oauth_tokens') WHERE name = 'audience'").get();
if (!hasCol) return;
db.prepare('DELETE FROM oauth_tokens WHERE audience IS NULL').run();
},
// Remove NOT NULL constraint on day_accommodations.place_id so hotel
// reservations created from the Bookings tab without a linked place can
// still persist their date range. Change ON DELETE CASCADE → SET NULL so
// deleting a place orphans the accommodation row instead of cascading.
() => {
db.exec(`
CREATE TABLE day_accommodations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
check_in TEXT,
check_in_end TEXT,
check_out TEXT,
confirmation TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO day_accommodations_new
SELECT id, trip_id, place_id, start_day_id, end_day_id,
check_in, check_in_end, check_out, confirmation, notes, created_at
FROM day_accommodations;
DROP TABLE day_accommodations;
ALTER TABLE day_accommodations_new RENAME TO day_accommodations;
CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id);
CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id);
CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id);
`);
},
// Migration: null out proxy image_url entries that have no backing disk cache.
// Migrations 107 and the migration below wrote /api/maps/place-photo/<id>/bytes
// into places.image_url without actually fetching/caching the photo bytes. The
// photoService short-circuits on that prefix and hits /bytes directly → 404.
// Rows with a confirmed disk cache entry in google_place_photo_meta are left alone;
// only stale proxy URLs (never actually fetched) are cleared so the normal
// fetch-and-cache flow can repopulate them.
() => {
db.exec(`
UPDATE places
SET image_url = NULL, updated_at = CURRENT_TIMESTAMP
WHERE image_url LIKE '/api/maps/place-photo/%/bytes'
AND google_place_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM google_place_photo_meta
WHERE place_id = places.google_place_id
AND error_at IS NULL
)
`);
},
// Migration: clear legacy Google photo URLs missed by Migration 107.
// Migration 107 matched /places/%/photos/% only; lh3.googleusercontent.com URLs use
// /place-photos/ or /places/<opaque-id> paths and were skipped. NULL those stale URLs
// so the normal fetch-and-cache flow repopulates image_url with a real proxy URL.
() => {
db.exec(`
UPDATE places
SET image_url = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE image_url IS NOT NULL
AND image_url != ''
AND image_url NOT LIKE '/api/maps/place-photo/%'
AND (
image_url LIKE 'http://%googleusercontent.com/%'
OR image_url LIKE 'https://%googleusercontent.com/%'
OR image_url LIKE 'http://%places.googleapis.com/%'
OR image_url LIKE 'https://%places.googleapis.com/%'
)
`);
},
];
if (currentVersion < migrations.length) {
+14 -1
View File
@@ -25,10 +25,23 @@ function createTables(db: Database.Database): void {
synology_password TEXT,
synology_sid TEXT,
must_change_password INTEGER DEFAULT 0,
password_version INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
consumed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -331,7 +344,7 @@ function createTables(db: Database.Database): void {
CREATE TABLE IF NOT EXISTS day_accommodations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
check_in TEXT,
+1
View File
@@ -46,6 +46,7 @@ const server = app.listen(PORT, () => {
}
scheduler.start();
scheduler.startTripReminders();
scheduler.startTodoReminders();
scheduler.startVersionCheck();
scheduler.startDemoReset();
scheduler.startIdempotencyCleanup();
+14
View File
@@ -11,6 +11,7 @@ import { registerResources } from './resources';
import { registerTools } from './tools';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
import { writeAudit, getClientIp } from '../services/auditLog';
import { getAppUrl } from '../services/oidcService';
export { revokeUserSessions, revokeUserSessionsForClient };
@@ -151,6 +152,12 @@ const sessionSweepInterval = setInterval(() => {
// Prevent the interval from keeping the process alive if nothing else is running
sessionSweepInterval.unref();
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getAppUrl() || '').replace(/\/+$/, '');
res.set('WWW-Authenticate',
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource", error="${error}"`);
}
interface VerifyTokenResult {
user: User;
/** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */
@@ -173,6 +180,10 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
if (token.startsWith('trekoa_')) {
const result = getUserByAccessToken(token);
if (!result) return null;
// RFC 8707: audience must always match this resource endpoint.
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
if (result.audience !== expected) return null;
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
}
@@ -211,6 +222,7 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
const tokenResult = verifyToken(req.headers['authorization']);
if (!tokenResult) {
setAuthChallenge(res);
res.status(401).json({ error: 'Access token required' });
return;
}
@@ -231,10 +243,12 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
return;
}
if (session.userId !== user.id) {
setAuthChallenge(res);
res.status(403).json({ error: 'Session belongs to a different user' });
return;
}
if (session.clientId !== clientId) {
setAuthChallenge(res);
res.status(403).json({ error: 'Session was created with a different OAuth client' });
return;
}
+8 -4
View File
@@ -13,7 +13,7 @@ import { listCategories } from '../services/categoryService';
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
import { getNotifications } from '../services/inAppNotifications';
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
import { isAddonEnabled } from '../services/adminService';
import { isAddonEnabled, getCollabFeatures } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService';
import { canRead, canReadTrips } from './scopes';
@@ -188,7 +188,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
);
// Collab notes for a trip
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) server.registerResource(
const collabFeatures = isAddonEnabled(ADDON_IDS.COLLAB) ? getCollabFeatures() : null;
if (collabFeatures?.notes && canRead(scopes, 'collab')) server.registerResource(
'trip-collab-notes',
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
{ description: 'Shared collaborative notes for a trip', mimeType: 'application/json' },
@@ -319,8 +320,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
);
}
// Collab polls & messages (addon-gated)
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) {
// Collab polls (addon + sub-feature gated)
if (collabFeatures?.polls && canRead(scopes, 'collab')) {
server.registerResource(
'trip-collab-polls',
new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }),
@@ -332,7 +333,10 @@ export function registerResources(server: McpServer, userId: number, scopes: str
return jsonContent(uri.href, polls);
}
);
}
// Collab messages (addon + sub-feature gated)
if (collabFeatures?.chat && canRead(scopes, 'collab')) {
server.registerResource(
'trip-collab-messages',
new ResourceTemplate('trek://trips/{tripId}/collab/messages', { list: undefined }),
+15 -13
View File
@@ -7,7 +7,7 @@ import {
listPolls, createPoll, votePoll, closePoll, deletePoll,
listMessages, createMessage, deleteMessage, addOrRemoveReaction,
} from '../../services/collabService';
import { isAddonEnabled } from '../../services/adminService';
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
@@ -22,9 +22,11 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
if (!isAddonEnabled(ADDON_IDS.COLLAB)) return;
const features = getCollabFeatures();
// --- COLLAB NOTES ---
if (W) server.registerTool(
if (features.notes && W) server.registerTool(
'create_collab_note',
{
description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
@@ -47,7 +49,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.notes && W) server.registerTool(
'update_collab_note',
{
description: 'Edit an existing collaborative note on a trip.',
@@ -72,7 +74,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.notes && W) server.registerTool(
'delete_collab_note',
{
description: 'Delete a collaborative note from a trip.',
@@ -94,7 +96,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
// --- COLLAB POLLS & CHAT ---
if (R) server.registerTool(
if (features.polls && R) server.registerTool(
'list_collab_polls',
{
description: 'List all polls for a trip.',
@@ -110,7 +112,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.polls && W) server.registerTool(
'create_collab_poll',
{
description: 'Create a new poll in the collab panel.',
@@ -132,7 +134,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.polls && W) server.registerTool(
'vote_collab_poll',
{
description: 'Vote on a poll option (or remove vote if already voted for that option).',
@@ -152,7 +154,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.polls && W) server.registerTool(
'close_collab_poll',
{
description: 'Close a poll so no more votes can be cast.',
@@ -172,7 +174,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.polls && W) server.registerTool(
'delete_collab_poll',
{
description: 'Delete a poll and all its votes.',
@@ -192,7 +194,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (R) server.registerTool(
if (features.chat && R) server.registerTool(
'list_collab_messages',
{
description: 'List chat messages for a trip (most recent 100, oldest-first).',
@@ -209,7 +211,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.chat && W) server.registerTool(
'send_collab_message',
{
description: "Send a chat message to a trip's collab channel.",
@@ -230,7 +232,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.chat && W) server.registerTool(
'delete_collab_message',
{
description: 'Delete a chat message (only the message owner can delete their own messages).',
@@ -250,7 +252,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.chat && W) server.registerTool(
'react_collab_message',
{
description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).',
+2 -2
View File
@@ -44,7 +44,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'),
confirmation_number: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
metadata: z.record(z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
endpoints: endpointSchema,
needs_review: z.boolean().optional(),
},
@@ -95,7 +95,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'),
confirmation_number: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
metadata: z.record(z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
endpoints: endpointSchema,
needs_review: z.boolean().optional(),
},
+11 -19
View File
@@ -12,7 +12,7 @@ import {
import {
createOrUpdateShareLink, getShareLink, deleteShareLink,
} from '../../services/shareService';
import { isAddonEnabled } from '../../services/adminService';
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { countMessages, listPolls } from '../../services/collabService';
import {
@@ -161,6 +161,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
const collabFeatures = collabEnabled ? getCollabFeatures() : null;
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
// Core trip data (metadata, days, members, accommodations) is always included
// because this tool is always registered and needed for navigation.
@@ -173,16 +174,16 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
let pollCount = 0;
let messageCount = 0;
if (canReadCollab) {
pollCount = listPolls(tripId).length;
messageCount = countMessages(tripId);
if (collabFeatures?.polls) pollCount = listPolls(tripId).length;
if (collabFeatures?.chat) messageCount = countMessages(tripId);
}
const notice = getDeprecationNotice();
const data = {
const summaryData = {
...summary,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab ? summary.collab_notes : [],
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab && collabFeatures?.notes ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
@@ -191,19 +192,10 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify(data, null, 2) },
{ type: 'text' as const, text: JSON.stringify(summaryData, null, 2) },
],
};
return ok({
...summary,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
});
return ok(summaryData);
}
);
+31 -17
View File
@@ -4,6 +4,7 @@ import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { AuthRequest, OptionalAuthRequest, User } from '../types';
import { applyIdempotency } from './idempotency';
import { isDemoEmail } from '../services/demo';
export function extractToken(req: Request): string | null {
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
@@ -13,13 +14,34 @@ export function extractToken(req: Request): string | null {
return (authHeader && authHeader.split(' ')[1]) || null;
}
function verifyJwtAndLoadUser(token: string): User | null {
/**
* Verify a JWT and load its user, enforcing the password_version gate.
*
* Exported so every auth surface in the codebase (MCP bearer tokens,
* file download query tokens, the photo-serving route) goes through the
* same check. A password reset bumps `users.password_version`, which
* invalidates every JWT that embedded the prior value but only if
* every verify path actually compares the claim. Previously several
* paths called `jwt.verify` directly and skipped the DB lookup, so a
* stolen token kept working after the victim reset.
*/
export function verifyJwtAndLoadUser(token: string): User | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
return user ?? null;
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number };
const row = db.prepare(
'SELECT id, username, email, role, password_version FROM users WHERE id = ?'
).get(decoded.id) as (User & { password_version?: number }) | undefined;
if (!row) return null;
// Session invalidation: any token whose embedded password_version
// predates the user's current one is rejected. Tokens issued before
// the `pv` claim existed (decoded.pv === undefined) are treated as
// version 0 so legacy sessions keep working until the user resets.
const tokenPv = typeof decoded.pv === 'number' ? decoded.pv : 0;
const currentPv = typeof row.password_version === 'number' ? row.password_version : 0;
if (tokenPv !== currentPv) return null;
// Don't leak password_version beyond the middleware.
const { password_version: _pv, ...user } = row;
return user as User;
} catch {
return null;
}
@@ -68,15 +90,7 @@ const optionalAuth = (req: Request, res: Response, next: NextFunction): void =>
return next();
}
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
(req as OptionalAuthRequest).user = user || null;
} catch (err: unknown) {
(req as OptionalAuthRequest).user = null;
}
(req as OptionalAuthRequest).user = verifyJwtAndLoadUser(token) || null;
next();
};
@@ -91,8 +105,8 @@ const adminOnly = (req: Request, res: Response, next: NextFunction): void => {
const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void => {
const authReq = req as AuthRequest;
if (process.env.DEMO_MODE === 'true' && authReq.user?.email === 'demo@nomad.app') {
res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host NOMAD for full functionality.' });
if (process.env.DEMO_MODE === 'true' && isDemoEmail(authReq.user?.email)) {
res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' });
return;
}
next();
+29 -8
View File
@@ -2,6 +2,13 @@ import { Request, Response, NextFunction } from 'express';
import { db } from '../db/database';
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
// Reject pathological client-supplied keys outright instead of hashing
// everything — 128 chars is plenty for any realistic UUID / ULID / nonce.
const MAX_KEY_LENGTH = 128;
// Responses larger than this are not worth caching — a backup-restore
// endpoint could otherwise store a megabyte-sized JSON body per request
// key and, with mass key creation, blow up idempotency_keys.
const MAX_CACHED_BODY_BYTES = 256 * 1024;
interface IdempotencyRow {
status_code: number;
@@ -12,9 +19,14 @@ interface IdempotencyRow {
* Called from within `authenticate` after req.user is set.
*
* For mutating requests carrying X-Idempotency-Key:
* - If (key, userId) already stored: replays the cached response.
* - If (key, userId, method, path) already stored: replays the cached response.
* - Otherwise: wraps res.json to capture and store a successful response.
*
* The lookup is scoped by method + path as well as user so the same key
* replayed against a different endpoint doesn't return the cached body
* of an unrelated request. Key length is capped and the cached body is
* skipped when it exceeds `MAX_CACHED_BODY_BYTES`.
*
* Storing happens in idempotency_keys (24h TTL, cleaned by scheduler).
*/
export function applyIdempotency(req: Request, res: Response, next: NextFunction, userId: number): void {
@@ -28,11 +40,17 @@ export function applyIdempotency(req: Request, res: Response, next: NextFunction
next();
return;
}
if (key.length > MAX_KEY_LENGTH) {
res.status(400).json({ error: 'X-Idempotency-Key exceeds maximum length of 128 characters' });
return;
}
// Return cached response if key already processed for this user
// Return cached response only if the same key was seen for the same
// user AND the same method+path — avoids a POST's cached body leaking
// into an unrelated PATCH that reused the idempotency-key string.
const existing = db.prepare(
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ?'
).get(key, userId) as IdempotencyRow | undefined;
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?'
).get(key, userId, req.method, req.path) as IdempotencyRow | undefined;
if (existing) {
res.status(existing.status_code).json(JSON.parse(existing.response_body));
@@ -44,10 +62,13 @@ export function applyIdempotency(req: Request, res: Response, next: NextFunction
res.json = function (body: unknown): Response {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
db.prepare(
`INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(key, userId, req.method, req.path, res.statusCode, JSON.stringify(body), Math.floor(Date.now() / 1000));
const serialized = JSON.stringify(body);
if (serialized.length <= MAX_CACHED_BODY_BYTES) {
db.prepare(
`INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(key, userId, req.method, req.path, res.statusCode, serialized, Math.floor(Date.now() / 1000));
}
} catch {
// Non-fatal: if storage fails, the request still succeeds
}
+18 -17
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { extractToken, verifyJwtAndLoadUser } from './auth';
import { DEMO_EMAILS } from '../services/demo';
/** Paths that never require MFA (public or pre-auth). */
export function isPublicApiPath(method: string, pathNoQuery: string): boolean {
@@ -42,21 +42,25 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
return;
}
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
// Accept both the httpOnly session cookie (regular SPA users) and the
// Authorization header (MCP / API clients). Previously this only looked
// at the header so every normal cookie-authenticated session sailed
// past `require_mfa` unchecked.
const token = extractToken(req);
if (!token) {
next();
return;
}
let userId: number;
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
userId = decoded.id;
} catch {
// Use the shared verify helper so the `password_version` gate applies
// here too — a JWT stolen before a password reset would otherwise
// continue to satisfy this middleware until its natural 24h expiry.
const verified = verifyJwtAndLoadUser(token);
if (!verified) {
next();
return;
}
const userId = verified.id;
const requireRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
if (requireRow?.value !== 'true') {
@@ -64,16 +68,13 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
return;
}
if (process.env.DEMO_MODE === 'true') {
const demo = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
if (demo?.email === 'demo@trek.app' || demo?.email === 'demo@nomad.app') {
next();
return;
}
if (process.env.DEMO_MODE === 'true' && verified.email && DEMO_EMAILS.has(verified.email)) {
next();
return;
}
const row = db.prepare('SELECT mfa_enabled, role FROM users WHERE id = ?').get(userId) as
| { mfa_enabled: number | boolean; role: string }
const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as
| { mfa_enabled: number | boolean }
| undefined;
if (!row) {
next();
+1
View File
@@ -266,6 +266,7 @@ router.get('/collab-features', (_req: Request, res: Response) => {
router.put('/collab-features', (req: Request, res: Response) => {
const result = svc.updateCollabFeatures(req.body);
invalidateMcpSessions();
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
+90 -11
View File
@@ -36,7 +36,10 @@ import {
deleteMcpToken,
createWsToken,
createResourceToken,
requestPasswordReset,
resetPassword,
} from '../services/authService';
import { sendPasswordResetEmail, getAppUrl } from '../services/notifications';
const router = express.Router();
@@ -76,6 +79,8 @@ const RATE_LIMIT_CLEANUP = 5 * 60 * 1000;
const loginAttempts = new Map<string, { count: number; first: number }>();
const mfaAttempts = new Map<string, { count: number; first: number }>();
const forgotAttempts = new Map<string, { count: number; first: number }>();
const resetAttempts = new Map<string, { count: number; first: number }>();
setInterval(() => {
const now = Date.now();
for (const [key, record] of loginAttempts) {
@@ -84,6 +89,12 @@ setInterval(() => {
for (const [key, record] of mfaAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) mfaAttempts.delete(key);
}
for (const [key, record] of forgotAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) forgotAttempts.delete(key);
}
for (const [key, record] of resetAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) resetAttempts.delete(key);
}
}, RATE_LIMIT_CLEANUP);
function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempts) {
@@ -104,6 +115,8 @@ function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempt
}
const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW);
const mfaLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, mfaAttempts);
const forgotLimiter = rateLimiter(3, RATE_LIMIT_WINDOW, forgotAttempts);
const resetLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, resetAttempts);
// ---------------------------------------------------------------------------
// Routes
@@ -114,10 +127,10 @@ router.get('/app-config', optionalAuth, (req: Request, res: Response) => {
res.json(getAppConfig(user));
});
router.post('/demo-login', (_req: Request, res: Response) => {
router.post('/demo-login', (req: Request, res: Response) => {
const result = demoLogin();
if (result.error) return res.status(result.status!).json({ error: result.error });
setAuthCookie(res, result.token!);
setAuthCookie(res, result.token!, req);
res.json({ token: result.token, user: result.user });
});
@@ -131,7 +144,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
const result = registerUser(req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
setAuthCookie(res, result.token!);
setAuthCookie(res, result.token!, req);
res.status(201).json({ token: result.token, user: result.user });
});
@@ -142,10 +155,76 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
}
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
setAuthCookie(res, result.token!);
setAuthCookie(res, result.token!, req);
res.json({ token: result.token, user: result.user });
});
// ---------------------------------------------------------------------------
// Password reset (forgot / complete)
// ---------------------------------------------------------------------------
// Generic OK response — identical regardless of email existence, to
// prevent enumeration via response body OR status code.
const GENERIC_FORGOT_RESPONSE = { ok: true };
// Minimum time we spend inside the forgot handler so a "no such user"
// path does not complete noticeably faster than a real reset.
const FORGOT_MIN_LATENCY_MS = 350;
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
const started = Date.now();
const rawEmail = typeof req.body?.email === 'string' ? req.body.email : '';
const ip = getClientIp(req);
const outcome = requestPasswordReset(rawEmail, ip);
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
// Build the reset URL from the server-side canonical APP_URL (or
// first ALLOWED_ORIGINS entry) — never from request headers. A
// crafted `Origin` / `Host` / `Referer` would otherwise put an
// attacker-controlled domain into the emailed reset link while the
// token itself is still legitimate.
const origin = getAppUrl();
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
// Audit the REQUEST always — even for "no user" — so abuse is visible.
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
try {
const delivery = await sendPasswordResetEmail(outcome.userEmail, url, outcome.userId);
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
} catch (err) {
// Never surface delivery failure to the caller — still respond ok.
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
}
} else {
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
}
// Pad the response so timing doesn't reveal outcome.
const elapsed = Date.now() - started;
if (elapsed < FORGOT_MIN_LATENCY_MS) {
await new Promise((r) => setTimeout(r, FORGOT_MIN_LATENCY_MS - elapsed));
}
res.json(GENERIC_FORGOT_RESPONSE);
});
router.post('/reset-password', resetLimiter, (req: Request, res: Response) => {
const ip = getClientIp(req);
const result = resetPassword(req.body);
if (result.error) {
writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
return res.status(result.status!).json({ error: result.error });
}
if (result.mfa_required) {
return res.status(200).json({ mfa_required: true });
}
writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
// Purposefully do NOT auto-login — the user just demonstrated they
// have email+password access; asking them to sign in fresh is the
// standard, safer UX.
res.json({ success: true });
});
router.get('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = getCurrentUser(authReq.user.id);
@@ -153,8 +232,8 @@ router.get('/me', authenticate, (req: Request, res: Response) => {
res.json({ user });
});
router.post('/logout', (_req: Request, res: Response) => {
clearAuthCookie(res);
router.post('/logout', (req: Request, res: Response) => {
clearAuthCookie(res, req);
res.json({ success: true });
});
@@ -198,15 +277,15 @@ router.get('/me/settings', authenticate, (req: Request, res: Response) => {
res.json({ settings: result.settings });
});
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), (req: Request, res: Response) => {
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
res.json(saveAvatar(authReq.user.id, req.file.filename));
res.json(await saveAvatar(authReq.user.id, req.file.filename));
});
router.delete('/avatar', authenticate, (req: Request, res: Response) => {
router.delete('/avatar', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(deleteAvatar(authReq.user.id));
res.json(await deleteAvatar(authReq.user.id));
});
router.get('/users', authenticate, (req: Request, res: Response) => {
@@ -251,7 +330,7 @@ router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => {
const result = verifyMfaLogin(req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
setAuthCookie(res, result.token!);
setAuthCookie(res, result.token!, req);
res.json({ token: result.token, user: result.user });
});
+5 -2
View File
@@ -9,6 +9,7 @@ import { validateStringLengths } from '../middleware/validate';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import { db } from '../db/database';
import { BLOCKED_EXTENSIONS } from '../services/fileService';
import {
verifyTripAccess,
listNotes,
@@ -41,8 +42,10 @@ const noteUpload = multer({
defParamCharset: 'utf8',
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const BLOCKED = ['.svg', '.html', '.htm', '.xml', '.xhtml', '.js', '.jsx', '.ts', '.exe', '.bat', '.sh', '.cmd', '.msi', '.dll', '.com', '.vbs', '.ps1', '.php'];
if (BLOCKED.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
// Share the single BLOCKED_EXTENSIONS list from fileService so
// executable/script attachments can't sneak in via collab when the
// main uploader already rejects them.
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
const err: Error & { statusCode?: number } = new Error('File type not allowed');
err.statusCode = 400;
return cb(err);
+4 -4
View File
@@ -210,7 +210,7 @@ router.post('/:id/restore', authenticate, (req: Request, res: Response) => {
});
// Permanently delete from trash
router.delete('/:id/permanent', authenticate, (req: Request, res: Response) => {
router.delete('/:id/permanent', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
@@ -222,13 +222,13 @@ router.delete('/:id/permanent', authenticate, (req: Request, res: Response) => {
const file = getDeletedFile(id, tripId);
if (!file) return res.status(404).json({ error: 'File not found in trash' });
permanentDeleteFile(file);
await permanentDeleteFile(file);
res.json({ success: true });
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string);
});
// Empty entire trash
router.delete('/trash/empty', authenticate, (req: Request, res: Response) => {
router.delete('/trash/empty', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
@@ -237,7 +237,7 @@ router.delete('/trash/empty', authenticate, (req: Request, res: Response) => {
if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const deleted = emptyTrash(tripId);
const deleted = await emptyTrash(tripId);
res.json({ success: true, deleted });
});
+59 -4
View File
@@ -87,6 +87,20 @@ oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request,
scope_descriptions: Object.fromEntries(
ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label])
),
resource_parameter_supported: true,
});
});
// RFC 9728 Protected Resource Metadata
oauthPublicRouter.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const base = (getAppUrl() || '').replace(/\/+$/, '');
res.json({
resource: `${base}/mcp`,
authorization_servers: [base],
bearer_methods_supported: ['header'],
scopes_supported: ALL_SCOPES,
resource_name: 'TREK MCP',
});
});
@@ -98,7 +112,7 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
// Accept both JSON and application/x-www-form-urlencoded
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token } = body;
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body;
const ip = getClientIp(req);
if (!isAddonEnabled(ADDON_IDS.MCP)) {
@@ -133,6 +147,12 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
// RFC 8707: if the auth code was bound to a resource, the token request must present the same value
if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'resource_mismatch' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
// Verify client secret
if (!authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
@@ -146,8 +166,8 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
const tokens = issueTokens(client_id, pending.userId, pending.scopes);
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes }, ip });
const tokens = issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
return res.json(tokens);
}
@@ -185,6 +205,37 @@ oauthPublicRouter.post('/oauth/register', dcrLimiter, (req: Request, res: Respon
if (redirectUris.length === 0) {
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required and must be a non-empty array' });
}
// OAuth 2.1 + RFC 8252: confidential web apps need HTTPS; public
// clients (MCP, native) are limited to loopback or a reverse-DNS
// private-use scheme. This rejects `http://evil.example` DCR payloads
// that today would otherwise be accepted since we previously only
// checked shape. Dangerous URL schemes (`javascript:`, `data:` etc.)
// are explicitly rejected — the authorize flow later 302s the
// browser to this URI, which with `javascript:` would execute
// attacker-controlled script under our redirect origin's context.
const DANGEROUS_SCHEMES = new Set([
'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:',
]);
const allowed = redirectUris.every((u) => {
try {
const url = new URL(u);
if (DANGEROUS_SCHEMES.has(url.protocol)) return false;
if (url.protocol === 'https:') return true;
if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) return true;
// RFC 8252 §7.1 private-use scheme: must be a reverse-DNS name
// (e.g. `com.example.myapp:/callback`). Requiring a dot in the
// scheme is a cheap heuristic that rules out bare `myapp:` and
// `x:` one-off schemes the spec explicitly discourages.
const schemeBody = url.protocol.slice(0, -1);
if (/^[a-z][a-z0-9+.-]*$/i.test(schemeBody) && schemeBody.includes('.')) return true;
return false;
} catch {
return false;
}
});
if (!allowed) {
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme' });
}
const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : '';
const clientName = rawName || 'MCP Client';
@@ -275,6 +326,7 @@ oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: R
state: params.state,
code_challenge: params.code_challenge || '',
code_challenge_method: params.code_challenge_method || '',
resource: typeof params.resource === 'string' ? params.resource : undefined,
},
userId,
);
@@ -298,7 +350,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
const { user } = req as AuthRequest;
const {
client_id, redirect_uri, scope, state,
code_challenge, code_challenge_method, approved,
code_challenge, code_challenge_method, approved, resource,
} = req.body as {
client_id: string;
redirect_uri: string;
@@ -307,6 +359,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
code_challenge: string;
code_challenge_method: string;
approved: boolean;
resource?: string;
};
const ip = getClientIp(req);
@@ -332,6 +385,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
state,
code_challenge,
code_challenge_method,
resource,
};
const validation = validateAuthorizeRequest(params, user.id);
@@ -350,6 +404,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
userId: user.id,
redirectUri: redirect_uri,
scopes,
resource: validation.resource ?? null,
codeChallenge: code_challenge,
codeChallengeMethod: 'S256',
});
+32 -1
View File
@@ -9,6 +9,7 @@ import {
consumeAuthCode,
exchangeCodeForToken,
getUserInfo,
verifyIdToken,
findOrCreateUser,
touchLastLogin,
generateToken,
@@ -97,10 +98,40 @@ router.get('/callback', async (req: Request, res: Response) => {
return res.redirect(frontendUrl('/login?oidc_error=token_failed'));
}
// Strict id_token verification: signature via JWKS + iss + aud.
// Previously only the access_token was used to hit userinfo, so a
// compromised provider or MITM could supply a crafted userinfo
// response the server would blindly trust. When the id_token is
// missing from the token response (non-compliant provider) we still
// reject — an Authorization Code flow MUST return one per OIDC Core.
if (!tokenData.id_token) {
console.error('[OIDC] Token response missing id_token — refusing login');
return res.redirect(frontendUrl('/login?oidc_error=no_id_token'));
}
const idVerify = await verifyIdToken(
tokenData.id_token,
doc,
config.clientId,
config.issuer,
);
if (idVerify.ok !== true) {
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
console.error('[OIDC] id_token verification failed:', reason);
return res.redirect(frontendUrl('/login?oidc_error=id_token_invalid'));
}
const userInfo = await getUserInfo(doc.userinfo_endpoint, tokenData.access_token);
if (!userInfo.email) {
return res.redirect(frontendUrl('/login?oidc_error=no_email'));
}
// Cross-check: the userinfo response must be for the same subject
// the id_token signed. Catches a compromised userinfo endpoint that
// speaks for a different principal than the id_token's claim.
const tokenSub = idVerify.claims.sub;
if (typeof tokenSub === 'string' && userInfo.sub && userInfo.sub !== tokenSub) {
console.error('[OIDC] userinfo.sub does not match id_token.sub — refusing login');
return res.redirect(frontendUrl('/login?oidc_error=subject_mismatch'));
}
const result = findOrCreateUser(userInfo, config, pending.inviteToken);
if ('error' in result) {
@@ -126,7 +157,7 @@ router.get('/exchange', (req: Request, res: Response) => {
const result = consumeAuthCode(code);
if ('error' in result) return res.status(400).json({ error: result.error });
setAuthCookie(res, result.token);
setAuthCookie(res, result.token, req);
res.json({ token: result.token });
});
+76 -1
View File
@@ -207,6 +207,81 @@ function startTripReminders(): void {
}, { timezone: tz });
}
// Todo due-date reminders: daily check at 9 AM for unchecked todos
// whose due_date falls within the next TODO_REMINDER_LEAD_DAYS days.
// Each todo gets reminded at most once per 24 h (tracked via
// todo_items.reminded_at) so the scheduler doesn't spam the user every
// morning leading up to the deadline.
const TODO_REMINDER_LEAD_DAYS = 3;
let todoReminderTask: ScheduledTask | null = null;
function startTodoReminders(): void {
if (todoReminderTask) { todoReminderTask.stop(); todoReminderTask = null; }
const { db } = require('./db/database');
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
const enabled = getSetting('notify_todo_due') !== 'false';
if (!enabled) {
const { logInfo: li } = require('./services/auditLog');
li('Todo due reminders: disabled in settings');
return;
}
const { logInfo: liSetup } = require('./services/auditLog');
liSetup(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`);
const tz = process.env.TZ || 'UTC';
todoReminderTask = cron.schedule('0 9 * * *', async () => {
try {
const { send } = require('./services/notificationService');
// Select unchecked todos with a due date inside the lead window
// that haven't been reminded in the last 24 hours. `due_date` is
// stored as a YYYY-MM-DD text; SQLite date() handles it directly.
const todos = db.prepare(`
SELECT ti.id, ti.trip_id, ti.name, ti.due_date, ti.assigned_user_id,
t.title AS trip_title, t.user_id AS trip_owner_id
FROM todo_items ti
JOIN trips t ON t.id = ti.trip_id
WHERE ti.checked = 0
AND ti.due_date IS NOT NULL
AND ti.due_date <> ''
AND date(ti.due_date) <= date('now', '+' || ? || ' days')
AND date(ti.due_date) >= date('now')
AND (ti.reminded_at IS NULL OR ti.reminded_at <= datetime('now', '-20 hours'))
`).all(TODO_REMINDER_LEAD_DAYS) as {
id: number; trip_id: number; name: string; due_date: string;
assigned_user_id: number | null; trip_title: string; trip_owner_id: number;
}[];
for (const todo of todos) {
const targetScope: 'user' | 'trip' = todo.assigned_user_id ? 'user' : 'trip';
const targetId = todo.assigned_user_id ?? todo.trip_id;
await send({
event: 'todo_due',
actorId: null,
scope: targetScope,
targetId,
params: {
todo: todo.name,
trip: todo.trip_title,
tripId: String(todo.trip_id),
due: todo.due_date,
},
}).catch(() => {});
db.prepare('UPDATE todo_items SET reminded_at = CURRENT_TIMESTAMP WHERE id = ?').run(todo.id);
}
const { logInfo: li } = require('./services/auditLog');
if (todos.length > 0) {
li(`Todo reminders sent for ${todos.length} item(s)`);
}
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Todo reminder check failed: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
}
// Version check: daily at 9 AM — notify admins if a new TREK release is available
let versionCheckTask: ScheduledTask | null = null;
@@ -280,4 +355,4 @@ function stop(): void {
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
}
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
+283 -23
View File
@@ -15,7 +15,9 @@ import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from './apiKe
import { createEphemeralToken } from './ephemeralTokens';
import { revokeUserSessions } from '../mcp';
import { startTripReminders } from '../scheduler';
import { verifyJwtAndLoadUser } from '../middleware/auth';
import { User } from '../types';
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
// ---------------------------------------------------------------------------
// Constants
@@ -156,9 +158,12 @@ export function isOidcOnlyMode(): boolean {
return !resolveAuthToggles().password_login;
}
export function generateToken(user: { id: number | bigint }) {
export function generateToken(user: { id: number | bigint; password_version?: number }) {
const pv = typeof user.password_version === 'number'
? user.password_version
: ((db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0);
return jwt.sign(
{ id: user.id },
{ id: user.id, pv },
JWT_SECRET,
{ expiresIn: '24h', algorithm: 'HS256' }
);
@@ -172,10 +177,46 @@ export function normalizeBackupCode(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
}
// Legacy SHA-256 hex hash. Kept so existing stored hashes (from before
// the bcrypt migration) can still be verified in `matchBackupCode`
// without forcing every user to re-enrol their MFA device. New hashes
// are produced by `hashBackupCodeBcrypt` below.
export function hashBackupCode(input: string): string {
return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex');
}
const BCRYPT_BACKUP_COST = 10;
/**
* Hash a backup code with bcrypt for at-rest storage. Backup codes only
* have ~40 bits of entropy (8 hex chars) so a plain SHA-256 rainbow
* table cracks them in minutes if the DB ever leaks. bcrypt with a
* moderate cost raises that cost by ~3-4 orders of magnitude.
*/
export function hashBackupCodeBcrypt(input: string): string {
return bcrypt.hashSync(normalizeBackupCode(input), BCRYPT_BACKUP_COST);
}
/**
* Constant-time match of a plaintext backup code against a stored hash
* in either format (bcrypt or legacy SHA-256 hex). Used by login and
* password-reset flows; callers that need to CONSUME the matching
* entry should use this to find the index, then splice it out.
*/
export function matchBackupCode(plaintext: string, storedHash: string): boolean {
if (!storedHash) return false;
if (storedHash.startsWith('$2')) {
// bcrypt hash — compareSync is constant-time internally.
try { return bcrypt.compareSync(normalizeBackupCode(plaintext), storedHash); }
catch { return false; }
}
// Legacy SHA-256 hex. Compare the SHA-256 of the input against the
// stored hex with a constant-time comparator so timing can't leak.
const candidate = hashBackupCode(plaintext);
if (candidate.length !== storedHash.length) return false;
return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(storedHash));
}
export function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] {
const codes: string[] = [];
while (codes.length < count) {
@@ -257,7 +298,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
require_mfa: requireMfaRow?.value === 'true',
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
demo_mode: isDemo,
demo_email: isDemo ? 'demo@trek.app' : undefined,
demo_email: isDemo ? DEMO_EMAIL_PRIMARY : undefined,
demo_password: isDemo ? 'demo12345' : undefined,
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
notification_channel: notifChannel,
@@ -280,7 +321,7 @@ export function demoLogin(): { error?: string; status?: number; token?: string;
if (process.env.DEMO_MODE !== 'true') {
return { error: 'Not found', status: 404 };
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@trek.app') as User | undefined;
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(DEMO_EMAIL_PRIMARY) as User | undefined;
if (!user) return { error: 'Demo user not found', status: 500 };
const token = generateToken(user);
const safe = stripUserForClient(user) as Record<string, unknown>;
@@ -455,7 +496,7 @@ export function changePassword(
if (isOidcOnlyMode()) {
return { error: 'Password authentication is disabled.', status: 403 };
}
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@trek.app') {
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
return { error: 'Password change is disabled in demo mode.', status: 403 };
}
@@ -477,7 +518,7 @@ export function changePassword(
}
export function deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } {
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@trek.app') {
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
return { error: 'Account deletion is disabled in demo mode.', status: 403 };
}
if (userRole === 'admin') {
@@ -597,11 +638,13 @@ export function getSettings(userId: number): { error?: string; status?: number;
// Avatar
// ---------------------------------------------------------------------------
export function saveAvatar(userId: number, filename: string) {
export async function saveAvatar(userId: number, filename: string) {
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(userId) as { avatar: string | null } | undefined;
if (current && current.avatar) {
// Fire-and-forget: leftover files are harmless; the DB update is
// the source of truth for which avatar is current.
const oldPath = path.join(avatarDir, current.avatar);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
await fs.promises.rm(oldPath, { force: true }).catch(() => {});
}
db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, userId);
@@ -610,11 +653,11 @@ export function saveAvatar(userId: number, filename: string) {
return { success: true, avatar_url: avatarUrl(updated || {}) };
}
export function deleteAvatar(userId: number) {
export async function deleteAvatar(userId: number) {
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(userId) as { avatar: string | null } | undefined;
if (current && current.avatar) {
const filePath = path.join(avatarDir, current.avatar);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
await fs.promises.rm(filePath, { force: true }).catch(() => {});
}
db.prepare('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
return { success: true };
@@ -862,7 +905,7 @@ export function getTravelStats(userId: number) {
// ---------------------------------------------------------------------------
export function setupMfa(userId: number, userEmail: string): { error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise<string> } {
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@nomad.app') {
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
return { error: 'MFA is not available in demo mode.', status: 403 };
}
const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined;
@@ -895,7 +938,7 @@ export function enableMfa(userId: number, code?: string): { error?: string; stat
return { error: 'Invalid verification code', status: 401 };
}
const backupCodes = generateBackupCodes();
const backupHashes = backupCodes.map(hashBackupCode);
const backupHashes = backupCodes.map(hashBackupCodeBcrypt);
const enc = encryptMfaSecret(pending);
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
enc,
@@ -911,7 +954,7 @@ export function disableMfa(
userEmail: string,
body: { password?: string; code?: string }
): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean } {
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@nomad.app') {
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
return { error: 'MFA cannot be changed in demo mode.', status: 403 };
}
const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
@@ -970,8 +1013,9 @@ export function verifyMfaLogin(body: {
const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
if (!okTotp) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
const candidateHash = hashBackupCode(tokenStr);
const idx = hashes.findIndex(h => h === candidateHash);
// matchBackupCode handles both bcrypt and legacy SHA-256 hashes;
// any store older than the bcrypt migration keeps working.
const idx = hashes.findIndex((h) => matchBackupCode(tokenStr, h));
if (idx === -1) {
return { error: 'Invalid verification code', status: 401 };
}
@@ -994,6 +1038,219 @@ export function verifyMfaLogin(body: {
}
}
// ---------------------------------------------------------------------------
// Password reset
// ---------------------------------------------------------------------------
// 60 min; long enough to read the email in a second tab, short enough
// that a leaked link is unlikely to still be valid when someone tries it.
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
const PASSWORD_RESET_TOKEN_BYTES = 32; // 256-bit entropy
/**
* Returns the SHA-256 hex hash of a reset token. Raw tokens are never
* persisted we only store and compare their hashes.
*/
function hashResetToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
/**
* Shape returned by requestPasswordReset. For enumeration-safety the
* route ALWAYS returns the same response to the client regardless of
* whether a user existed this struct is only consumed internally by
* the route handler to decide whether to send an email / log a link.
*/
export interface PasswordResetRequestOutcome {
tokenForDelivery: string | null; // raw token — send via email or log, never return to client
userId: number | null;
userEmail: string | null;
reason: 'issued' | 'no_user' | 'oidc_only' | 'throttled_per_email' | 'password_login_disabled';
}
// Per-email throttle (defence-in-depth on top of the per-IP limiter).
const perEmailResetAttempts = new Map<string, { count: number; first: number }>();
const PASSWORD_RESET_PER_EMAIL_WINDOW_MS = 15 * 60 * 1000;
const PASSWORD_RESET_PER_EMAIL_MAX = 3;
setInterval(() => {
const now = Date.now();
for (const [key, record] of perEmailResetAttempts) {
if (now - record.first >= PASSWORD_RESET_PER_EMAIL_WINDOW_MS) perEmailResetAttempts.delete(key);
}
}, 5 * 60 * 1000).unref?.();
export function requestPasswordReset(rawEmail: string, createdIp: string | null): PasswordResetRequestOutcome {
const email = String(rawEmail || '').trim().toLowerCase();
// Basic shape check — a fully empty / malformed email is treated like
// "no user" so we still spend the same time internally.
const looksLikeEmail = email.length > 0 && /.+@.+\..+/.test(email);
// Global policy check: password login disabled → no reset possible.
const toggles = resolveAuthToggles();
if (!toggles.password_login) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'password_login_disabled' };
}
// Per-email throttle. We check this BEFORE the DB lookup so the timing
// is identical regardless of whether the account exists.
const throttleKey = email || '__noemail__';
const now = Date.now();
const record = perEmailResetAttempts.get(throttleKey);
if (record && record.count >= PASSWORD_RESET_PER_EMAIL_MAX && now - record.first < PASSWORD_RESET_PER_EMAIL_WINDOW_MS) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'throttled_per_email' };
}
if (!record || now - record.first >= PASSWORD_RESET_PER_EMAIL_WINDOW_MS) {
perEmailResetAttempts.set(throttleKey, { count: 1, first: now });
} else {
record.count++;
}
if (!looksLikeEmail) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' };
}
const user = db.prepare('SELECT id, email, password_hash, oidc_sub FROM users WHERE email = ?').get(email) as
| { id: number; email: string; password_hash: string | null; oidc_sub: string | null }
| undefined;
if (!user) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' };
}
// OIDC-only account (no local password) — we can't reset what isn't there.
// The client still gets the generic "if that email exists…" response.
if (!user.password_hash && user.oidc_sub) {
return { tokenForDelivery: null, userId: user.id, userEmail: user.email, reason: 'oidc_only' };
}
// Invalidate any prior unconsumed tokens for this user so there is
// always at most one live reset link in flight.
db.prepare(
"UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE user_id = ? AND consumed_at IS NULL"
).run(user.id);
const raw = randomBytes(PASSWORD_RESET_TOKEN_BYTES).toString('base64url');
const token_hash = hashResetToken(raw);
const expires_at = new Date(Date.now() + PASSWORD_RESET_TTL_MS).toISOString();
db.prepare(
'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_ip) VALUES (?, ?, ?, ?)'
).run(user.id, token_hash, expires_at, createdIp);
return { tokenForDelivery: raw, userId: user.id, userEmail: user.email, reason: 'issued' };
}
export interface ResetPasswordOutcome {
error?: string;
status?: number;
success?: boolean;
/** When true the client must collect a TOTP/backup code and call again. */
mfa_required?: boolean;
userId?: number;
}
/**
* Consume a reset token and set a new password. If the target user has
* MFA enabled, a valid TOTP code or backup code must be supplied a
* compromised email alone therefore does NOT allow taking over a
* 2FA-protected account.
*/
export function resetPassword(body: {
token?: string;
new_password?: string;
mfa_code?: string;
}): ResetPasswordOutcome {
const { token, new_password, mfa_code } = body;
if (!token || typeof token !== 'string') {
return { error: 'Reset token is required', status: 400 };
}
if (!new_password || typeof new_password !== 'string') {
return { error: 'New password is required', status: 400 };
}
// Check the policy BEFORE touching the token so an invalid password
// does not burn the user's one-time link.
const pwCheck = validatePassword(new_password);
if (!pwCheck.ok) return { error: pwCheck.reason!, status: 400 };
const tokenHash = hashResetToken(token);
const row = db.prepare(
'SELECT id, user_id, expires_at, consumed_at FROM password_reset_tokens WHERE token_hash = ?'
).get(tokenHash) as
| { id: number; user_id: number; expires_at: string; consumed_at: string | null }
| undefined;
if (!row) return { error: 'Invalid or expired reset link', status: 400 };
if (row.consumed_at) return { error: 'This reset link has already been used', status: 400 };
if (new Date(row.expires_at).getTime() < Date.now()) {
return { error: 'Reset link has expired. Please request a new one.', status: 400 };
}
const user = db.prepare(
'SELECT id, email, mfa_enabled, mfa_secret, mfa_backup_codes, password_version FROM users WHERE id = ?'
).get(row.user_id) as
| { id: number; email: string; mfa_enabled: number | boolean; mfa_secret: string | null; mfa_backup_codes: string | null; password_version: number }
| undefined;
if (!user) return { error: 'Invalid or expired reset link', status: 400 };
// MFA gate. If enabled, require a valid TOTP or backup code.
const mfaOn = user.mfa_enabled === 1 || user.mfa_enabled === true;
let backupCodeConsumedIndex: number | null = null;
if (mfaOn) {
if (!user.mfa_secret) {
// Data inconsistency — fail closed.
return { error: 'MFA is enabled but not configured. Contact your administrator.', status: 500 };
}
const supplied = typeof mfa_code === 'string' ? mfa_code.trim() : '';
if (!supplied) return { mfa_required: true, status: 200 };
const secret = decryptMfaSecret(user.mfa_secret);
const okTotp = authenticator.verify({ token: supplied.replace(/\s/g, ''), secret });
if (!okTotp) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
const idx = hashes.findIndex((h) => matchBackupCode(supplied, h));
if (idx === -1) return { error: 'Invalid MFA code', status: 401 };
backupCodeConsumedIndex = idx;
}
}
const newHash = bcrypt.hashSync(new_password, 12);
const newPv = (user.password_version ?? 0) + 1;
db.transaction(() => {
// Burn the token first to keep it atomic with the password change.
db.prepare('UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id);
// Also burn every OTHER live token for this user — a fresh login
// should not leave a second door open.
db.prepare(
"UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE user_id = ? AND consumed_at IS NULL AND id != ?"
).run(user.id, row.id);
db.prepare(
'UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(newHash, newPv, user.id);
// Consume backup code if one was used.
if (backupCodeConsumedIndex !== null) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
hashes.splice(backupCodeConsumedIndex, 1);
db.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?').run(JSON.stringify(hashes), user.id);
}
// Revoke every other credential class the user had. The
// password_version bump alone invalidates JWT cookie sessions, but
// MCP static tokens and OAuth 2.1 bearer tokens are separate stores
// that survive the bump unless we prune them here.
db.prepare('DELETE FROM mcp_tokens WHERE user_id = ?').run(user.id);
try {
db.prepare(
"UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL"
).run(user.id);
} catch { /* oauth_tokens table may not exist in very old installs */ }
})();
// Kick off any MCP/WS session cleanup — same hook the account-delete path uses.
try { revokeUserSessions?.(user.id); } catch { /* best-effort */ }
return { success: true, userId: user.id };
}
// ---------------------------------------------------------------------------
// MCP tokens
// ---------------------------------------------------------------------------
@@ -1060,7 +1317,7 @@ export function createResourceToken(userId: number, purpose?: string): { error?:
export function isDemoUser(userId: number): boolean {
if (process.env.DEMO_MODE !== 'true') return false;
const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
return user?.email === 'demo@nomad.app';
return isDemoEmail(user?.email);
}
export function verifyMcpToken(rawToken: string): User | null {
@@ -1078,12 +1335,15 @@ export function verifyMcpToken(rawToken: string): User | null {
return null;
}
/**
* Verify a JWT the same way `middleware/auth.ts#verifyJwtAndLoadUser`
* does including the `password_version` check so that stolen tokens
* lose access the moment the victim resets their password.
*
* This is the single entry point every non-cookie JWT verification path
* (MCP bearer, WebSocket handshake, file-download query tokens, photo
* route) should go through.
*/
export function verifyJwtToken(token: string): User | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(decoded.id) as User | undefined;
return user || null;
} catch {
return null;
}
return verifyJwtAndLoadUser(token);
}
+14
View File
@@ -5,6 +5,7 @@ import fs from 'fs';
import Database from 'better-sqlite3';
import { db, closeDb, reinitialize } from '../db/database';
import * as scheduler from '../scheduler';
import { invalidatePermissionsCache } from './permissions';
// ---------------------------------------------------------------------------
// Paths
@@ -246,6 +247,12 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
}
} finally {
reinitialize();
// The restored DB has different permission-override rows from
// the pre-restore DB, but our process-local permissions cache
// still holds the pre-restore state. Any request using a cached
// permission would decide against the wrong grants until the
// next restart. Dropping the cache forces a fresh read.
invalidatePermissionsCache();
}
fs.rmSync(extractDir, { recursive: true, force: true });
@@ -253,6 +260,13 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
} catch (err: unknown) {
console.error('Restore error:', err);
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true });
// Belt-and-braces: the inner `finally` already drops the permissions
// cache after a successful swap, but if the extraction/copy step
// itself threw before the DB swap even started, the cache wasn't
// stale anyway. Invalidating here too costs nothing and guarantees
// we never serve cached permissions that don't match the DB state
// we leave the process in after a failed restore.
try { invalidatePermissionsCache(); } catch { /* best-effort */ }
throw err;
}
}
+30 -7
View File
@@ -1,9 +1,32 @@
import { Response } from 'express';
import { Request, Response } from 'express';
const COOKIE_NAME = 'trek_session';
export function cookieOptions(clear = false) {
const secure = process.env.COOKIE_SECURE !== 'false' && (process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true');
/**
* Decide whether the session cookie should carry the `Secure` flag.
*
* We previously only derived this from `NODE_ENV=production` or
* `FORCE_HTTPS=true`. That left behind a common self-host setup:
* TREK running behind Traefik / Caddy / Cloudflare Tunnel with
* `NODE_ENV=development` locally and no `FORCE_HTTPS` the cookie
* went out without `Secure`, even though the public leg was https.
*
* Now we also honour `req.secure`, which Express derives from
* `X-Forwarded-Proto` once `trust proxy` is set (TREK sets it to `1`
* in production automatically). If Express sees the request was TLS
* on the outermost hop, the cookie is `Secure`. `COOKIE_SECURE=false`
* remains the explicit escape hatch for plain-HTTP LAN testing.
*/
export function cookieOptions(clear = false, req?: Request) {
if (process.env.COOKIE_SECURE === 'false') {
return buildOptions(clear, false);
}
const envSecure = process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true';
const requestSecure = req?.secure === true;
return buildOptions(clear, envSecure || requestSecure);
}
function buildOptions(clear: boolean, secure: boolean) {
return {
httpOnly: true,
secure,
@@ -13,10 +36,10 @@ export function cookieOptions(clear = false) {
};
}
export function setAuthCookie(res: Response, token: string): void {
res.cookie(COOKIE_NAME, token, cookieOptions());
export function setAuthCookie(res: Response, token: string, req?: Request): void {
res.cookie(COOKIE_NAME, token, cookieOptions(false, req));
}
export function clearAuthCookie(res: Response): void {
res.clearCookie(COOKIE_NAME, cookieOptions(true));
export function clearAuthCookie(res: Response, req?: Request): void {
res.clearCookie(COOKIE_NAME, cookieOptions(true, req));
}
+6 -4
View File
@@ -166,7 +166,7 @@ export function deleteDay(id: string | number) {
export interface DayAccommodation {
id: number;
trip_id: number;
place_id: number;
place_id: number | null;
start_day_id: number;
end_day_id: number;
check_in: string | null;
@@ -180,7 +180,7 @@ function getAccommodationWithPlace(id: number | bigint) {
return db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a
JOIN places p ON a.place_id = p.id
LEFT JOIN places p ON a.place_id = p.id
WHERE a.id = ?
`).get(id);
}
@@ -191,9 +191,11 @@ function getAccommodationWithPlace(id: number | bigint) {
export function listAccommodations(tripId: string | number) {
return db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng,
r.title as reservation_title
FROM day_accommodations a
JOIN places p ON a.place_id = p.id
LEFT JOIN places p ON a.place_id = p.id
LEFT JOIN reservations r ON r.accommodation_id = a.id
WHERE a.trip_id = ?
ORDER BY a.created_at ASC
`).all(tripId);
+24
View File
@@ -0,0 +1,24 @@
// Central registry of demo-user email addresses.
//
// Historical: the demo account was seeded as "demo@trek.app" (see
// authService.demoLogin), but several guards — demoUploadBlock in
// middleware/auth.ts, the MFA/backup-code bypasses in authService —
// were still checking the pre-rename "demo@nomad.app" string, so they
// either never fired or silently diverged between call sites. Routing
// every check through this constant keeps them aligned.
export const DEMO_EMAIL_PRIMARY = 'demo@trek.app';
/**
* All email addresses that should be treated as the demo account.
* Includes the historical `demo@nomad.app` identifier so instances that
* upgraded in place without resetting the DB still hit demo-mode guards.
*/
export const DEMO_EMAILS: ReadonlySet<string> = new Set([
DEMO_EMAIL_PRIMARY,
'demo@nomad.app',
]);
export function isDemoEmail(email: string | null | undefined): boolean {
return !!email && DEMO_EMAILS.has(email);
}
+46 -18
View File
@@ -1,9 +1,8 @@
import path from 'path';
import fs from 'fs';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from '../config';
import { db, canAccessTrip } from '../db/database';
import { consumeEphemeralToken } from './ephemeralTokens';
import { verifyJwtAndLoadUser } from '../middleware/auth';
import { TripFile } from '../types';
// ---------------------------------------------------------------------------
@@ -12,7 +11,18 @@ import { TripFile } from '../types';
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass';
export const BLOCKED_EXTENSIONS = ['.svg', '.html', '.htm', '.xml'];
// Single authoritative blocklist for every file-upload surface (main
// file manager + collab attachments). When the admin setting
// `allowed_file_types` is `*`, this list is still enforced so the
// wildcard doesn't silently admit executables/scripts.
export const BLOCKED_EXTENSIONS = [
// Server-rendered / scripted content that could XSS a viewer
'.svg', '.html', '.htm', '.xml', '.xhtml',
// Scripts
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.php', '.py', '.rb', '.pl',
// Executables
'.exe', '.bat', '.sh', '.cmd', '.msi', '.dll', '.com', '.vbs', '.ps1', '.app',
];
export const filesDir = path.join(__dirname, '../../uploads/files');
// ---------------------------------------------------------------------------
@@ -68,12 +78,12 @@ export function authenticateDownload(bearerToken: string | undefined, queryToken
}
if (bearerToken) {
try {
const decoded = jwt.verify(bearerToken, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
return { userId: decoded.id };
} catch {
return { error: 'Invalid or expired token', status: 401 };
}
// Use the shared helper so the password_version gate applies here too;
// previously this bypassed the check and stolen download tokens stayed
// valid across a password reset.
const user = verifyJwtAndLoadUser(bearerToken);
if (!user) return { error: 'Invalid or expired token', status: 401 };
return { userId: user.id };
}
const uid = consumeEphemeralToken(queryToken!, 'download');
@@ -193,24 +203,42 @@ export function restoreFile(id: string | number) {
return formatFile(restored);
}
export function permanentDeleteFile(file: TripFile) {
export async function permanentDeleteFile(file: TripFile): Promise<void> {
const { resolved } = resolveFilePath(file.filename);
if (fs.existsSync(resolved)) {
try { fs.unlinkSync(resolved); } catch (e) { console.error('Error deleting file:', e); }
// `force: true` swallows ENOENT, replacing the prior existsSync+unlink
// double-call that blocked the event loop twice per deletion. Only
// drop the DB row when the on-disk unlink either succeeded or the
// file was already gone — otherwise a permission / ENOSPC failure
// would orphan the bytes on disk with no DB pointer left to clean it.
try {
await fs.promises.rm(resolved, { force: true });
} catch (e) {
console.error(`[files] unlink failed for ${file.filename}, keeping DB row:`, e);
throw e;
}
db.prepare('DELETE FROM trip_files WHERE id = ?').run(file.id);
}
export function emptyTrash(tripId: string | number): number {
export async function emptyTrash(tripId: string | number): Promise<number> {
const trashed = db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').all(tripId) as TripFile[];
for (const file of trashed) {
// Collect successful IDs separately so we only DELETE rows whose disk
// content was actually removed — failing unlinks keep their DB row
// and a retry via the single-file delete path can try again.
const successfullyUnlinked: number[] = [];
await Promise.all(trashed.map(async (file) => {
const { resolved } = resolveFilePath(file.filename);
if (fs.existsSync(resolved)) {
try { fs.unlinkSync(resolved); } catch (e) { console.error('Error deleting file:', e); }
try {
await fs.promises.rm(resolved, { force: true });
successfullyUnlinked.push(Number(file.id));
} catch (e) {
console.error(`[files] unlink failed for ${file.filename}, keeping DB row:`, e);
}
}));
if (successfullyUnlinked.length > 0) {
const placeholders = successfullyUnlinked.map(() => '?').join(',');
db.prepare(`DELETE FROM trip_files WHERE id IN (${placeholders})`).run(...successfullyUnlinked);
}
db.prepare('DELETE FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').run(tripId);
return trashed.length;
return successfullyUnlinked.length;
}
// ---------------------------------------------------------------------------
+11 -3
View File
@@ -648,6 +648,12 @@ export async function getPlacePhoto(
return null;
}
// Reject URL-shaped placeIds — legacy DBs may store raw photo URLs in image_url
if (/^https?:\/\//i.test(placeId)) {
placePhotoCache.markError(placeId);
return null;
}
// Google Photos — fetch details to get photo name
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
headers: {
@@ -655,13 +661,15 @@ export async function getPlacePhoto(
'X-Goog-FieldMask': 'photos',
},
});
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
const body = await detailsRes.text();
if (!detailsRes.ok) {
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
placePhotoCache.markError(placeId);
return null;
}
let details: GooglePlaceDetails & { error?: { message?: string } };
try { details = body ? JSON.parse(body) : { photos: [] }; }
catch { placePhotoCache.markError(placeId); return null; }
if (!details.photos?.length) {
placePhotoCache.markError(placeId);
@@ -679,8 +679,10 @@ export async function streamSynologyAsset(
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
// Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files
// (original uses xl size to get a full-resolution JPEG-compatible render)
const resolvedSize = kind === 'original' ? 'xl' : (size || 'sm');
// (original uses xl size to get a full-resolution JPEG-compatible render).
// Thumbnail default is 'm' (~320px) — 'sm' (240px) looked pixelated on
// the journey grid on retina screens.
const resolvedSize = kind === 'original' ? 'xl' : (size || 'm');
const params = new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
method: 'get',
@@ -9,6 +9,7 @@ export type NotifEventType =
| 'trip_invite'
| 'booking_change'
| 'trip_reminder'
| 'todo_due'
| 'vacay_invite'
| 'photos_shared'
| 'collab_message'
@@ -29,6 +30,7 @@ const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
trip_invite: ['inapp', 'email', 'webhook', 'ntfy'],
booking_change: ['inapp', 'email', 'webhook', 'ntfy'],
trip_reminder: ['inapp', 'email', 'webhook', 'ntfy'],
todo_due: ['inapp', 'email', 'webhook', 'ntfy'],
vacay_invite: ['inapp', 'email', 'webhook', 'ntfy'],
photos_shared: ['inapp', 'email', 'webhook', 'ntfy'],
collab_message: ['inapp', 'email', 'webhook', 'ntfy'],
@@ -82,6 +82,13 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
navigateTextKey: 'notif.action.view_trip',
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
},
todo_due: {
inAppType: 'navigate',
titleKey: 'notif.todo_due.title',
textKey: 'notif.todo_due.text',
navigateTextKey: 'notif.action.view_trip',
navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null),
},
vacay_invite: {
inAppType: 'navigate',
titleKey: 'notif.vacay_invite.title',
+112
View File
@@ -100,6 +100,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Trip invite: "${p.trip}"`, body: `${p.actor} invited ${p.invitee || 'a member'} to the trip "${p.trip}".` }),
booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }),
trip_reminder: p => ({ title: `Trip reminder: ${p.trip}`, body: `Your trip "${p.trip}" is coming up soon!` }),
todo_due: p => ({ title: `To-do due: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" is due on ${p.due}.` }),
vacay_invite: p => ({ title: 'Vacay Fusion Invite', body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.` }),
photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }),
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -111,6 +112,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }),
booking_change: p => ({ title: `Neue Buchung: ${p.booking}`, body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.` }),
trip_reminder: p => ({ title: `Reiseerinnerung: ${p.trip}`, body: `Deine Reise "${p.trip}" steht bald an!` }),
todo_due: p => ({ title: `Aufgabe fällig: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" ist am ${p.due} fällig.` }),
vacay_invite: p => ({ title: 'Vacay Fusion-Einladung', body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.` }),
photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }),
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -122,6 +124,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }),
booking_change: p => ({ title: `Nouvelle réservation : ${p.booking}`, body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".` }),
trip_reminder: p => ({ title: `Rappel de voyage : ${p.trip}`, body: `Votre voyage "${p.trip}" approche !` }),
todo_due: p => ({ title: `Tâche à échéance : ${p.todo}`, body: `"${p.todo}" dans "${p.trip}" est due le ${p.due}.` }),
vacay_invite: p => ({ title: 'Invitation Vacay Fusion', body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.` }),
photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }),
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
@@ -133,6 +136,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }),
booking_change: p => ({ title: `Nueva reserva: ${p.booking}`, body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".` }),
trip_reminder: p => ({ title: `Recordatorio: ${p.trip}`, body: `¡Tu viaje "${p.trip}" se acerca!` }),
todo_due: p => ({ title: `Tarea pendiente: ${p.todo}`, body: `"${p.todo}" en "${p.trip}" vence el ${p.due}.` }),
vacay_invite: p => ({ title: 'Invitación Vacay Fusion', body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.` }),
photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }),
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -144,6 +148,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }),
booking_change: p => ({ title: `Nieuwe boeking: ${p.booking}`, body: `${p.actor} heeft een boeking "${p.booking}" (${p.type}) toegevoegd aan "${p.trip}".` }),
trip_reminder: p => ({ title: `Reisherinnering: ${p.trip}`, body: `Je reis "${p.trip}" komt eraan!` }),
todo_due: p => ({ title: `Taak verloopt: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" verloopt op ${p.due}.` }),
vacay_invite: p => ({ title: 'Vacay Fusion uitnodiging', body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.` }),
photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }),
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -155,6 +160,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }),
booking_change: p => ({ title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".` }),
trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }),
todo_due: p => ({ title: `Задача к сроку: ${p.todo}`, body: `"${p.todo}" в поездке "${p.trip}" — срок ${p.due}.` }),
vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }),
photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }),
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -166,6 +172,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }),
booking_change: p => ({ title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"${p.type})。` }),
trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }),
todo_due: p => ({ title: `待办事项即将到期:${p.todo}`, body: `"${p.trip}" 中的"${p.todo}"将于 ${p.due} 到期。` }),
vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }),
photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }),
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}${p.preview}` }),
@@ -177,6 +184,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `邀請加入「${p.trip}`, body: `${p.actor} 邀請了 ${p.invitee || '成員'} 加入行程「${p.trip}」。` }),
booking_change: p => ({ title: `新預訂:${p.booking}`, body: `${p.actor} 在「${p.trip}」中新增了預訂「${p.booking}」(${p.type})。` }),
trip_reminder: p => ({ title: `行程提醒:${p.trip}`, body: `您的行程「${p.trip}」即將開始!` }),
todo_due: p => ({ title: `待辦事項即將到期:${p.todo}`, body: `${p.trip}」中的「${p.todo}」將於 ${p.due} 到期。` }),
vacay_invite: p => ({ title: 'Vacay 融合邀請', body: `${p.actor} 邀請您合併假期計畫。開啟 TREK 以接受或拒絕。` }),
photos_shared: p => ({ title: `已分享 ${p.count} 張照片`, body: `${p.actor} 在「${p.trip}」中分享了 ${p.count} 張照片。` }),
collab_message: p => ({ title: `${p.trip}」中的新訊息`, body: `${p.actor}${p.preview}` }),
@@ -188,6 +196,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }),
booking_change: p => ({ title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".` }),
trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }),
todo_due: p => ({ title: `مهمة مستحقة: ${p.todo}`, body: `"${p.todo}" في "${p.trip}" مستحقة في ${p.due}.` }),
vacay_invite: p => ({ title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.` }),
photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }),
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -199,6 +208,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Convite para "${p.trip}"`, body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".` }),
booking_change: p => ({ title: `Nova reserva: ${p.booking}`, body: `${p.actor} adicionou uma reserva "${p.booking}" (${p.type}) em "${p.trip}".` }),
trip_reminder: p => ({ title: `Lembrete: ${p.trip}`, body: `Sua viagem "${p.trip}" está chegando!` }),
todo_due: p => ({ title: `Tarefa com vencimento: ${p.todo}`, body: `"${p.todo}" em "${p.trip}" vence em ${p.due}.` }),
vacay_invite: p => ({ title: 'Convite Vacay Fusion', body: `${p.actor} convidou você para fundir planos de férias. Abra o TREK para aceitar ou recusar.` }),
photos_shared: p => ({ title: `${p.count} fotos compartilhadas`, body: `${p.actor} compartilhou ${p.count} foto(s) em "${p.trip}".` }),
collab_message: p => ({ title: `Nova mensagem em "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -210,6 +220,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Pozvánka do "${p.trip}"`, body: `${p.actor} pozval ${p.invitee || 'člena'} na výlet "${p.trip}".` }),
booking_change: p => ({ title: `Nová rezervace: ${p.booking}`, body: `${p.actor} přidal rezervaci "${p.booking}" (${p.type}) k "${p.trip}".` }),
trip_reminder: p => ({ title: `Připomínka výletu: ${p.trip}`, body: `Váš výlet "${p.trip}" se blíží!` }),
todo_due: p => ({ title: `Úkol se blíží: ${p.todo}`, body: `"${p.todo}" ve výletě "${p.trip}" má termín ${p.due}.` }),
vacay_invite: p => ({ title: 'Pozvánka Vacay Fusion', body: `${p.actor} vás pozval ke spojení dovolenkových plánů. Otevřete TREK pro přijetí nebo odmítnutí.` }),
photos_shared: p => ({ title: `${p.count} sdílených fotek`, body: `${p.actor} sdílel ${p.count} foto v "${p.trip}".` }),
collab_message: p => ({ title: `Nová zpráva v "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -221,6 +232,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Meghívó a(z) "${p.trip}" utazásra`, body: `${p.actor} meghívta ${p.invitee || 'egy tagot'} a(z) "${p.trip}" utazásra.` }),
booking_change: p => ({ title: `Új foglalás: ${p.booking}`, body: `${p.actor} hozzáadott egy "${p.booking}" (${p.type}) foglalást a(z) "${p.trip}" utazáshoz.` }),
trip_reminder: p => ({ title: `Utazás emlékeztető: ${p.trip}`, body: `A(z) "${p.trip}" utazás hamarosan kezdődik!` }),
todo_due: p => ({ title: `Teendő esedékes: ${p.todo}`, body: `"${p.todo}" (${p.trip}) határideje: ${p.due}.` }),
vacay_invite: p => ({ title: 'Vacay Fusion meghívó', body: `${p.actor} meghívott a nyaralási tervek összevonásához. Nyissa meg a TREK-et az elfogadáshoz vagy elutasításhoz.` }),
photos_shared: p => ({ title: `${p.count} fotó megosztva`, body: `${p.actor} ${p.count} fotót osztott meg a(z) "${p.trip}" utazásban.` }),
collab_message: p => ({ title: `Új üzenet a(z) "${p.trip}" utazásban`, body: `${p.actor}: ${p.preview}` }),
@@ -232,6 +244,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Invito a "${p.trip}"`, body: `${p.actor} ha invitato ${p.invitee || 'un membro'} al viaggio "${p.trip}".` }),
booking_change: p => ({ title: `Nuova prenotazione: ${p.booking}`, body: `${p.actor} ha aggiunto una prenotazione "${p.booking}" (${p.type}) a "${p.trip}".` }),
trip_reminder: p => ({ title: `Promemoria viaggio: ${p.trip}`, body: `Il tuo viaggio "${p.trip}" si avvicina!` }),
todo_due: p => ({ title: `Attività in scadenza: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" scade il ${p.due}.` }),
vacay_invite: p => ({ title: 'Invito Vacay Fusion', body: `${p.actor} ti ha invitato a fondere i piani vacanza. Apri TREK per accettare o rifiutare.` }),
photos_shared: p => ({ title: `${p.count} foto condivise`, body: `${p.actor} ha condiviso ${p.count} foto in "${p.trip}".` }),
collab_message: p => ({ title: `Nuovo messaggio in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -243,6 +256,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Zaproszenie do "${p.trip}"`, body: `${p.actor} zaprosił ${p.invitee || 'członka'} do podróży "${p.trip}".` }),
booking_change: p => ({ title: `Nowa rezerwacja: ${p.booking}`, body: `${p.actor} dodał rezerwację "${p.booking}" (${p.type}) do "${p.trip}".` }),
trip_reminder: p => ({ title: `Przypomnienie o podróży: ${p.trip}`, body: `Twoja podróż "${p.trip}" zbliża się!` }),
todo_due: p => ({ title: `Zadanie z terminem: ${p.todo}`, body: `"${p.todo}" w "${p.trip}" — termin ${p.due}.` }),
vacay_invite: p => ({ title: 'Zaproszenie Vacay Fusion', body: `${p.actor} zaprosił Cię do połączenia planów urlopowych. Otwórz TREK, aby zaakceptować lub odrzucić.` }),
photos_shared: p => ({ title: `${p.count} zdjęć udostępnionych`, body: `${p.actor} udostępnił ${p.count} zdjęcie/zdjęcia w "${p.trip}".` }),
collab_message: p => ({ title: `Nowa wiadomość w "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -254,6 +268,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
trip_invite: p => ({ title: `Undangan perjalanan: "${p.trip}"`, body: `${p.actor} mengundang ${p.invitee || 'seorang anggota'} ke perjalanan "${p.trip}".` }),
booking_change: p => ({ title: `Pemesanan baru: ${p.booking}`, body: `${p.actor} menambahkan "${p.booking}" (${p.type}) baru ke "${p.trip}".` }),
trip_reminder: p => ({ title: `Pengingat perjalanan: ${p.trip}`, body: `Perjalanan Anda "${p.trip}" akan segera tiba!` }),
todo_due: p => ({ title: `Tugas jatuh tempo: ${p.todo}`, body: `"${p.todo}" di "${p.trip}" jatuh tempo pada ${p.due}.` }),
vacay_invite: p => ({ title: 'Undangan Penggabungan Vacay', body: `${p.actor} mengundang Anda untuk menggabungkan rencana liburan. Buka TREK untuk menerima atau menolak.` }),
photos_shared: p => ({ title: `${p.count} foto dibagikan`, body: `${p.actor} membagikan ${p.count} foto di "${p.trip}".` }),
collab_message: p => ({ title: `Pesan baru di "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
@@ -316,6 +331,103 @@ export function buildEmailHtml(subject: string, body: string, lang: string, navi
// ── Send functions ─────────────────────────────────────────────────────────
// ── Password reset email ───────────────────────────────────────────────────
interface PasswordResetStrings { subject: string; greeting: string; body: string; ctaIntro: string; expiry: string; ignore: string }
const PASSWORD_RESET_I18N: Record<string, PasswordResetStrings> = {
en: { subject: 'Reset your password', greeting: 'Hi', body: 'We received a request to reset the password for your TREK account. Click the button below to set a new password.', ctaIntro: 'Reset password', expiry: 'This link expires in 60 minutes.', ignore: "If you didn't request this, you can safely ignore this email — your password won't change." },
de: { subject: 'Passwort zurücksetzen', greeting: 'Hallo', body: 'Wir haben eine Anfrage erhalten, das Passwort für dein TREK-Konto zurückzusetzen. Klicke auf den Button unten, um ein neues Passwort festzulegen.', ctaIntro: 'Passwort zurücksetzen', expiry: 'Dieser Link ist 60 Minuten gültig.', ignore: 'Wenn du das nicht warst, ignoriere diese E-Mail — dein Passwort bleibt unverändert.' },
fr: { subject: 'Réinitialisez votre mot de passe', greeting: 'Bonjour', body: 'Nous avons reçu une demande de réinitialisation du mot de passe de votre compte TREK. Cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe.', ctaIntro: 'Réinitialiser le mot de passe', expiry: 'Ce lien expire dans 60 minutes.', ignore: "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail — votre mot de passe ne changera pas." },
es: { subject: 'Restablecer tu contraseña', greeting: 'Hola', body: 'Recibimos una solicitud para restablecer la contraseña de tu cuenta de TREK. Haz clic en el botón de abajo para establecer una nueva contraseña.', ctaIntro: 'Restablecer contraseña', expiry: 'Este enlace caduca en 60 minutos.', ignore: 'Si no solicitaste esto, puedes ignorar este correo — tu contraseña no cambiará.' },
it: { subject: 'Reimposta la tua password', greeting: 'Ciao', body: 'Abbiamo ricevuto una richiesta di reimpostazione della password per il tuo account TREK. Clicca il pulsante qui sotto per impostare una nuova password.', ctaIntro: 'Reimposta password', expiry: 'Questo link scade tra 60 minuti.', ignore: 'Se non hai richiesto questa operazione, ignora questa email — la tua password non cambierà.' },
nl: { subject: 'Reset je wachtwoord', greeting: 'Hallo', body: 'We hebben een verzoek ontvangen om het wachtwoord voor je TREK-account te resetten. Klik op de knop hieronder om een nieuw wachtwoord in te stellen.', ctaIntro: 'Wachtwoord resetten', expiry: 'Deze link verloopt over 60 minuten.', ignore: 'Als jij dit niet hebt aangevraagd, kun je deze e-mail negeren — je wachtwoord blijft ongewijzigd.' },
ru: { subject: 'Сброс пароля', greeting: 'Здравствуйте', body: 'Мы получили запрос на сброс пароля вашего аккаунта TREK. Нажмите кнопку ниже, чтобы установить новый пароль.', ctaIntro: 'Сбросить пароль', expiry: 'Ссылка действительна 60 минут.', ignore: 'Если вы не запрашивали сброс — просто проигнорируйте это письмо, пароль останется прежним.' },
zh: { subject: '重置您的密码', greeting: '您好', body: '我们收到了重置您的 TREK 账户密码的请求。点击下方按钮设置新密码。', ctaIntro: '重置密码', expiry: '此链接将在 60 分钟后失效。', ignore: '如果这不是您本人的请求,可以忽略本邮件 — 您的密码不会改变。' },
'zh-TW': { subject: '重設您的密碼', greeting: '您好', body: '我們收到了重設您 TREK 帳號密碼的請求。點擊下方按鈕以設定新密碼。', ctaIntro: '重設密碼', expiry: '此連結將於 60 分鐘後失效。', ignore: '若非您本人發起的請求,請忽略此郵件 — 您的密碼不會變更。' },
hu: { subject: 'Jelszó visszaállítása', greeting: 'Szia', body: 'Kérést kaptunk a TREK-fiókod jelszavának visszaállítására. Kattints az alábbi gombra az új jelszó beállításához.', ctaIntro: 'Jelszó visszaállítása', expiry: 'Ez a link 60 perc után lejár.', ignore: 'Ha nem te kérted ezt, nyugodtan hagyd figyelmen kívül ezt az e-mailt — a jelszavad változatlan marad.' },
ar: { subject: 'إعادة تعيين كلمة المرور', greeting: 'مرحبا', body: 'تلقينا طلبًا لإعادة تعيين كلمة المرور لحسابك في TREK. انقر على الزر أدناه لتعيين كلمة مرور جديدة.', ctaIntro: 'إعادة تعيين كلمة المرور', expiry: 'تنتهي صلاحية هذا الرابط خلال 60 دقيقة.', ignore: 'إذا لم تطلب هذا، يمكنك تجاهل هذه الرسالة — لن تتغير كلمة المرور الخاصة بك.' },
br: { subject: 'Redefinir sua senha', greeting: 'Olá', body: 'Recebemos um pedido para redefinir a senha da sua conta TREK. Clique no botão abaixo para definir uma nova senha.', ctaIntro: 'Redefinir senha', expiry: 'Este link expira em 60 minutos.', ignore: 'Se você não solicitou isto, pode ignorar este e-mail — sua senha não será alterada.' },
cs: { subject: 'Obnovení hesla', greeting: 'Ahoj', body: 'Obdrželi jsme žádost o obnovení hesla k tvému účtu TREK. Klikni na tlačítko níže a nastav nové heslo.', ctaIntro: 'Obnovit heslo', expiry: 'Odkaz vyprší za 60 minut.', ignore: 'Pokud jsi o obnovení nežádal/a, tento e-mail ignoruj — heslo zůstane beze změny.' },
pl: { subject: 'Zresetuj hasło', greeting: 'Cześć', body: 'Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta TREK. Kliknij przycisk poniżej, aby ustawić nowe hasło.', ctaIntro: 'Zresetuj hasło', expiry: 'Link wygaśnie za 60 minut.', ignore: 'Jeśli to nie Ty, zignoruj tę wiadomość — Twoje hasło pozostanie bez zmian.' },
};
function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings, recipient: string, resetUrl: string, lang: string): string {
const safeGreeting = escapeHtml(`${strings.greeting}, ${recipient}`);
const safeBody = escapeHtml(strings.body);
const safeExpiry = escapeHtml(strings.expiry);
const safeIgnore = escapeHtml(strings.ignore);
const safeCta = escapeHtml(strings.ctaIntro);
const block = `
<p style="margin:0 0 16px 0; font-size:16px;">${safeGreeting},</p>
<p style="margin:0 0 20px 0; font-size:15px; line-height:1.6;">${safeBody}</p>
<p style="margin:28px 0;">
<a href="${resetUrl}" style="display:inline-block;padding:14px 28px;background:#111827;color:#fff;text-decoration:none;border-radius:10px;font-weight:600;font-size:15px;">${safeCta}</a>
</p>
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
`;
return buildEmailHtml(subject, block, lang);
}
/**
* Delivers a password-reset link. When SMTP is configured the user
* receives an email. When it isn't, the link is logged to stdout in a
* clearly-fenced block so the self-hosting admin can hand it off by
* other means. In both cases the caller always gets a boolean that
* indicates only whether the caller should treat delivery as
* best-effort done the API response to the user must NOT leak it.
*/
export async function sendPasswordResetEmail(
to: string,
resetUrl: string,
userId: number | null,
): Promise<{ delivered: 'email' | 'log' | 'failed' }> {
const lang = userId ? getUserLanguage(userId) : 'en';
const strings = PASSWORD_RESET_I18N[lang] || PASSWORD_RESET_I18N.en;
const smtpCfg = getSmtpConfig();
if (!smtpCfg) {
// No SMTP configured — log the link in a visually distinct block so
// the admin can relay it. Never log the associated user id/email
// content at a lower level, only what's needed.
// eslint-disable-next-line no-console
console.log(
`\n===== PASSWORD RESET LINK =====\n` +
`to: ${to}\n` +
`url: ${resetUrl}\n` +
`expires: 60 minutes\n` +
`(SMTP is not configured — deliver this link to the user manually.)\n` +
`================================\n`,
);
logInfo(`Password reset link issued (no SMTP) for=${to}`);
return { delivered: 'log' };
}
try {
const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true';
const transporter = nodemailer.createTransport({
host: smtpCfg.host,
port: smtpCfg.port,
secure: smtpCfg.secure,
auth: smtpCfg.user ? { user: smtpCfg.user, pass: smtpCfg.pass } : undefined,
...(skipTls ? { tls: { rejectUnauthorized: false } } : {}),
});
await transporter.sendMail({
from: smtpCfg.from,
to,
subject: `TREK — ${strings.subject}`,
text: `${strings.greeting}, ${to}\n\n${strings.body}\n\n${strings.ctaIntro}: ${resetUrl}\n\n${strings.expiry}\n${strings.ignore}`,
html: buildPasswordResetHtml(strings.subject, strings, to, resetUrl, lang),
});
logInfo(`Password reset email sent to=${to}`);
return { delivered: 'email' };
} catch (err) {
logError(`Password reset email failed to=${to}: ${err instanceof Error ? err.message : err}`);
return { delivered: 'failed' };
}
}
export async function sendEmail(to: string, subject: string, body: string, userId?: number, navigateTarget?: string): Promise<boolean> {
const config = getSmtpConfig();
if (!config) return false;
+29 -6
View File
@@ -6,6 +6,7 @@ import { ADDON_IDS } from '../addons';
import { User } from '../types';
import { writeAudit, logWarn } from './auditLog';
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
import { getAppUrl } from './oidcService';
// ---------------------------------------------------------------------------
// Constants
@@ -28,6 +29,7 @@ interface PendingCode {
userId: number;
redirectUri: string;
scopes: string[];
resource: string | null;
codeChallenge: string;
codeChallengeMethod: 'S256';
expiresAt: number;
@@ -67,6 +69,7 @@ interface OAuthTokenRow {
access_token_hash: string;
refresh_token_hash: string;
scopes: string; // JSON array
audience: string | null;
access_token_expires_at: string;
refresh_token_expires_at: string;
revoked_at: string | null;
@@ -243,6 +246,7 @@ export function createAuthCode(params: {
userId: number;
redirectUri: string;
scopes: string[];
resource: string | null;
codeChallenge: string;
codeChallengeMethod: 'S256';
}): string | null {
@@ -294,6 +298,7 @@ export function issueTokens(
userId: number,
scopes: string[],
parentTokenId: number | null = null,
audience: string | null = null,
): {
access_token: string;
refresh_token: string;
@@ -312,9 +317,9 @@ export function issueTokens(
db.prepare(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
return {
access_token: rawAccess,
@@ -333,12 +338,13 @@ export interface OAuthTokenInfo {
user: User;
scopes: string[];
clientId: string;
audience: string | null;
}
export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
const hash = hashToken(rawToken);
const row = db.prepare(`
SELECT ot.scopes, ot.revoked_at, ot.access_token_expires_at,
SELECT ot.scopes, ot.audience, ot.revoked_at, ot.access_token_expires_at,
ot.user_id, ot.client_id, u.username, u.email, u.role
FROM oauth_tokens ot
JOIN users u ON ot.user_id = u.id
@@ -353,6 +359,7 @@ export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
scopes: JSON.parse(row.scopes),
clientId: row.client_id,
audience: row.audience ?? null,
};
}
@@ -406,7 +413,7 @@ export function refreshTokens(
const hash = hashToken(rawRefreshToken);
const row = db.prepare(`
SELECT id, client_id, user_id, scopes, refresh_token_expires_at, revoked_at, parent_token_id
SELECT id, client_id, user_id, scopes, audience, refresh_token_expires_at, revoked_at, parent_token_id
FROM oauth_tokens WHERE refresh_token_hash = ?
`).get(hash) as OAuthTokenRow | undefined;
@@ -442,7 +449,7 @@ export function refreshTokens(
revokeUserSessionsForClient(row.user_id, clientId);
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id);
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
return { tokens };
@@ -522,6 +529,7 @@ export interface AuthorizeParams {
state?: string;
code_challenge: string;
code_challenge_method: string;
resource?: string;
}
export interface ValidateAuthorizeResult {
@@ -530,6 +538,7 @@ export interface ValidateAuthorizeResult {
error_description?: string;
client?: { name: string; allowed_scopes: string[] };
scopes?: string[];
resource?: string | null;
/** true when user is logged in but consent UI must be shown */
consentRequired?: boolean;
/** true when the request is valid but user is not authenticated */
@@ -573,6 +582,19 @@ export function validateAuthorizeRequest(
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
}
// RFC 8707 resource indicator: if provided, must identify the TREK
// MCP endpoint exactly. If the client didn't supply `resource`, we
// bind the token to the MCP endpoint by default — previously this
// left `audience = null`, and the audience-bind check on MCP requests
// then treated a null audience as "valid for any resource".
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
const resource = params.resource
? params.resource.replace(/\/+$/, '')
: mcpResource;
if (resource !== mcpResource) {
return { valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' };
}
const requestedScopes = (params.scope || '').split(' ').filter(Boolean);
if (requestedScopes.length === 0) {
return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' };
@@ -599,6 +621,7 @@ export function validateAuthorizeRequest(
valid: true,
client: { name: client.name, allowed_scopes: allowedScopes },
scopes: grantedScopes,
resource: resource ?? mcpResource,
consentRequired,
scopeSelectable: client.created_via === 'dcr',
};
+130 -13
View File
@@ -14,6 +14,8 @@ export interface OidcDiscoveryDoc {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
jwks_uri?: string;
issuer?: string;
_issuer?: string;
}
@@ -138,6 +140,12 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
const doc = (await res.json()) as OidcDiscoveryDoc;
// Validate that the discovery doc's issuer matches the operator-configured
// one. A MITM or compromised doc could otherwise supply a crafted issuer
// that passes jwt.verify() because we used doc.issuer as the expected value.
if (doc.issuer && doc.issuer !== issuer) {
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
}
doc._issuer = url;
discoveryCache = doc;
discoveryCacheTime = Date.now();
@@ -221,6 +229,102 @@ export async function getUserInfo(userinfoEndpoint: string, accessToken: string)
return (await res.json()) as OidcUserInfo;
}
// ---------------------------------------------------------------------------
// id_token verification (signature + iss + aud + exp)
// ---------------------------------------------------------------------------
// 5 minute JWKS cache — short enough to pick up key rotation within a
// reasonable window, long enough that normal login flow doesn't fetch
// JWKS on every callback.
const JWKS_TTL_MS = 5 * 60 * 1000;
type JwksEntry = { keys: Array<Record<string, unknown>>; fetchedAt: number };
const jwksCache = new Map<string, JwksEntry>();
async function fetchJwks(jwksUri: string): Promise<Array<Record<string, unknown>>> {
const cached = jwksCache.get(jwksUri);
if (cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) return cached.keys;
const res = await fetch(jwksUri);
if (!res.ok) throw new Error(`JWKS fetch failed: HTTP ${res.status}`);
const json = (await res.json()) as { keys?: Array<Record<string, unknown>> };
const keys = json.keys ?? [];
jwksCache.set(jwksUri, { keys, fetchedAt: Date.now() });
return keys;
}
function base64UrlDecode(input: string): Buffer {
const padded = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (input.length % 4)) % 4);
return Buffer.from(padded, 'base64');
}
/**
* Verify an OIDC id_token end-to-end: signature against the provider's
* JWKS, issuer match, audience match, and exp/nbf. Does NOT verify a
* nonce the server doesn't currently send one in the auth request;
* when that's added, pass the expected nonce here and check `claims.nonce`.
*
* Returning the claims lets callers cross-check `sub` / `email` against
* the userinfo response. A mismatch would mean the provider's userinfo
* endpoint is speaking for a different subject than the id_token a
* classic IdP-side compromise signal worth refusing login over.
*/
export async function verifyIdToken(
idToken: string,
doc: OidcDiscoveryDoc,
clientId: string,
expectedIssuer: string,
): Promise<{ ok: true; claims: Record<string, unknown> } | { ok: false; error: string }> {
if (!doc.jwks_uri) return { ok: false, error: 'no_jwks_uri' };
const parts = idToken.split('.');
if (parts.length !== 3) return { ok: false, error: 'malformed_token' };
let header: { kid?: string; alg?: string };
try { header = JSON.parse(base64UrlDecode(parts[0]!).toString('utf8')); }
catch { return { ok: false, error: 'bad_header' }; }
const alg = header.alg;
if (!alg || !/^(RS256|RS384|RS512|ES256|ES384|ES512|PS256|PS384|PS512)$/.test(alg)) {
return { ok: false, error: 'unsupported_alg' };
}
let keys: Array<Record<string, unknown>>;
try { keys = await fetchJwks(doc.jwks_uri); }
catch (e) { return { ok: false, error: 'jwks_fetch_failed' }; }
// When the token carries a `kid`, refuse to fall back to any other
// key in the JWKS — a mismatch means the token was signed with a key
// the provider no longer publishes, and we should reject rather than
// mask the failure by trying another key.
const jwk = header.kid
? keys.find((k) => k['kid'] === header.kid)
: keys[0];
if (!jwk) return { ok: false, error: 'no_matching_key' };
let publicKey;
try {
// Node 16+ understands JWK directly; no PEM conversion library needed.
// Node's crypto accepts a JWK object directly as `{ key, format: 'jwk' }`.
// The type signature isn't strict on our TS config so we cast through any.
publicKey = crypto.createPublicKey({ key: jwk as any, format: 'jwk' });
} catch {
return { ok: false, error: 'key_import_failed' };
}
let claims: Record<string, unknown>;
try {
const verified = jwt.verify(idToken, publicKey, {
algorithms: [alg as jwt.Algorithm],
issuer: expectedIssuer,
audience: clientId,
});
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
} catch (err) {
const msg = err instanceof Error ? err.message : 'verify_failed';
return { ok: false, error: `signature_or_claim_mismatch: ${msg}` };
}
return { ok: true, claims };
}
// ---------------------------------------------------------------------------
// Find or create user by OIDC sub / email
// ---------------------------------------------------------------------------
@@ -286,21 +390,34 @@ export function findOrCreateUser(
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
if (existing) username = `${username}_${Date.now() % 10000}`;
const result = db.prepare(
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
).run(username, email, hash, role, sub, config.issuer, process.env.APP_VERSION || '0.0.0');
if (validInvite) {
const updated = db.prepare(
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)',
).run(validInvite.id);
if (updated.changes === 0) {
console.warn(`[OIDC] Invite token ${inviteToken?.slice(0, 8)}... exceeded max_uses (race condition)`);
// Atomic registration: if an invite was presented, the increment IS
// the capacity check — UPDATE matches zero rows the moment another
// concurrent callback wins the last slot, and the transaction aborts
// the user INSERT. Without this, two parallel OIDC callbacks could
// both pass the earlier SELECT-based check and each create a user.
const inviteRaceError = new Error('invite_exhausted');
try {
const createUser = db.transaction(() => {
if (validInvite) {
const updated = db.prepare(
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)',
).run(validInvite.id);
if (updated.changes === 0) throw inviteRaceError;
}
return db.prepare(
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
).run(username, email, hash, role, sub, config.issuer, process.env.APP_VERSION || '0.0.0');
});
const result = createUser() as { lastInsertRowid: number | bigint };
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
return { user };
} catch (err) {
if (err === inviteRaceError) {
console.warn(`[OIDC] Invite token ${inviteToken?.slice(0, 8)}... exhausted — concurrent callback won the last slot`);
return { error: 'registration_disabled' };
}
throw err;
}
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
return { user };
}
// ---------------------------------------------------------------------------
+3 -1
View File
@@ -96,7 +96,9 @@ export function getInFlight(placeId: string): Promise<{ filePath: string; attrib
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
inFlight.set(placeId, promise);
promise.finally(() => inFlight.delete(placeId));
promise
.finally(() => inFlight.delete(placeId))
.catch(() => { /* awaiter logs; this .catch only prevents unhandledRejection */ });
}
export function serveFilePath(placeId: string): string | null {
+11 -6
View File
@@ -149,10 +149,10 @@ export function createReservation(tripId: string | number, data: CreateReservati
let resolvedAccommodationId: number | null = accommodation_id || null;
if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) {
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
if (accPlaceId && start_day_id && end_day_id) {
if (start_day_id && end_day_id) {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
).run(tripId, accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
resolvedAccommodationId = Number(accResult.lastInsertRowid);
accommodationCreated = true;
}
@@ -274,11 +274,16 @@ export function updateReservation(id: string | number, tripId: string | number,
}
if (type === 'hotel' && create_accommodation) {
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
if (accPlaceId && start_day_id && end_day_id) {
if (start_day_id && end_day_id) {
if (resolvedAccId) {
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
} else {
if (accPlaceId) {
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
} else {
db.prepare('UPDATE day_accommodations SET start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
}
} else if (accPlaceId) {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
+10 -3
View File
@@ -1,7 +1,10 @@
import { db } from '../db/database';
import { maybe_encrypt_api_key } from './apiKeyCrypto';
import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']);
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'mapbox_access_token']);
// Encrypted keys that are masked (••••••••) when returned to the client.
// Keys not in this set but in ENCRYPTED_SETTING_KEYS are decrypted and returned.
const MASKED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']);
export const DEFAULTABLE_USER_SETTING_KEYS = [
'temperature_unit',
@@ -83,10 +86,14 @@ export function getUserSettings(userId: number): Record<string, unknown> {
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
const userSettings: Record<string, unknown> = {};
for (const row of rows) {
if (ENCRYPTED_SETTING_KEYS.has(row.key)) {
if (MASKED_SETTING_KEYS.has(row.key)) {
userSettings[row.key] = row.value ? '••••••••' : '';
continue;
}
if (ENCRYPTED_SETTING_KEYS.has(row.key)) {
userSettings[row.key] = row.value ? (decrypt_api_key(row.value) ?? '') : '';
continue;
}
try {
userSettings[row.key] = JSON.parse(row.value);
} catch {
+10 -3
View File
@@ -44,9 +44,14 @@ export function createOrUpdateShareLink(
return { token: existing.token, created: false };
}
// New share links default to a 90-day TTL. Existing tokens that were
// created before the expires_at migration keep NULL here and remain
// valid indefinitely until the owner rotates them; that preserves
// behaviour for anyone who's already sharing a link.
const token = crypto.randomBytes(24).toString('base64url');
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
.run(tripId, token, createdBy, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0);
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run(tripId, token, createdBy, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, expiresAt);
return { token, created: true };
}
@@ -79,7 +84,9 @@ export function deleteShareLink(tripId: string): void {
* permission flags. Returns null if the token is invalid or the trip is gone.
*/
export function getSharedTripData(token: string): Record<string, any> | null {
const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any;
const shareRow = db.prepare(
"SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).get(token) as any;
if (!shareRow) return null;
const tripId = shareRow.trip_id;
+55 -2
View File
@@ -44,6 +44,11 @@ vi.mock('../../src/services/oidcService', async (importOriginal) => {
discover: vi.fn(),
exchangeCodeForToken: vi.fn(),
getUserInfo: vi.fn(),
// Bypass real JWKS fetch + signature verification in tests. Callers
// that exercise the security of verifyIdToken should unit-test the
// function directly instead; integration tests here focus on the
// callback flow, not the crypto.
verifyIdToken: vi.fn(),
};
});
@@ -58,6 +63,7 @@ import * as oidcService from '../../src/services/oidcService';
const mockDiscover = vi.mocked(oidcService.discover);
const mockExchangeCode = vi.mocked(oidcService.exchangeCodeForToken);
const mockGetUserInfo = vi.mocked(oidcService.getUserInfo);
const mockVerifyIdToken = vi.mocked(oidcService.verifyIdToken);
const MOCK_DISCOVERY_DOC = {
authorization_endpoint: 'https://oidc.example.com/auth',
@@ -142,9 +148,11 @@ describe('GET /api/auth/oidc/callback', () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({
access_token: 'test-access-token',
id_token: 'fake.id.token',
_ok: true,
_status: 200,
});
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-alice-123' } });
mockGetUserInfo.mockResolvedValueOnce({
sub: 'sub-alice-123',
email: 'alice@example.com',
@@ -162,7 +170,8 @@ describe('GET /api/auth/oidc/callback', () => {
it('OIDC-005: new user gets created when registration is open', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', _ok: true, _status: 200 });
mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', id_token: 'fake.id.token', _ok: true, _status: 200 });
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-newuser-999' } });
mockGetUserInfo.mockResolvedValueOnce({
sub: 'sub-newuser-999',
email: 'newuser@example.com',
@@ -214,6 +223,49 @@ describe('GET /api/auth/oidc/callback', () => {
expect(res.headers.location).toContain('oidc_error=token_failed');
});
it('OIDC-010a: missing id_token in token response → redirects with no_id_token error', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 }); // no id_token
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=no_id_token');
});
it('OIDC-010b: verifyIdToken failure → redirects with id_token_invalid error', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'bad.id.token', _ok: true, _status: 200 });
mockVerifyIdToken.mockResolvedValueOnce({ ok: false, error: 'signature_or_claim_mismatch: invalid signature' });
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=id_token_invalid');
});
it('OIDC-010c: userinfo.sub does not match id_token.sub → redirects with subject_mismatch error', async () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 });
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-from-token' } });
mockGetUserInfo.mockResolvedValueOnce({
sub: 'sub-different-from-userinfo',
email: 'alice@example.com',
name: 'Alice',
});
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=subject_mismatch');
});
it('OIDC-010: registration disabled for new user → redirects with registration_disabled error', async () => {
// Need at least one existing user so isFirstUser=false
createUser(testDb, { email: 'existing@example.com' });
@@ -221,7 +273,8 @@ describe('GET /api/auth/oidc/callback', () => {
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 });
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 });
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-blocked-user' } });
mockGetUserInfo.mockResolvedValueOnce({
sub: 'sub-blocked-user',
email: 'blocked@example.com',
@@ -38,6 +38,7 @@ const { isAddonEnabledMock } = vi.hoisted(() => {
});
vi.mock('../../../src/services/adminService', () => ({
isAddonEnabled: isAddonEnabledMock,
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
}));
import { createTables } from '../../../src/db/schema';
@@ -37,6 +37,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
vi.mock('../../../src/services/adminService', () => ({
isAddonEnabled: vi.fn().mockReturnValue(true),
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
}));
import { createTables } from '../../../src/db/schema';

Some files were not shown because too many files have changed in this diff Show More