Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16b81a8356 | |||
| 5984adb2ea | |||
| f8eb1915fe | |||
| b556c636eb | |||
| b20db1428d | |||
| 4a5a59cb78 | |||
| 20bf9c2312 | |||
| 9f57ab4517 | |||
| 292e443dbe | |||
| 2d0414b4a3 | |||
| e612de9143 | |||
| c857d38bcd | |||
| d7a71c0572 | |||
| 58c061e653 | |||
| 22d1d06d39 | |||
| 290f566daa | |||
| 8ca2507050 | |||
| 9c666a0aaf | |||
| b3f2f7308a | |||
| af9b31c1ff | |||
| d7d1493289 | |||
| 54e042b736 | |||
| 0ba31847eb | |||
| 26ab39dc21 | |||
| 00be0eab05 | |||
| ed97bb1deb | |||
| 51387b0af1 | |||
| 1559ed12bd | |||
| c1b9d11173 | |||
| 2ab8b401fb | |||
| 49af7a8b0d | |||
| dd90c6d424 |
@@ -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
|
||||
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'wiki/**'
|
||||
- '.github/workflows/wiki.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
|
||||
@@ -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
|
||||
@@ -58,4 +58,6 @@ coverage
|
||||
*.tgz
|
||||
|
||||
.scannerwork
|
||||
test-data
|
||||
test-data
|
||||
|
||||
.run
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  | |
|
||||
<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:
|
||||
· <a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a> ·
|
||||
|
||||
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">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -434,6 +459,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',
|
||||
|
||||
@@ -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': 'تمت مشاركة الصور',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 haven’t 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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 l’indirizzo 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 all’accesso',
|
||||
'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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Фото опубликованы',
|
||||
|
||||
@@ -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': '照片已分享',
|
||||
|
||||
@@ -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': '照片已分享',
|
||||
|
||||
@@ -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
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 792 KiB |
|
After Width: | Height: | Size: 908 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 928 KiB |
|
After Width: | Height: | Size: 132 KiB |
@@ -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 |
@@ -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 |
@@ -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)
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -1767,6 +1767,145 @@ 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);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,6 +46,7 @@ const server = app.listen(PORT, () => {
|
||||
}
|
||||
scheduler.start();
|
||||
scheduler.startTripReminders();
|
||||
scheduler.startTodoReminders();
|
||||
scheduler.startVersionCheck();
|
||||
scheduler.startDemoReset();
|
||||
scheduler.startIdempotencyCleanup();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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).',
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -38,6 +38,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';
|
||||
|
||||
@@ -42,7 +42,10 @@ const { isAddonEnabledMock } = vi.hoisted(() => {
|
||||
const isAddonEnabledMock = vi.fn().mockReturnValue(true);
|
||||
return { isAddonEnabledMock };
|
||||
});
|
||||
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: isAddonEnabledMock }));
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: isAddonEnabledMock,
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
|
||||
const { mockGetTripSummary } = vi.hoisted(() => ({
|
||||
mockGetTripSummary: vi.fn(),
|
||||
|
||||
@@ -41,6 +41,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 }),
|
||||
}));
|
||||
|
||||
// Mock async service functions that make external calls
|
||||
|
||||