Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
@@ -27,3 +25,4 @@
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
.github/assets/TREK1.gif filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b9153871a41ca2c53ab9188ea400eb45f4065680eae0ee0ebc3fbcf18373d99c
|
||||
size 95418702
|
||||
@@ -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
|
||||
@@ -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=".github/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
|
||||
|
||||
@@ -114,6 +114,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: {
|
||||
|
||||
@@ -463,6 +463,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': 'كلمتا المرور غير متطابقتين',
|
||||
|
||||
@@ -458,6 +458,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',
|
||||
|
||||
@@ -458,6 +458,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í',
|
||||
|
||||
@@ -463,6 +463,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',
|
||||
|
||||
@@ -522,6 +522,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',
|
||||
|
||||
@@ -450,6 +450,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',
|
||||
|
||||
@@ -451,6 +451,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',
|
||||
|
||||
@@ -458,6 +458,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',
|
||||
|
||||
@@ -520,6 +520,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',
|
||||
|
||||
@@ -458,6 +458,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',
|
||||
|
||||
@@ -451,6 +451,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',
|
||||
|
||||
@@ -425,6 +425,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',
|
||||
|
||||
@@ -451,6 +451,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': 'Ошибка демо-входа',
|
||||
|
||||
@@ -451,6 +451,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': '演示登录失败',
|
||||
|
||||
@@ -510,6 +510,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': '演示登入失敗',
|
||||
|
||||
@@ -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
|
||||
@@ -2312,7 +2312,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 +2333,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 && (
|
||||
|
||||
@@ -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
|
||||
|
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 |
@@ -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();
|
||||
|
||||
@@ -77,6 +77,11 @@ export function createApp(): express.Application {
|
||||
|
||||
const shouldForceHttps = process.env.FORCE_HTTPS === '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: {
|
||||
|
||||
@@ -1767,6 +1767,31 @@ 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);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,11 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
|
||||
if (token.startsWith('trekoa_')) {
|
||||
const result = getUserByAccessToken(token);
|
||||
if (!result) return null;
|
||||
// RFC 8707: if the token carries an audience, it must match this resource endpoint
|
||||
if (result.audience !== null) {
|
||||
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 +223,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 +244,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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -15,11 +15,21 @@ export function extractToken(req: Request): string | null {
|
||||
|
||||
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 +78,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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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 } 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
|
||||
@@ -146,6 +159,71 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
|
||||
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 incoming request origin so dev /
|
||||
// prod both work without extra config.
|
||||
const origin = (req.headers['origin'] as string | undefined)
|
||||
|| (req.headers['referer'] ? new URL(req.headers['referer'] as string).origin : undefined)
|
||||
|| `${req.protocol}://${req.get('host')}`;
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -275,6 +295,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 +319,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 +328,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 +354,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
resource,
|
||||
};
|
||||
|
||||
const validation = validateAuthorizeRequest(params, user.id);
|
||||
@@ -350,6 +373,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',
|
||||
});
|
||||
|
||||
@@ -156,9 +156,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' }
|
||||
);
|
||||
@@ -994,6 +997,210 @@ 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 candidateHash = hashBackupCode(supplied);
|
||||
const idx = hashes.findIndex(h => h === candidateHash);
|
||||
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);
|
||||
}
|
||||
})();
|
||||
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -316,6 +316,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,13 @@ 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
|
||||
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
const resource = params.resource ? params.resource.replace(/\/+$/, '') : null;
|
||||
if (resource !== null && 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 +615,7 @@ export function validateAuthorizeRequest(
|
||||
valid: true,
|
||||
client: { name: client.name, allowed_scopes: allowedScopes },
|
||||
scopes: grantedScopes,
|
||||
resource: resource ?? mcpResource,
|
||||
consentRequired,
|
||||
scopeSelectable: client.created_via === 'dcr',
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(),
|
||||
vi.mock('../../../src/demo/demo-reset', () => ({ saveBaseline: vi.fn() }));
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Accommodations
|
||||
|
||||
Link an accommodation to specific check-in and check-out days so it appears for every day you are staying there. Accommodations are a distinct record type backed by the `day_accommodations` table and are separate from regular reservations, though each accommodation automatically creates a linked **Hotel** reservation.
|
||||
|
||||
<!-- TODO: screenshot: accommodation list showing check-in/check-out details -->
|
||||
|
||||
## Creating an accommodation
|
||||
|
||||
There are two ways to create an accommodation:
|
||||
|
||||
**From the Reservations panel:** Click **Add** and select **Hotel** as the booking type. When the type is set to Hotel, the date/time and location fields are replaced by accommodation-specific inputs (see below). Saving the form creates both the hotel reservation and the underlying accommodation record at the same time.
|
||||
|
||||
**From the Day Detail panel:** Click the hotel icon or the **Add accommodation** button in the Day Detail overlay. A picker appears that lets you select a place from the trip, choose the day range, and optionally fill in check-in/check-out times and a confirmation code. This creates the accommodation record and its linked Hotel reservation together.
|
||||
|
||||

|
||||
|
||||
## Accommodation-specific fields
|
||||
|
||||
When creating or editing via the Reservations panel with type set to **Hotel**, the date/time and location fields are replaced by accommodation-specific inputs:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Accommodation** | Search for or select an existing trip place to link as the property. Selecting a place pre-fills the title if it is empty and pre-fills the location field if the place has an address |
|
||||
| **From** | The check-in day |
|
||||
| **To** | The check-out day |
|
||||
| **Check-in** | The earliest time you can check in |
|
||||
| **Check-in until** | The latest time the front desk accepts check-in |
|
||||
| **Check-out** | The latest time you must check out |
|
||||
|
||||
The **Confirmation code**, **Status** (Pending / Confirmed), and **Notes** fields are also available, as they are for all reservation types.
|
||||
|
||||
## In the Day Detail panel
|
||||
|
||||
For each day between the **From** day and **To** day (inclusive), the accommodation appears in the Day Detail panel overlay. It shows the linked place name and address, a check-in or check-out label for the relevant boundary days, and the check-in window, check-out time, and confirmation code if set. Middle nights show the place name without a check-in/check-out label. The linked Hotel reservation's status and confirmation number are also shown inline.
|
||||
|
||||

|
||||
|
||||
## In the day plan sidebar
|
||||
|
||||
Accommodations appear as small colour-coded badges in the day header row of the day plan sidebar:
|
||||
|
||||
- **Green badge** — check-in day
|
||||
- **Red badge** — check-out day
|
||||
- **Neutral badge** — nights in between (ongoing stay)
|
||||
|
||||
Clicking a badge navigates to the linked place. Hotel-type reservations are filtered out of the inline transport card list between places; they do not appear as transport items in the timeline.
|
||||
|
||||
## In the Reservations panel
|
||||
|
||||
Hotel reservation cards in the Reservations panel show the linked accommodation name (place name) alongside the standard reservation fields such as the confirmation code and status. The check-in and check-out times are displayed in the metadata section of the card when they have been set.
|
||||
|
||||
---
|
||||
|
||||
**See also:** [Reservations-and-Bookings](Reservations-and-Bookings) · [Transport-Flights-Trains-Cars](Transport-Flights-Trains-Cars) · [Day-Plans-and-Notes](Day-Plans-and-Notes)
|
||||
@@ -0,0 +1,34 @@
|
||||
# Addons Overview
|
||||
|
||||
Addons are optional features that an admin can enable or disable for the entire TREK instance. When an addon is disabled, its navigation tabs, menu items, and API routes are hidden from all users.
|
||||
|
||||

|
||||
|
||||
## What addons are
|
||||
|
||||
Each addon extends TREK with functionality beyond the core trip-planning features. Addons are managed globally — you cannot enable an addon for one user only. Once enabled, the feature becomes available to all users on the instance.
|
||||
|
||||
## Addon list
|
||||
|
||||
The following addons are registered in the system (defined in `server/src/db/seeds.ts`; the TypeScript constant `ADDON_IDS` in `server/src/addons.ts` covers all addons except `naver_list_import`):
|
||||
|
||||
| Addon ID | Type | Description |
|
||||
|---|---|---|
|
||||
| `mcp` | integration | Exposes TREK data and actions through the Model Context Protocol for AI assistant integrations. |
|
||||
| `packing` | trip | Packing list management — create templates and lists linked to trips. See [Packing-Lists](Packing-Lists). |
|
||||
| `budget` | trip | Trip budget tracking — log expenses, set budgets, and track spending per trip. See [Budget-Tracking](Budget-Tracking). |
|
||||
| `documents` | trip | Document and file attachments for trips — store itineraries, visa copies, and other files. See [Documents-and-Files](Documents-and-Files). |
|
||||
| `vacay` | global | Personal vacation day planner with a year calendar, holiday packs, and collaborator fusion. See [Vacay](Vacay). |
|
||||
| `atlas` | global | Interactive world map showing countries and regions you have visited, plus a bucket list. See [Atlas](Atlas). |
|
||||
| `collab` | trip | Notes, polls, and live chat for trip collaboration. See [Real-Time-Collaboration](Real-Time-Collaboration). |
|
||||
| `journey` | global | Trip tracking and travel journal — check-ins, photos, and daily stories. See [Journey-Journal](Journey-Journal). |
|
||||
| `naver_list_import` | trip | Import places from shared Naver Maps lists directly into a trip. |
|
||||
|
||||
|
||||
## Enabling addons
|
||||
|
||||
> **Admin:** all addons are toggled from the admin panel. Navigate to [Admin-Addons](Admin-Addons) to enable or disable individual addons for your instance.
|
||||
|
||||
## Per-addon sub-features
|
||||
|
||||
Some addons expose sub-features that an admin can independently toggle. The [Real-Time-Collaboration](Real-Time-Collaboration) addon, for example, lets an admin decide which of its four sub-features (chat, notes, polls, and what's next) are active across the instance. These are configured from the [Admin-Addons](Admin-Addons) panel alongside the addon's main toggle.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Admin — Addons
|
||||
|
||||
The **Addons** tab lets you enable or disable optional features for the entire TREK instance. Toggling an addon affects all users immediately — disabling one hides its UI elements and blocks its API routes instance-wide.
|
||||
|
||||
<!-- TODO: screenshot: addon toggle switches in admin panel -->
|
||||
|
||||

|
||||
|
||||
## What addons control
|
||||
|
||||
Each addon toggle controls a feature set. When you disable an addon, users lose access to that feature everywhere in the app. No data is deleted; re-enabling the addon restores access to existing data.
|
||||
|
||||
## Addon categories
|
||||
|
||||
Addons are grouped into three categories, shown as labeled sections.
|
||||
|
||||
### Trip addons
|
||||
|
||||
Trip addons add per-trip feature panels. They appear in every trip where the addon is enabled.
|
||||
|
||||
The default trip addons are: **Lists**, **Budget**, **Documents**, **Collab**, and **Naver List Import** (all enabled by default). The exact list is determined by what is registered in your TREK database.
|
||||
|
||||
**Sub-toggles on trip addons:**
|
||||
|
||||
- **Lists** — when enabled, a nested **Bag Tracking** toggle appears. Bag Tracking lets users assign packed items to specific bags.
|
||||
- **Collab** — when enabled, four sub-toggles appear for individual collaboration features:
|
||||
- **Chat** — in-trip real-time chat
|
||||
- **Notes** — shared trip notes
|
||||
- **Polls** — trip polls
|
||||
- **What's Next** — the "what's next" widget
|
||||
|
||||
Each sub-toggle can be disabled independently while the parent addon remains enabled.
|
||||
|
||||
### Global addons
|
||||
|
||||
Global addons add features that are not tied to a single trip. The default global addons are **Vacay**, **Atlas**, and **Journey**.
|
||||
|
||||
- **Vacay** — personal vacation day planner with calendar view. Enabled by default.
|
||||
- **Atlas** — world map of visited countries with travel stats. Enabled by default.
|
||||
- **Journey** — trip tracking and travel journal (check-ins, photos, daily stories). **Disabled by default.**
|
||||
|
||||
**Sub-items on global addons:**
|
||||
|
||||
- The **Journey** addon shows photo provider toggles underneath it. Each photo provider (e.g., Immich, Synology Photos) can be enabled or disabled independently.
|
||||
|
||||
### Integration addons
|
||||
|
||||
Integration addons connect TREK to external services. Enabling an integration addon typically requires additional configuration (API keys, URLs) in the **Settings** tab.
|
||||
|
||||
- The **MCP** addon requires `APP_URL` to be set in your environment. When enabled, the **MCP Access** tab appears in the Admin Panel. **Disabled by default.** See [MCP-Overview](MCP-Overview) for full details.
|
||||
|
||||
## Enabling or disabling an addon
|
||||
|
||||
Click the toggle switch on any addon row. The change is applied immediately — no save button is needed. A brief success toast confirms the update.
|
||||
|
||||
If a toggle fails (e.g., network error), it rolls back to its previous state.
|
||||
|
||||
## Additional configuration
|
||||
|
||||
Some addons require credentials or environment variables before they are functional:
|
||||
|
||||
- **Journey** — requires photo provider credentials (Immich or Synology Photos) configured per-user in their personal Settings. See [Photo-Providers](Photo-Providers).
|
||||
- **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Admin-Panel-Overview](Admin-Panel-Overview)
|
||||
- [Admin-MCP-Tokens](Admin-MCP-Tokens)
|
||||
- [MCP-Overview](MCP-Overview)
|
||||
- [Addons-Overview](Addons-Overview)
|
||||
@@ -0,0 +1,47 @@
|
||||
# Admin — Categories
|
||||
|
||||
The **Personalization** tab → **Categories** section lets you manage global place categories. Categories are shared across all trips and all users on the instance.
|
||||
|
||||
<!-- TODO: screenshot: category list in admin panel -->
|
||||
|
||||

|
||||
|
||||
## What categories are
|
||||
|
||||
A category is a label consisting of a name, a color, and an icon. Users assign categories to places when creating or editing a place. Categories appear:
|
||||
|
||||
- In the place form's category selector
|
||||
- As colored chips on place cards
|
||||
- In the places filter panel
|
||||
- In the map legend
|
||||
|
||||
## Creating a category
|
||||
|
||||
Click **New Category** (top-right of the category section). A form appears inline:
|
||||
|
||||
1. **Name** — required. Free-text label for the category.
|
||||
2. **Icon** — a scrollable grid of ~47 curated Lucide icons (Pin, Hotel, Restaurant, Transport, Nature, etc.). Click any icon to select it. The default icon is `MapPin`.
|
||||
3. **Color** — 12 preset color swatches plus a custom color picker (pipette button). The default color is `#6366f1` (indigo). The 12 presets are:
|
||||
|
||||
`#6366f1` · `#8b5cf6` · `#ec4899` · `#ef4444` · `#f97316` · `#f59e0b` · `#10b981` · `#06b6d4` · `#3b82f6` · `#84cc16` · `#6b7280` · `#1f2937`
|
||||
|
||||
4. A **live preview** chip shows how the category will appear to users as you make selections.
|
||||
|
||||
Click **Create** to save.
|
||||
|
||||
## Editing a category
|
||||
|
||||
Click the pencil icon on any category row. The same form appears in-place with the existing values pre-filled. Change any field and click **Update**.
|
||||
|
||||
## Deleting a category
|
||||
|
||||
Click the trash icon on a category row and confirm. Deletion sets `category_id` to `NULL` on any places that had the category assigned — the places themselves are not affected, they become uncategorized.
|
||||
|
||||
## List ordering
|
||||
|
||||
Categories are always displayed in alphabetical order by name. There is no manual reordering.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Places-and-Search](Places-and-Search)
|
||||
- [Admin-Panel-Overview](Admin-Panel-Overview)
|
||||
@@ -0,0 +1,50 @@
|
||||
# Admin — GitHub Releases
|
||||
|
||||
The **GitHub** tab shows the TREK release history fetched from GitHub and provides links to community resources and support options.
|
||||
|
||||
<!-- TODO: screenshot: GitHub releases panel with release timeline -->
|
||||
|
||||

|
||||
|
||||
## Support and resources
|
||||
|
||||
Six cards at the top of the tab link to external resources:
|
||||
|
||||
| Card | Link |
|
||||
|------|------|
|
||||
| **Ko-fi** | Support the project financially |
|
||||
| **Buy Me a Coffee** | Alternative support link |
|
||||
| **Discord** | Join the TREK community |
|
||||
| **Report a Bug** | Open a GitHub issue with the bug report template |
|
||||
| **Feature Request** | Open a GitHub Discussion in the feature requests category |
|
||||
| **Wiki** | Open the GitHub Wiki |
|
||||
|
||||
## Release timeline
|
||||
|
||||
Below the support cards, a chronological timeline lists GitHub releases for the `mauriceboe/TREK` repository. Each entry shows:
|
||||
|
||||
- **Version tag** (e.g., `v2.9.14`)
|
||||
- A **Latest** badge on the first (most recent) entry in the displayed list
|
||||
- **Release date** and author
|
||||
- A **Show details / Hide details** toggle that expands the release notes (Markdown rendered inline)
|
||||
|
||||
When the running server version is a stable release, pre-release entries are filtered out of the timeline.
|
||||
|
||||
Releases load 10 at a time. Click **Load more** at the bottom of the timeline to fetch additional pages.
|
||||
|
||||
If the admin API request fails, the timeline section shows an error message. If the server cannot reach the GitHub API, the timeline displays no releases (the server returns an empty list rather than an error).
|
||||
|
||||
## Version check
|
||||
|
||||
The server checks for available updates daily at 9 AM (server timezone, defaults to UTC) and sends an admin notification when a newer version is published. When an update is available, a banner also appears at the top of the Admin page on next load.
|
||||
|
||||
Results are cached for 5 minutes to avoid repeated API calls.
|
||||
|
||||
## When to check
|
||||
|
||||
Review the GitHub tab before performing an upgrade to read the release notes for any versions between your current install and the target version. See [Updating](Updating) for the upgrade procedure.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Updating](Updating)
|
||||
- [Admin-Panel-Overview](Admin-Panel-Overview)
|
||||
@@ -0,0 +1,48 @@
|
||||
# Admin — MCP Tokens
|
||||
|
||||
The **MCP Access** panel shows all active MCP OAuth sessions and API tokens across every user on the instance. As an admin you can revoke sessions and delete tokens.
|
||||
|
||||
This panel is only visible when the **MCP addon** is enabled in the [Admin-Addons](Admin-Addons) panel.
|
||||
|
||||
<!-- TODO: screenshot: OAuth client and token list in MCP Access panel -->
|
||||
|
||||

|
||||
|
||||
## OAuth Sessions
|
||||
|
||||
OAuth sessions are created when a user authorizes an MCP client via the OAuth 2.1 flow. These are the recommended way to connect MCP clients to TREK.
|
||||
|
||||
**Columns:**
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| Client name | The name of the registered OAuth client. Granted scopes are shown as badges below the name (up to 6 are shown; click "+N more" to expand) |
|
||||
| Owner | The username of the user who authorized the session |
|
||||
| Created | Date the session was established |
|
||||
| (actions) | Trash icon button to revoke |
|
||||
|
||||
**Revoking a session:** Click the trash icon on the row and confirm. The session is invalidated immediately and the revocation is recorded in the audit log. The user's MCP client will need to re-authorize before it can make further requests.
|
||||
|
||||
OAuth access tokens use the prefix `trekoa_`.
|
||||
|
||||
## API Tokens
|
||||
|
||||
API tokens are long-lived tokens that users create in their personal settings. They are identified by the `trek_` prefix.
|
||||
|
||||
**Columns:**
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| Token name | The label the user gave the token, with its truncated prefix shown below |
|
||||
| Owner | The username of the user who created it |
|
||||
| Created | Date the token was created |
|
||||
| Last used | Date of the most recent API call using this token, or "Never" if unused |
|
||||
| (actions) | Trash icon button to delete |
|
||||
|
||||
**Deleting a token:** Click the trash icon and confirm. The token is invalidated immediately. The user must create a new token in their settings if they still need access.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [MCP-Overview](MCP-Overview)
|
||||
- [MCP-Setup](MCP-Setup)
|
||||
- [Admin-Panel-Overview](Admin-Panel-Overview)
|
||||
@@ -0,0 +1,65 @@
|
||||
# Admin — Packing Templates
|
||||
|
||||
The **Personalization** tab → **Packing Templates** section lets you create reusable packing list templates that users can apply to any trip.
|
||||
|
||||
<!-- TODO: screenshot: packing templates admin list with categories and items -->
|
||||
|
||||

|
||||
|
||||
## What templates are
|
||||
|
||||
A packing template is a three-level hierarchy:
|
||||
|
||||
```
|
||||
Template
|
||||
└── Category
|
||||
└── Item
|
||||
```
|
||||
|
||||
When a user applies a template to a trip, all categories and items from that template are copied into the trip's packing list.
|
||||
|
||||
## Template list
|
||||
|
||||
The template list shows each template as a collapsible row displaying:
|
||||
|
||||
- Template name
|
||||
- Category count and item count (e.g., `3 categories · 12 items`)
|
||||
- Edit (rename) and delete buttons
|
||||
|
||||
## Creating a template
|
||||
|
||||
1. Click **New Template** (top-right of the panel).
|
||||
2. Type a name and press **Enter** or click the confirm button.
|
||||
3. The new template is added to the list and automatically expanded.
|
||||
|
||||
## Adding categories to a template
|
||||
|
||||
With a template expanded, click **Add category** (dashed border button at the bottom of the expanded section). Type a category name and press **Enter** or click confirm.
|
||||
|
||||
## Adding items to a category
|
||||
|
||||
Click the `+` button inside any category header. An inline input appears below the last item. Type an item name and press **Enter** to add it. You can add multiple items in sequence without closing the input — press **Enter** after each one.
|
||||
|
||||
## Editing inline
|
||||
|
||||
All editing is inline:
|
||||
|
||||
- **Rename a template** — click the pencil icon on the template row. The name becomes an input; press **Enter** or click away to save.
|
||||
- **Rename a category** — click the pencil icon in the category header. Press **Enter** or click away to save.
|
||||
- **Rename an item** — hover the item row to reveal the pencil icon, then click it. Press **Enter** or click the confirm button to save (clicking away does not save).
|
||||
|
||||
## Deleting
|
||||
|
||||
- **Delete a template** — click the trash icon on the template row. The template is removed. This does not affect trips that already had items from this template applied.
|
||||
- **Delete a category** — click the trash icon in the category header. All items in that category are also deleted from the template.
|
||||
- **Delete an item** — hover the item row to reveal the trash icon.
|
||||
|
||||
## Applying templates to a trip
|
||||
|
||||
Users apply templates through the **Packing** panel inside the trip planner. See [Packing-Templates](Packing-Templates) for user-facing documentation.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Packing-Templates](Packing-Templates)
|
||||
- [Packing-Lists](Packing-Lists)
|
||||
- [Admin-Panel-Overview](Admin-Panel-Overview)
|
||||
@@ -0,0 +1,39 @@
|
||||
# Admin Panel Overview
|
||||
|
||||
The Admin Panel is the central control surface for TREK instance operators. It is only accessible to users with the `admin` role.
|
||||
|
||||
## Accessing the Admin Panel
|
||||
|
||||
Navigate to the **Admin** link in the top navbar. If you do not see it, your account does not have admin privileges.
|
||||
|
||||
<!-- TODO: screenshot: admin panel main dashboard with tabs -->
|
||||
|
||||

|
||||
|
||||
## Tabs
|
||||
|
||||
The Admin Panel is divided into tabs. Most tabs are always visible; a few appear only under specific conditions.
|
||||
|
||||
| Tab | Purpose | Conditional? |
|
||||
|-----|---------|--------------|
|
||||
| **Users** | Manage users, invite links, and permissions | No |
|
||||
| **Personalization** | Packing templates and place categories | No |
|
||||
| **User Defaults** | Default settings applied to new users | No |
|
||||
| **Addons** | Enable or disable optional features instance-wide | No |
|
||||
| **Settings** | Authentication methods, MFA, allowed file types, API keys, OIDC/SSO configuration, and JWT secret rotation | No |
|
||||
| **Notifications** | SMTP, webhook, ntfy, and push notification channel configuration; trip reminder toggle; admin notification preferences | No |
|
||||
| **Backup** | Manual and scheduled database backups | No |
|
||||
| **Audit** | Chronological activity log | No |
|
||||
| **MCP Access** | OAuth sessions and static API tokens | Only when the MCP addon is enabled |
|
||||
| **GitHub** | Release timeline and support links | No |
|
||||
| **Dev: Notifications** | Test notification dispatch | Only in development mode (`NODE_ENV=development`) |
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Admin-Users-and-Invites](Admin-Users-and-Invites)
|
||||
- [Admin-Addons](Admin-Addons)
|
||||
- [Admin-Categories](Admin-Categories)
|
||||
- [Admin-Packing-Templates](Admin-Packing-Templates)
|
||||
- [Admin-Permissions](Admin-Permissions)
|
||||
- [Admin-MCP-Tokens](Admin-MCP-Tokens)
|
||||
- [Admin-GitHub-Releases](Admin-GitHub-Releases)
|
||||
@@ -0,0 +1,76 @@
|
||||
# Admin — Permissions
|
||||
|
||||
The Permissions panel, located at the bottom of the **Users** tab, controls which role level is required to perform each action. Changes apply immediately across the entire instance.
|
||||
|
||||
<!-- TODO: screenshot: permissions matrix with role dropdowns -->
|
||||
|
||||

|
||||
|
||||
## Role model
|
||||
|
||||
TREK uses four permission levels, ordered from most to least privileged:
|
||||
|
||||
| Level | Who it includes |
|
||||
|-------|----------------|
|
||||
| `admin` | Instance administrators only |
|
||||
| `trip_owner` | The user who created the trip |
|
||||
| `trip_member` | Any user who is a member of the trip |
|
||||
| `everybody` | Any authenticated user (for `trip_create`: no trip context required; for all other actions: any authenticated user with trip access) |
|
||||
|
||||
Each action is assigned a minimum required level. A user whose role is at or above that level can perform the action. Not every level is available for every action — each action exposes only the levels that make sense for it. For example, `trip_create` only allows `everybody` or `admin`, while `trip_edit` only allows `trip_owner` or `trip_member`.
|
||||
|
||||
## Action categories
|
||||
|
||||
Actions are grouped into five categories:
|
||||
|
||||
### Trip
|
||||
|
||||
| Action key | What it controls |
|
||||
|------------|-----------------|
|
||||
| `trip_create` | Create a new trip |
|
||||
| `trip_edit` | Edit trip name, dates, description, and currency |
|
||||
| `trip_delete` | Permanently delete a trip |
|
||||
| `trip_archive` | Archive or unarchive a trip |
|
||||
| `trip_cover_upload` | Upload or change the cover image for a trip |
|
||||
|
||||
### Members
|
||||
|
||||
| Action key | What it controls |
|
||||
|------------|-----------------|
|
||||
| `member_manage` | Invite or remove trip members |
|
||||
|
||||
### Files
|
||||
|
||||
| Action key | What it controls |
|
||||
|------------|-----------------|
|
||||
| `file_upload` | Upload files to a trip |
|
||||
| `file_edit` | Edit file descriptions and links |
|
||||
| `file_delete` | Move files to trash or permanently delete them |
|
||||
|
||||
### Content & Schedule
|
||||
|
||||
| Action key | What it controls |
|
||||
|------------|-----------------|
|
||||
| `place_edit` | Add, edit, or delete places |
|
||||
| `day_edit` | Edit days, day notes, and place assignments |
|
||||
| `reservation_edit` | Create, edit, or delete reservations |
|
||||
|
||||
### Budget, Packing & Collaboration
|
||||
|
||||
| Action key | What it controls |
|
||||
|------------|-----------------|
|
||||
| `budget_edit` | Create, edit, or delete budget items |
|
||||
| `packing_edit` | Manage packing items and bags |
|
||||
| `collab_edit` | Create notes, polls, and send messages |
|
||||
| `share_manage` | Create or delete public share links |
|
||||
|
||||
## Changing permissions
|
||||
|
||||
Each action row has a dropdown. Select the minimum role level required. A **customized** badge appears next to any action that has been changed from its default.
|
||||
|
||||
Click **Save** (top-right of the panel) to persist your changes. Use the **Reset to defaults** button (circular arrow icon) to revert all actions to their shipped defaults without saving — you still need to click **Save** after resetting if you want to persist the reset state.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Admin-Panel-Overview](Admin-Panel-Overview)
|
||||
- [Admin-Users-and-Invites](Admin-Users-and-Invites)
|
||||
@@ -0,0 +1,90 @@
|
||||
# Admin — Users and Invites
|
||||
|
||||
The **Users** tab in the Admin Panel lets you view all registered users, manage their accounts, and create invite links so new people can register without open registration.
|
||||
|
||||
<!-- TODO: screenshot: users table with invite form -->
|
||||
|
||||

|
||||
|
||||
## User list
|
||||
|
||||
The user table shows every registered account with the following columns:
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| **User** | Avatar, username, and an always-visible presence dot (green = online, grey = offline) |
|
||||
| **Email** | Account email address |
|
||||
| **Role** | Badge showing `admin` or `user` |
|
||||
| **Created** | Account creation date |
|
||||
| **Last Login** | Date and time of most recent login |
|
||||
| **Actions** | Edit and delete buttons |
|
||||
|
||||
Your own account row is highlighted. You cannot delete your own account.
|
||||
|
||||
## User actions
|
||||
|
||||
### Edit a user
|
||||
|
||||
Click the pencil icon on any row to open the edit form. You can change:
|
||||
|
||||
- **Username**
|
||||
- **Email address**
|
||||
- **Role** — toggle between `user` and `admin`
|
||||
- **Password** — set a new password; must be at least 8 characters
|
||||
|
||||
Click **Save** to apply changes.
|
||||
|
||||
### Delete a user
|
||||
|
||||
Click the trash icon and confirm. Deletion is permanent. The user's account is removed from the database along with their data (cascade behavior is enforced at the database level).
|
||||
|
||||
You cannot delete your own account while logged in as that user.
|
||||
|
||||
## Creating a user directly
|
||||
|
||||
Click **Create User** (top-right of the Users tab) to create an account without an invite link. You set the username, email, password, and role at creation time.
|
||||
|
||||
## Invite links
|
||||
|
||||
Invite links let a specific number of people register themselves. This is useful when open registration is disabled.
|
||||
|
||||

|
||||
|
||||
### Creating an invite
|
||||
|
||||
Click **Create Invite** (invite links section, below the user table). Configure:
|
||||
|
||||
- **Max uses** — how many times the link can be used before it expires: `1×`, `2×`, `3×`, `4×`, `5×`, or `∞` (unlimited). Defaults to `1×`.
|
||||
- **Expiry** — how long the link remains valid: `1d`, `3d`, `7d`, `14d`, or `∞` (no expiry). Defaults to `7d`.
|
||||
|
||||
After creation the link is copied to your clipboard automatically. Share it with the intended recipient. The URL format is:
|
||||
|
||||
```
|
||||
<APP_URL>/register?invite=<token>
|
||||
```
|
||||
|
||||
### Invite list
|
||||
|
||||
Existing invites are listed below the creation button. Each row shows:
|
||||
|
||||
- The invite token (truncated, monospace)
|
||||
- A status badge — `active`, `used up`, or `expired`
|
||||
- **Usage** — `used / max` (or `used / ∞` for unlimited)
|
||||
- **Expiry** date, if set
|
||||
- **Created by** — the admin who generated the link
|
||||
- A **copy link** button (only shown for active invites)
|
||||
- A **delete** (revoke) button
|
||||
|
||||
Revoking an invite immediately invalidates it; anyone following the link after revocation will receive an error.
|
||||
|
||||
## Permissions
|
||||
|
||||
The **Users** tab also hosts the Permissions panel at the bottom, which controls what roles can perform which actions. See [Admin-Permissions](Admin-Permissions) for details.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Login-and-Registration](Login-and-Registration)
|
||||
- [Invite-Links](Invite-Links)
|
||||
- [Admin-Permissions](Admin-Permissions)
|
||||
- [Two-Factor-Authentication](Two-Factor-Authentication)
|
||||
- [Admin-Panel-Overview](Admin-Panel-Overview)
|
||||
@@ -0,0 +1,55 @@
|
||||
# Atlas
|
||||
|
||||
Atlas is an interactive world map that shows countries and regions you have visited across all your trips, together with a bucket list of places you want to go.
|
||||
|
||||
> **Admin:** enable Atlas in [Admin-Addons](Admin-Addons).
|
||||
|
||||
<!-- TODO: screenshot: world map with visited countries highlighted -->
|
||||
|
||||

|
||||
|
||||
## What Atlas is
|
||||
|
||||
Atlas gives you a visual overview of your travel footprint. Visited countries are highlighted on the map. You can also mark individual sub-national regions and maintain a personal bucket list of future destinations.
|
||||
|
||||
## Accessing Atlas
|
||||
|
||||
When the admin has enabled the Atlas addon, an **Atlas** entry appears in the main navigation. Your visited countries are populated automatically from your existing trips.
|
||||
|
||||
## Marking countries as visited
|
||||
|
||||
Click any country on the map to open an action popup where you can mark it as visited or add it to your bucket list. Use the search bar at the top of the map to find and fly to a country — pressing Enter or selecting a result from the dropdown opens the same action popup.
|
||||
|
||||
To remove a manually-marked country (one with no trips or places recorded in it), click it on the map and confirm removal in the popup.
|
||||
|
||||
Visits detected automatically from your trips are shown in addition to any countries you mark manually.
|
||||
|
||||
### Sub-national regions
|
||||
|
||||
At zoom level 5 and above, the map switches to a sub-national region view (states, provinces, etc.). You can mark individual regions as visited or add them to your bucket list. Marking a region also counts the parent country as visited if it was not already.
|
||||
|
||||
## Bucket list
|
||||
|
||||
The bucket list is separate from "visited". Use it to track countries or places you want to visit in the future. Each bucket list item can have a name, coordinates, country code, optional notes, and a target date.
|
||||
|
||||
## Statistics
|
||||
|
||||
Your Atlas statistics panel shows:
|
||||
|
||||
- **Countries visited** — total number of distinct countries.
|
||||
- **Trips** — total number of trips across all time.
|
||||
- **Places** — total number of individual places logged in trips.
|
||||
- **Cities** — total number of distinct cities visited.
|
||||
- **Travel days** — total days spent travelling.
|
||||
- **Continent breakdown** — number of countries visited per continent (Europe, Asia, North America, South America, Africa, Oceania).
|
||||
- **Travel streak** — number of consecutive years in which you have taken at least one trip.
|
||||
- **Trips this year** — number of trips in the current calendar year.
|
||||
|
||||
## Visual effect
|
||||
|
||||
The desktop glass panel at the bottom of the map uses a liquid-glass visual effect — a dynamic inner glow and border highlight that follows your cursor across the panel.
|
||||
|
||||
## See also
|
||||
|
||||
- [Addons-Overview](Addons-Overview)
|
||||
- [Admin-Addons](Admin-Addons)
|
||||
@@ -0,0 +1,138 @@
|
||||
# Audit Log
|
||||
|
||||
The audit log records significant actions taken on your TREK instance. Use it to monitor logins, admin changes, and integration configuration.
|
||||
|
||||
## Where to find it
|
||||
|
||||
**Admin Panel → Audit** tab.
|
||||
|
||||
<!-- TODO: screenshot: audit log table with action entries -->
|
||||
|
||||

|
||||
|
||||
## What the log captures
|
||||
|
||||
Actions are grouped by area below. The **Action key** is the raw value stored in the log.
|
||||
|
||||
### Authentication
|
||||
|
||||
| Action key | Description |
|
||||
|---|---|
|
||||
| `user.register` | User registered |
|
||||
| `user.login` | User logged in |
|
||||
| `user.login_failed` | Login attempt failed |
|
||||
| `user.password_change` | User changed their password |
|
||||
| `user.account_delete` | User deleted their account |
|
||||
|
||||
### MFA
|
||||
|
||||
| Action key | Description |
|
||||
|---|---|
|
||||
| `user.mfa_enable` | MFA enabled on an account |
|
||||
| `user.mfa_disable` | MFA disabled on an account |
|
||||
|
||||
### Trips
|
||||
|
||||
| Action key | Description |
|
||||
|---|---|
|
||||
| `trip.create` | Trip created (includes title) |
|
||||
| `trip.update` | Trip updated (includes changed fields) |
|
||||
| `trip.copy` | Trip duplicated (includes source and new trip IDs) |
|
||||
| `trip.delete` | Trip deleted (includes trip ID and title) |
|
||||
|
||||
### Admin actions
|
||||
|
||||
| Action key | Description |
|
||||
|---|---|
|
||||
| `admin.user_create` | User created by admin |
|
||||
| `admin.user_update` | User edited by admin (role, email, username, etc.) |
|
||||
| `admin.user_delete` | User deleted by admin |
|
||||
| `admin.invite_create` | Invite link created |
|
||||
| `admin.invite_delete` | Invite link deleted |
|
||||
| `admin.permissions_update` | Instance permissions updated |
|
||||
| `admin.oidc_update` | OIDC/SSO settings updated |
|
||||
| `admin.addon_update` | Addon enabled, disabled, or configured |
|
||||
| `admin.oauth_session.revoke` | OAuth session revoked by admin |
|
||||
| `admin.rotate_jwt_secret` | JWT secret rotated |
|
||||
| `admin.bag_tracking` | Bag tracking feature toggled |
|
||||
| `admin.places_photos` | Places photos feature toggled |
|
||||
| `admin.places_autocomplete` | Places autocomplete feature toggled |
|
||||
| `admin.places_details` | Places details feature toggled |
|
||||
| `admin.collab_features` | Collaboration features updated |
|
||||
| `admin.packing_template_delete` | Packing template deleted |
|
||||
| `admin.default_user_settings_update` | Default user settings updated |
|
||||
| `admin.demo_baseline_save` | Demo baseline snapshot saved |
|
||||
| `settings.app_update` | App settings updated (SMTP, webhooks, MFA policy, etc.) |
|
||||
|
||||
### Backups
|
||||
|
||||
| Action key | Description |
|
||||
|---|---|
|
||||
| `backup.create` | Manual backup created |
|
||||
| `backup.restore` | Restore from stored backup |
|
||||
| `backup.upload_restore` | Restore from uploaded ZIP |
|
||||
| `backup.delete` | Backup deleted |
|
||||
| `backup.auto_settings` | Auto-backup schedule saved |
|
||||
|
||||
### MCP
|
||||
|
||||
| Action key | Description |
|
||||
|---|---|
|
||||
| `mcp.tool_call` | MCP tool invoked (resource = tool name) |
|
||||
|
||||
### OAuth
|
||||
|
||||
| Action key | Description |
|
||||
|---|---|
|
||||
| `oauth.client.create` | OAuth client application created |
|
||||
| `oauth.client.rotate_secret` | OAuth client secret rotated |
|
||||
| `oauth.client.delete` | OAuth client application deleted |
|
||||
| `oauth.consent.grant` | User granted OAuth consent |
|
||||
| `oauth.token.issue` | OAuth access token issued |
|
||||
| `oauth.token.refresh` | OAuth access token refreshed |
|
||||
| `oauth.token.revoke` | OAuth token revoked |
|
||||
| `oauth.token.grant_failed` | OAuth token grant attempt failed |
|
||||
| `oauth.token.client_auth_failed` | OAuth client authentication failed |
|
||||
|
||||
### Integrations
|
||||
|
||||
| Action key | Description |
|
||||
|---|---|
|
||||
| `immich.private_ip_configured` | Immich URL saved that resolves to a private IP |
|
||||
|
||||
## Log columns
|
||||
|
||||
| Column | Description |
|
||||
|---|---|
|
||||
| Time | Timestamp of the action |
|
||||
| User | Username and email of the acting user (or `anonymous` for unauthenticated events) |
|
||||
| Action | Action key (see tables above) |
|
||||
| Resource | Affected resource (filename, trip ID, tool name, etc.) where applicable |
|
||||
| IP | Client IP address |
|
||||
| Details | Additional context in JSON format |
|
||||
|
||||
## Pagination
|
||||
|
||||
The panel loads 100 entries at a time by default. Click **Load more** at the bottom to fetch the next page. The total count is shown above the table.
|
||||
|
||||
## IP addresses
|
||||
|
||||
The client IP is read from the `X-Forwarded-For` header. When TREK is behind a reverse proxy, set `TRUST_PROXY=true` so the header is trusted and the real client IP is recorded. Without this setting, the proxy's own IP is logged instead. See [Environment-Variables](Environment-Variables).
|
||||
|
||||
## Log file
|
||||
|
||||
In addition to the database, audit events are written to a plain-text log file:
|
||||
|
||||
- **Path:** `./data/logs/trek.log`
|
||||
- **Rotation:** rotated when the file reaches 10 MB
|
||||
- **Retention:** the 4 most recent rotated files are kept (`trek.log.1` through `trek.log.4`)
|
||||
|
||||
## Database retention
|
||||
|
||||
Audit entries in the database are never automatically deleted. They accumulate and are paginated in the UI.
|
||||
|
||||
## See also
|
||||
|
||||
- [Admin-Panel-Overview](Admin-Panel-Overview)
|
||||
- [Security-Hardening](Security-Hardening)
|
||||
- [Environment-Variables](Environment-Variables)
|
||||
@@ -0,0 +1,88 @@
|
||||
# Backups
|
||||
|
||||
TREK stores all data in a single SQLite database (`travel.db`) plus an `uploads/` directory of attachments, cover photos, and avatars. The Backup panel lets you create, download, restore, and schedule backups of both.
|
||||
|
||||
## Where to find it
|
||||
|
||||
**Admin Panel → Backup** tab.
|
||||
|
||||
<!-- TODO: screenshot: backup tab with backup list and auto-backup settings -->
|
||||
|
||||

|
||||
|
||||
## What a backup contains
|
||||
|
||||
A backup is a ZIP archive with two entries:
|
||||
|
||||
| Entry | Contents |
|
||||
|---|---|
|
||||
| `travel.db` | The full SQLite database |
|
||||
| `uploads/` | All uploaded attachments, covers, and avatars |
|
||||
|
||||
**Not included:** the encryption key. Store your `ENCRYPTION_KEY` separately from the backup ZIP — for example, in a password manager. See [Encryption-Key-Rotation](Encryption-Key-Rotation).
|
||||
|
||||
## Manual backup
|
||||
|
||||
Click **Create Backup** in the Backup tab. The server creates the ZIP and makes it available for download. Up to 3 manual backups can be created per hour per IP address (rate-limit window: 1 hour).
|
||||
|
||||
You can also download or delete any existing backup from the list.
|
||||
|
||||
## Restoring a backup
|
||||
|
||||
You can restore from:
|
||||
|
||||
- **A stored backup** — click **Restore** next to any backup in the list.
|
||||
- **An uploaded ZIP** — click **Upload & Restore** and select a backup file from your computer (maximum upload size: 500 MB).
|
||||
|
||||
Before restoring, TREK runs integrity checks on the uploaded database:
|
||||
|
||||
1. **SQLite `PRAGMA integrity_check`** — verifies the database file is not corrupt.
|
||||
2. **Required tables present** — confirms the file contains `users`, `trips`, `trip_members`, `places`, and `days`. Files missing any of these are rejected as not being a valid TREK backup.
|
||||
|
||||
> **Warning:** Restoring replaces all current data. Back up your current state first if you want to keep it.
|
||||
|
||||
## Auto-backup
|
||||
|
||||
Enable scheduled backups in the **Auto-Backup** section of the Backup tab.
|
||||
|
||||
**Interval** options:
|
||||
|
||||
- Hourly
|
||||
- Daily
|
||||
- Weekly
|
||||
- Monthly
|
||||
|
||||
**Retention** (`Keep last … days`) — enter a number of days. Backups older than that many days are pruned after each auto-backup run. Set to **0** to keep all backups indefinitely (no pruning).
|
||||
|
||||
**Schedule** options (depend on interval):
|
||||
|
||||
- **Hour** — time of day for daily, weekly, and monthly backups (0–23).
|
||||
- **Day of week** — Sunday through Saturday (for weekly backups).
|
||||
- **Day of month** — 1–28 (for monthly backups). Day 29–31 is excluded to avoid months with fewer days.
|
||||
|
||||
Auto-backup files are named `auto-backup-<timestamp>.zip` (manual backups use `backup-<timestamp>.zip`).
|
||||
|
||||
After each auto-backup run, **all** backup files (manual and auto) older than `keep_days` are pruned. Set `keep_days` to `0` to disable pruning entirely.
|
||||
|
||||
## Before updating TREK
|
||||
|
||||
Always create a manual backup before updating. See [Updating](Updating).
|
||||
|
||||
## Audit log
|
||||
|
||||
The following actions are recorded in the [Audit-Log](Audit-Log):
|
||||
|
||||
| Action key | When |
|
||||
|---|---|
|
||||
| `backup.create` | Manual backup created |
|
||||
| `backup.restore` | Restore from stored backup |
|
||||
| `backup.upload_restore` | Restore from uploaded ZIP |
|
||||
| `backup.delete` | Backup deleted |
|
||||
| `backup.auto_settings` | Auto-backup settings saved |
|
||||
|
||||
## See also
|
||||
|
||||
- [Encryption-Key-Rotation](Encryption-Key-Rotation)
|
||||
- [Admin-Panel-Overview](Admin-Panel-Overview)
|
||||
- [Security-Hardening](Security-Hardening)
|
||||
- [Updating](Updating)
|
||||
@@ -0,0 +1,83 @@
|
||||
# Budget Tracking
|
||||
|
||||
Track trip expenses by category, split costs between members, and visualize spending.
|
||||
|
||||
<!-- TODO: screenshot: budget summary and expense list -->
|
||||
|
||||

|
||||
|
||||
## Where to find it
|
||||
|
||||
Open the **Budget** tab inside the trip planner. The tab is only visible when the Budget addon is enabled.
|
||||
|
||||
> **Admin:** Budget is an addon. Enable it in [Admin-Addons](Admin-Addons).
|
||||
|
||||
## Currency
|
||||
|
||||
Use the currency picker in the Budget toolbar to select one currency for the entire trip. 46 currencies are supported (EUR, USD, GBP, JPY, CHF, CZK, PLN, SEK, NOK, DKK, TRY, THB, AUD, CAD, NZD, BRL, MXN, INR, IDR, MYR, PHP, SGD, KRW, CNY, HKD, TWD, ZAR, AED, SAR, ILS, EGP, MAD, HUF, RON, BGN, HRK, ISK, RUB, UAH, BDT, LKR, VND, CLP, COP, PEN, ARS). All amounts are displayed in this currency.
|
||||
|
||||
## Categories
|
||||
|
||||
Expenses are grouped into categories. Each category is shown with a small colored square indicator that cycles through a 12-color palette as you add more categories.
|
||||
|
||||
From the toolbar you can:
|
||||
|
||||
- **Add a category** — type a name and click the **+** button (or press Enter).
|
||||
- **Rename a category** — click the pencil icon next to its name in the category header.
|
||||
- **Reorder categories** — drag the grip handle on the left of the category header.
|
||||
- **Delete a category** — click the trash icon in the category header. This deletes all expense items inside it.
|
||||
|
||||
## Expense items
|
||||
|
||||
Each category contains a table of items with the following columns:
|
||||
|
||||
| Column | Notes |
|
||||
|---|---|
|
||||
| Name | Editable inline. Read-only when linked to a reservation. |
|
||||
| Total | The total cost for this item. |
|
||||
| Persons | Number of persons (or member chips on multi-member trips). |
|
||||
| Days | Number of days. |
|
||||
| Per Person | Calculated: Total ÷ Persons. |
|
||||
| Per Day | Calculated: Total ÷ Days. |
|
||||
| Per Person/Day | Calculated: Total ÷ (Persons × Days). |
|
||||
| Date | Optional expense date. |
|
||||
| Note | Free-text note. |
|
||||
|
||||
Click any editable cell to edit it inline. Drag the grip handle to reorder items within a category.
|
||||
|
||||
Add a new item using the inline **add row** at the bottom of each category table.
|
||||
|
||||
## Splitting costs
|
||||
|
||||
The **Persons** column behaves differently depending on the trip:
|
||||
|
||||
- **Single-user trip** — enter a number of persons directly.
|
||||
- **Multi-member trip** — a member chip picker appears. Click the edit button to assign or remove members from an expense. Click an assigned member chip again to mark them as **paid** (the chip shows a green ring).
|
||||
|
||||
## Settlement calculator
|
||||
|
||||
When multiple members are assigned to expenses and there are outstanding debts between members, a collapsible **Settlement** section appears inside the total card. Click the section header to expand it. It shows the minimum number of transfers needed to settle all debts (using a greedy matching algorithm), including:
|
||||
|
||||
- Transfer flows: who pays whom and how much.
|
||||
- Net balances: each member's overall surplus or deficit.
|
||||
|
||||
## Budget summary
|
||||
|
||||
The right-hand column contains two widgets:
|
||||
|
||||
- **Total card** — displays the grand total in large type. On multi-member trips it also shows a per-member breakdown with a proportional bar.
|
||||
- **Donut chart** — spending by category. Each segment uses that category's color. The legend always shows the amount and percentage for each category; hovering a legend row highlights it.
|
||||
|
||||
## Exporting
|
||||
|
||||
Click the **CSV** button in the toolbar to download a semicolon-delimited file containing all categories and items. The columns exported are: Category, Name, Date, Total, Persons, Days, Per Person, Per Day, Per Person/Day, Note.
|
||||
|
||||
## Permissions
|
||||
|
||||
All write operations (adding/editing/deleting items and categories, changing currency) require the `budget_edit` permission.
|
||||
|
||||
## See also
|
||||
|
||||
- [Admin-Addons](Admin-Addons)
|
||||
- [Reservations-and-Bookings](Reservations-and-Bookings)
|
||||
- [Trip-Planner-Overview](Trip-Planner-Overview)
|
||||
@@ -0,0 +1,59 @@
|
||||
# Collab Chat
|
||||
|
||||
Chat with your group in real time, without leaving the trip planner.
|
||||
|
||||
<!-- TODO: screenshot: chat panel with messages and reactions -->
|
||||
|
||||

|
||||
|
||||
## Where to find it
|
||||
|
||||
Open the trip planner and select the **Collab** tab. If the Chat sub-feature is enabled, a Chat panel appears — on desktop as the left column, on mobile as the first tab in the tab bar.
|
||||
|
||||
The Collab addon must be enabled by an admin and the Chat sub-feature must be turned on. See [Real-Time-Collaboration](Real-Time-Collaboration).
|
||||
|
||||
## Sending messages
|
||||
|
||||
Type in the input field at the bottom and press **Enter** (or click the send button) to post. Hold **Shift + Enter** to insert a line break without sending.
|
||||
|
||||
Messages load in pages of 100. A **Load more** button appears at the top of the chat when older messages are available.
|
||||
|
||||
## Emoji
|
||||
|
||||
Click the smiley-face button in the composer to open the emoji picker. The picker has three categories:
|
||||
|
||||
- **Smileys** — facial expressions and gestures
|
||||
- **Reactions** — hearts, fire, thumbs, and similar
|
||||
- **Travel** — planes, maps, food, cameras, and destinations
|
||||
|
||||
Emoji are rendered via Twemoji for consistent appearance across platforms.
|
||||
|
||||
## Reactions
|
||||
|
||||
**Right-click** a message on desktop (or **double-tap** on mobile) to open the quick-reaction menu. Eight quick reactions are available: ❤️ 😂 👍 😮 😢 🔥 👏 🎉. Click any reaction to toggle it on or off. Reactions from all users aggregate beneath the message; hover a reaction badge to see who reacted.
|
||||
|
||||
## Replies
|
||||
|
||||
Hover a message to reveal the action buttons. Click **Reply** to quote that message. A preview of the quoted text appears above your new message in the composer; click the **×** to cancel the reply. The quoted text is displayed inline inside your bubble when sent.
|
||||
|
||||
## URL link previews
|
||||
|
||||
When a message contains a URL, TREK automatically fetches an Open Graph preview (title, description, and thumbnail image) and displays it below the message text. Only the first URL in a message generates a preview.
|
||||
|
||||
## Message styling
|
||||
|
||||
Your own messages appear **right-aligned** with a blue bubble. Other members' messages appear **left-aligned** with a gray bubble. The username is shown above the first message in a group of consecutive messages from the same person; the avatar is shown beside the **last** message in that group. Timestamps appear below the last message in each group.
|
||||
|
||||
Messages that consist of only 1–3 emoji are displayed larger without a bubble.
|
||||
|
||||
## Deleting messages
|
||||
|
||||
Hover your own message to reveal the delete button (trash icon). The delete button is only visible when you have the `collab_edit` permission. Deleting replaces the bubble with an italicised notice — showing your username, "deleted a message", and a timestamp — visible to all members.
|
||||
|
||||
## Read-only viewers
|
||||
|
||||
Users without the `collab_edit` permission can read all messages but the composer is disabled — they cannot send, react, or delete messages. The reply button is visible to all users, but completing a reply still requires `collab_edit` (the send button is hidden for read-only users).
|
||||
|
||||
## Related pages
|
||||
|
||||
[Real-Time-Collaboration](Real-Time-Collaboration) · [Collab-Notes](Collab-Notes) · [Collab-Polls](Collab-Polls)
|
||||
@@ -0,0 +1,72 @@
|
||||
# Collab Notes
|
||||
|
||||
Share structured, richly formatted notes with your group. Notes are organized into color-coded categories and can be pinned, attached with files, and linked to external websites.
|
||||
|
||||
<!-- TODO: screenshot: collab notes editor with categories -->
|
||||
|
||||

|
||||
|
||||
## Where to find it
|
||||
|
||||
Open the trip planner → **Collab** tab → **Notes** section. The Collab addon must be enabled and the Notes sub-feature must be turned on. See [Real-Time-Collaboration](Real-Time-Collaboration).
|
||||
|
||||
## Categories
|
||||
|
||||
Notes are grouped into categories. Each category has a name and a color chosen from six swatches: **Indigo**, **Red**, **Amber**, **Emerald**, **Blue**, and **Violet**.
|
||||
|
||||
To manage categories, click the settings (gear) icon in the Notes header (only visible to users with the `collab_edit` permission). From the category settings modal you can:
|
||||
|
||||
- Add a new category (type a name and click **+**)
|
||||
- Rename a category by clicking its name inline
|
||||
- Change a category's color by clicking a swatch
|
||||
- Delete a category
|
||||
|
||||
All changes (including color updates) are staged locally and applied only when you click **Save** in the modal. Saving a color change updates every note in that category.
|
||||
|
||||
## Creating a note
|
||||
|
||||
Click **+ New Note** in the Notes header (only visible to users with the `collab_edit` permission). A modal opens with the following fields:
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| Title | Yes | Plain text |
|
||||
| Body | No | Markdown |
|
||||
| Category | No | Select from existing categories |
|
||||
| Website | No | URL; shows an Open Graph thumbnail on the note card |
|
||||
| Attachments | No | Files up to 50 MB; see restrictions below |
|
||||
|
||||
Click **Create** to save.
|
||||
|
||||
## Markdown support
|
||||
|
||||
Note bodies are rendered as GitHub Flavored Markdown with soft line breaks. Supported syntax includes headings, bold/italic, links, ordered and unordered lists, task lists, tables, fenced code blocks, and blockquotes.
|
||||
|
||||
## Pinning
|
||||
|
||||
Click the pin icon on a note card to pin it. Pinned notes sort to the top of the list (above all unpinned notes, regardless of category). Click again (shown as the unpin icon) to unpin.
|
||||
|
||||
## Attachments
|
||||
|
||||
You can attach images, PDFs, and other files to a note (requires the `file_upload` permission). Maximum file size is **50 MB** per file.
|
||||
|
||||
The following file types are blocked: `.svg`, `.html`, `.htm`, `.xml`, `.xhtml`, `.js`, `.jsx`, `.ts`, `.exe`, `.bat`, `.sh`, `.cmd`, `.msi`, `.dll`, `.com`, `.vbs`, `.ps1`, `.php`.
|
||||
|
||||
Image thumbnails are shown on the note card. Click a thumbnail to open a lightbox. PDFs open in a document viewer overlay.
|
||||
|
||||
You can also paste images or PDFs directly into the create/edit form (if the `file_upload` permission is granted).
|
||||
|
||||
## Editing a note
|
||||
|
||||
Click the pencil icon on a note card to open the edit modal. All fields, including attachments, can be updated. To remove an existing attachment, click the **×** next to it in the edit form.
|
||||
|
||||
## Expanding a note
|
||||
|
||||
Click the expand icon (arrows) on a note card to open a full-content view in a portal overlay. Users with the `collab_edit` permission will see a pencil button in the overlay header to switch directly to the edit modal.
|
||||
|
||||
## Filtering
|
||||
|
||||
Use the category filter pills below the Notes header to show only notes belonging to a specific category. Click **All** to clear the filter.
|
||||
|
||||
## Related pages
|
||||
|
||||
[Real-Time-Collaboration](Real-Time-Collaboration) · [Collab-Chat](Collab-Chat) · [Collab-Polls](Collab-Polls)
|
||||
@@ -0,0 +1,61 @@
|
||||
# Collab Polls
|
||||
|
||||
Create group polls to make decisions collaboratively — where to eat, which hotel, what to do on a free day.
|
||||
|
||||
<!-- TODO: screenshot: poll with options and vote counts -->
|
||||
|
||||

|
||||
|
||||
## Where to find it
|
||||
|
||||
Open the trip planner → **Collab** tab → **Polls** section. The Collab addon must be enabled and the Polls sub-feature must be turned on. See [Real-Time-Collaboration](Real-Time-Collaboration).
|
||||
|
||||
## Creating a poll
|
||||
|
||||
Users with `collab_edit` permission can click **+ New** in the Polls header. A modal opens with the following fields:
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| Question | Yes | The poll prompt |
|
||||
| Options | Yes | At least 2 options required; add more with **+ Add option** |
|
||||
| Multiple choice | No | Toggle on to allow voters to select more than one option |
|
||||
|
||||
Click **Create** to save and broadcast the poll to all connected members.
|
||||
|
||||
## Voting
|
||||
|
||||
Click an option button to vote. A filled circle and blue highlight indicate your selection. For **multiple-choice** polls, you can click additional options to select more than one; clicking an already-selected option removes your vote for that option. For **single-choice** polls, clicking a different option moves your vote to the new selection.
|
||||
|
||||
Percentage bars and voter avatars are only visible **after you have voted** or once the poll is closed or expired.
|
||||
|
||||
## Results
|
||||
|
||||
Each option shows:
|
||||
|
||||
- A **percentage fill bar** that expands proportionally to the votes received
|
||||
- Up to **3 voter avatar chips** stacked next to the option
|
||||
- The **percentage** of total votes in the right margin
|
||||
|
||||
The option with the most votes is highlighted when the poll is closed or expired.
|
||||
|
||||
## Deadline
|
||||
|
||||
Polls may have an optional deadline set via the API. When a deadline is present, a live countdown badge appears on the poll card, updating every **30 seconds**. The badge shows the remaining time in days/hours/minutes format.
|
||||
|
||||
When the deadline passes, the poll is automatically treated as closed: voting is disabled and results are displayed to everyone.
|
||||
|
||||
## Closing a poll manually
|
||||
|
||||
Users with `collab_edit` permission can click the lock icon on an open poll to close it immediately. Once closed, voting is permanently disabled.
|
||||
|
||||
## Deleting a poll
|
||||
|
||||
Users with `collab_edit` permission can delete a poll using the trash icon. Deletion is permanent and removes the poll for all members.
|
||||
|
||||
## Active and closed sections
|
||||
|
||||
Open polls appear at the top of the list. Closed or expired polls move to a **Closed** section below the active polls.
|
||||
|
||||
## Related pages
|
||||
|
||||
[Real-Time-Collaboration](Real-Time-Collaboration) · [Collab-Chat](Collab-Chat) · [Collab-Notes](Collab-Notes)
|
||||
@@ -0,0 +1,71 @@
|
||||
# Creating a Trip
|
||||
|
||||
<!-- TODO: screenshot: trip creation form with date and cover fields -->
|
||||
|
||||

|
||||
|
||||
## Opening the Dialog
|
||||
|
||||
Click the **New Trip** button in the dashboard toolbar (or the **Create First Trip** button on the empty state) to open the Create Trip dialog.
|
||||
|
||||
You can also open it directly via a deep link: navigate to `/dashboard?create=1`. This is the URL used by system notices that prompt you to create a trip.
|
||||
|
||||
## Fields
|
||||
|
||||
### Title (required)
|
||||
|
||||
The trip name. Cannot be empty — saving is blocked until a title is entered.
|
||||
|
||||
### Description (optional)
|
||||
|
||||
A short free-text description shown on the trip card.
|
||||
|
||||
### Dates
|
||||
|
||||
Set a **Start date** and **End date** using the date picker. The day count is calculated automatically when both are set.
|
||||
|
||||
If you leave **both** dates empty, a separate **Day count** field appears. Enter a number between **1 and 365** to create a date-less itinerary with a fixed number of days.
|
||||
|
||||
You cannot set only one date and leave the other blank via normal interaction — setting a start date auto-fills or adjusts the end date to preserve the previous duration.
|
||||
|
||||
### Cover Image
|
||||
|
||||
The cover image is displayed on the trip card and as the background of the Spotlight card. You can add one in three ways:
|
||||
|
||||
- **Drag and drop** an image file onto the dashed upload area.
|
||||
- **Paste from clipboard** — if you have an image in your clipboard, paste it anywhere in the dialog.
|
||||
- **File picker** — click the upload area to browse for a file.
|
||||
|
||||
When **creating** a new trip the cover image field is always visible. When **editing** an existing trip it is only shown if you have the `trip_cover_upload` permission. For a new trip, the image is uploaded immediately after the trip is created.
|
||||
|
||||
### Reminder
|
||||
|
||||
A push notification sent before the trip departs. The field shows a set of preset options:
|
||||
|
||||
| Option | Days before departure |
|
||||
|---|---|
|
||||
| None | 0 |
|
||||
| 1 day | 1 |
|
||||
| 3 days | 3 |
|
||||
| 9 days | 9 |
|
||||
| Custom | 1–30 (you enter the number) |
|
||||
|
||||
When **creating** a new trip the reminder field is always visible. When **editing** an existing trip it is only shown to the **trip owner** or **admin** users.
|
||||
|
||||
If reminders are disabled on your instance (`trip_reminders_enabled = false`), the reminder section is shown at reduced opacity with an informational message in place of the preset buttons.
|
||||
|
||||
> **Admin:** Trip reminders are controlled by a server-side feature flag (`trip_reminders_enabled`). Contact your administrator to enable them.
|
||||
|
||||
### Members
|
||||
|
||||
Add initial trip members from the members selector. On a **new** trip, selected members are queued locally and added to the trip immediately after it is saved. The selector shows all registered users on your instance except yourself.
|
||||
|
||||
## Saving
|
||||
|
||||
Click **Create Trip**. The trip is saved and you are taken to the [Trip-Planner-Overview](Trip-Planner-Overview) for the new trip.
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [Trip-Members-and-Sharing](Trip-Members-and-Sharing)
|
||||
- [Trip-Planner-Overview](Trip-Planner-Overview)
|
||||
- [My-Trips-Dashboard](My-Trips-Dashboard)
|
||||
@@ -0,0 +1,62 @@
|
||||
# Dashboard Widgets
|
||||
|
||||
The My Trips dashboard includes two utility widgets: a currency converter and a timezone clock.
|
||||
|
||||
<!-- TODO: screenshot: widget gallery showing currency converter and timezone clock -->
|
||||
|
||||

|
||||
|
||||
## Where they appear
|
||||
|
||||
On large screens (desktop/wide tablet), both widgets appear in a sticky right-hand sidebar of the [My-Trips-Dashboard](My-Trips-Dashboard). On mobile and narrow screens, the widgets are accessible via **Quick Actions** buttons on the dashboard — tapping the Currency or Timezone button opens a bottom sheet containing the full widget.
|
||||
|
||||
Each user configures their own widgets independently. Whether each widget is shown or hidden is saved to your account on the server (synced across devices). The selected currency pair and saved timezone list are stored in your browser's local storage and are device-specific.
|
||||
|
||||
### Showing and hiding widgets
|
||||
|
||||
On desktop, click the **Settings (gear) icon** in the dashboard toolbar to reveal toggle switches for each widget. Turning a widget off removes it from the sidebar; the preference is saved to your account.
|
||||
|
||||
---
|
||||
|
||||
## Currency Converter
|
||||
|
||||
The currency converter lets you quickly convert an amount between two currencies.
|
||||
|
||||
**How to use:**
|
||||
|
||||
1. Enter an amount in the input field.
|
||||
2. Select a source currency from the left selector.
|
||||
3. Select a target currency from the right selector.
|
||||
4. The converted amount is displayed immediately below.
|
||||
|
||||
You can also click the swap arrow to reverse source and target.
|
||||
|
||||
**Exchange rates** are fetched from [exchangerate-api.com](https://www.exchangerate-api.com) using the `https://api.exchangerate-api.com/v4/latest/{from}` endpoint. Rates are refreshed each time you change a currency or click the refresh icon.
|
||||
|
||||
**Supported currencies:** 162 currencies are available in the selector, including all major fiat currencies (USD, EUR, GBP, JPY, etc.) and many minor ones.
|
||||
|
||||
---
|
||||
|
||||
## Timezone Clock
|
||||
|
||||
The timezone clock displays live clocks for multiple time zones simultaneously.
|
||||
|
||||
**How to use:**
|
||||
|
||||
- Your local time is always shown at the top.
|
||||
- Below it, any zones you have added are listed with their current time and offset relative to your local zone.
|
||||
- Click **+** to add a zone. You can pick from 18 preset city zones, or enter any IANA timezone identifier (e.g. `America/Denver`) with an optional custom label (if omitted, the city portion of the identifier is used as the label).
|
||||
- Hover over a zone row and click **×** to remove it.
|
||||
|
||||
**Preset zones (18):**
|
||||
|
||||
New York, London, Berlin, Paris, Dubai, Mumbai, Bangkok, Tokyo, Sydney, Los Angeles, Chicago, São Paulo, Istanbul, Singapore, Hong Kong, Seoul, Moscow, Cairo.
|
||||
|
||||
Clocks update every 10 seconds. The 12-hour or 24-hour format follows your display settings (see [Display-Settings](Display-Settings)).
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [My-Trips-Dashboard](My-Trips-Dashboard)
|
||||
- [Addons-Overview](Addons-Overview)
|
||||
@@ -0,0 +1,73 @@
|
||||
# Day Plans and Notes
|
||||
|
||||
The Day Plan sidebar lets you organize places into days, add free-form notes, and manage the order of your itinerary.
|
||||
|
||||
<!-- TODO: screenshot: itinerary view with assigned places and day notes -->
|
||||
|
||||

|
||||
|
||||
## The Day Plan sidebar
|
||||
|
||||
The Day Plan sidebar is the left panel in the trip planner. Each trip day is shown as a collapsible section. Expanded or collapsed state is saved per trip in `sessionStorage` (key: `day-expanded-{tripId}`), so your layout is preserved across page reloads in the same browser session.
|
||||
|
||||
## Day timeline
|
||||
|
||||
Each day shows a merged, time-ordered list of:
|
||||
|
||||
- **Assigned places** — with time, category icon, and action buttons
|
||||
- **Day notes** — with their selected icon and optional time
|
||||
- **Reservations and transports** — non-hotel types (flights, trains, cars, cruises) appear inline; hotels appear in the Day Detail panel
|
||||
|
||||
Items are sorted by their time or position index.
|
||||
|
||||
## Assigning places to a day
|
||||
|
||||
- **Drag and drop** — drag a place from the right-hand Places sidebar and drop it onto a day section or between existing items.
|
||||
- **Mobile** — tap the **Add Place** button inside an expanded day section to open an inline search panel; find the place and tap it to assign.
|
||||
|
||||
You can also reorder places within a day, or move them to a different day, by dragging and dropping inside the sidebar.
|
||||
|
||||
## Multi-day reservations
|
||||
|
||||
A reservation that spans multiple days appears in each relevant day with a phase label:
|
||||
|
||||
| Reservation type | Start day | Middle days | End day |
|
||||
|---|---|---|---|
|
||||
| Flight | Departure | In transit | Arrival |
|
||||
| Car | Pickup | Active | Return |
|
||||
| Other | Start | Ongoing | End |
|
||||
|
||||
Car rentals that are in the "Active" (middle) phase are shown in the day header rather than the timeline.
|
||||
|
||||
## Day notes
|
||||
|
||||
Click the note **+** button in any day section to add a note. Notes have three fields:
|
||||
|
||||
- **Title** (required) — the main note text shown in the timeline
|
||||
- **Subtitle / detail** (optional) — a free-form text field (Markdown supported) displayed beneath the title
|
||||
- **Icon** — choose from 20 icons: FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark
|
||||
|
||||
Notes interleave with places and transports in the day timeline and are ordered by their `sort_order`. Use the **↑ / ↓** chevron buttons on a note to reposition it within the merged timeline. Notes can also be repositioned by dragging.
|
||||
|
||||
## Day Detail panel
|
||||
|
||||
Click a day header to open the Day Detail panel. It appears as a floating panel centered in the map area and shows:
|
||||
|
||||
- The weather forecast for that day (see [Weather-Forecasts](Weather-Forecasts))
|
||||
- Reservations linked to assignments on that day
|
||||
- Accommodation block (hotel check-in / check-out, with check-in window and confirmation number)
|
||||
|
||||
The panel can be collapsed to a slim header bar or closed entirely with the **X** button.
|
||||
|
||||
## Toolbar actions
|
||||
|
||||
At the top of the Day Plan sidebar:
|
||||
|
||||
- **Export PDF** — downloads a PDF of the full trip plan. See [PDF-Export](PDF-Export).
|
||||
- **ICS** — exports the trip as a calendar file (.ics) for import into calendar apps.
|
||||
- **Expand / Collapse all** — toggles all day sections open or closed at once.
|
||||
- **Undo** — reverses the last drag, reorder, or assign action.
|
||||
|
||||
Route calculation controls (optimize order, open in Google Maps) appear inside each expanded day section after the place list.
|
||||
|
||||
**See also:** [Places-and-Search](Places-and-Search) · [Map-Features](Map-Features) · [Route-Optimization](Route-Optimization) · [Weather-Forecasts](Weather-Forecasts) · [Reservations-and-Bookings](Reservations-and-Bookings)
|
||||
@@ -0,0 +1,62 @@
|
||||
# Demo Mode
|
||||
|
||||
Demo mode lets you run a public "try before you install" instance of TREK. A shared demo account is available for visitors, write operations are blocked for that account, and the database resets automatically every hour so the instance stays in a known state.
|
||||
|
||||
<!-- TODO: screenshot: demo mode banner or try-demo button on login page -->
|
||||
|
||||
## Enabling demo mode
|
||||
|
||||
Set `DEMO_MODE=true` in your environment and restart TREK. See [Environment-Variables](Environment-Variables) for how to set environment variables.
|
||||
|
||||
When demo mode is active, the login page shows a one-click **"Try the demo"** button. Clicking it logs the visitor in as the demo user immediately — no credentials need to be entered and no registration is required.
|
||||
|
||||
**Demo account (auto-created on first start):**
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Email | `demo@trek.app` |
|
||||
| Password | `demo12345` |
|
||||
|
||||
## What the demo user can and cannot do
|
||||
|
||||
The demo user account has read access to the shared trip data but the following operations are permanently blocked:
|
||||
|
||||
- **Password change** — returns 403.
|
||||
- **Account deletion** — returns 403.
|
||||
- **MFA enrollment or removal** — returns 403.
|
||||
- **File uploads** — avatar uploads, trip cover uploads, and document/photo file attachments are blocked and return 403.
|
||||
- **All MCP write tools** — create, update, and delete operations via the MCP API are blocked for the demo user.
|
||||
|
||||
Registration is also disabled while demo mode is active — visitors cannot create new accounts.
|
||||
|
||||
The admin account is unaffected and retains full access.
|
||||
|
||||
## Hourly reset
|
||||
|
||||
TREK schedules an automatic hourly reset of the demo database. At each reset:
|
||||
|
||||
1. The current `travel.db` is replaced with the saved baseline (`travel-baseline.db`).
|
||||
2. The admin account's credentials (`password_hash`, API keys, avatar) are re-applied on top of the restored baseline, so admin API keys and password changes survive the reset.
|
||||
|
||||
If no baseline has been saved yet, the reset is skipped and a message is logged.
|
||||
|
||||
## Saving a baseline
|
||||
|
||||
The baseline is the snapshot the hourly reset restores to. The admin can update it at any time:
|
||||
|
||||
**Endpoint:** `POST /admin/save-demo-baseline`
|
||||
|
||||
This is available in the admin panel. The baseline captures the current state of the database — including trip data, settings, and encrypted API keys — so demo features (maps, photos, weather) continue to work after each reset.
|
||||
|
||||
On first start with demo mode active, TREK seeds three example trips (Tokyo & Kyoto, Barcelona Long Weekend, New York City) owned by the admin and shared with the demo user, then saves the initial baseline automatically.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Demo mode is not for production use with real user data. The hourly reset deletes all visitor-created content.
|
||||
- All demo visitors share a single account — there is no isolation between sessions.
|
||||
- File uploads (photos, documents, trip covers, avatars) are disabled for the demo user.
|
||||
|
||||
## See also
|
||||
|
||||
- [Environment-Variables](Environment-Variables)
|
||||
- [Backups](Backups)
|
||||
@@ -0,0 +1,56 @@
|
||||
# Display Settings
|
||||
|
||||
The Display tab (Settings → Display) controls the visual appearance and locale preferences of the app. All changes save immediately to your account and persist across devices.
|
||||
|
||||
<!-- TODO: screenshot: appearance settings panel -->
|
||||
|
||||

|
||||
|
||||
## Color mode
|
||||
|
||||
Choose between three options:
|
||||
|
||||
| Option | Behaviour |
|
||||
|--------|-----------|
|
||||
| Light | Always uses the light theme |
|
||||
| Dark | Always uses the dark theme |
|
||||
| Auto | Follows your operating system / browser preference |
|
||||
|
||||
## Language
|
||||
|
||||
Select your preferred language from the button grid (desktop) or dropdown (mobile). The change takes effect immediately without a page reload. See [Languages](Languages) for the full list of supported languages.
|
||||
|
||||
## Temperature unit
|
||||
|
||||
Affects the weather widget on trip days.
|
||||
|
||||
| Option | Display |
|
||||
|--------|---------|
|
||||
| °C Celsius | Metric |
|
||||
| °F Fahrenheit | Imperial |
|
||||
|
||||
## Time format
|
||||
|
||||
Affects all time displays throughout the app.
|
||||
|
||||
| Option | Example |
|
||||
|--------|---------|
|
||||
| 24h | 14:30 |
|
||||
| 12h | 2:30 PM |
|
||||
|
||||
## Route calculation
|
||||
|
||||
Toggles automatic route calculation between places on the trip map. Set to **On** or **Off**.
|
||||
|
||||
## Booking route labels
|
||||
|
||||
Shows or hides labels on booking-related route segments on the map. Set to **On** or **Off**.
|
||||
|
||||
## Blur booking codes
|
||||
|
||||
When enabled, confirmation codes and reference numbers are blurred until you hover or tap. Set to **On** or **Off**.
|
||||
|
||||
## See also
|
||||
|
||||
- [Languages](Languages)
|
||||
- [User-Settings](User-Settings)
|
||||
@@ -0,0 +1,76 @@
|
||||
# Documents and Files
|
||||
|
||||
Attach and manage documents, tickets, and other files for your trip.
|
||||
|
||||
<!-- TODO: screenshot: file attachment list with filter tabs -->
|
||||
|
||||

|
||||
|
||||
## Where to find it
|
||||
|
||||
Open the **Files** tab inside the trip planner, or navigate directly to `/trips/:id/files`.
|
||||
|
||||
> **Admin:** Files is an addon. Enable it in [Admin-Addons](Admin-Addons).
|
||||
|
||||
## Uploading
|
||||
|
||||
Drag and drop files onto the upload area, click it to open the file picker, or paste an image directly into the Files panel.
|
||||
|
||||
- **Maximum file size:** 50 MB per file.
|
||||
- **Blocked file types:** `.svg`, `.html`, `.htm`, `.xml` — these are always rejected.
|
||||
- **Default allowed types:** jpg, jpeg, png, gif, webp, heic, pdf, doc, docx, xls, xlsx, txt, csv, pkpass. An admin can customize the allowed list in [Admin-Addons](Admin-Addons).
|
||||
|
||||
Requires the `file_upload` permission.
|
||||
|
||||
## Browsing and filtering
|
||||
|
||||
The toolbar provides filter tabs: **All**, **PDF**, **Images**, **Docs**, and conditionally **Starred** (only shown when at least one file is starred) and **Collab** (only shown when files exist from collaborative notes). Each tab shows a count badge.
|
||||
|
||||
## Previewing files
|
||||
|
||||
Clicking a non-image file (e.g., PDF) opens an inline preview modal with options to open in a new tab or download. Clicking an image file opens a full-screen lightbox. You can:
|
||||
|
||||
- Navigate between images using the **arrow buttons** or the **left/right arrow keys**.
|
||||
- Swipe left/right on touch devices.
|
||||
- Jump to a specific image using the **thumbnail strip** at the bottom.
|
||||
- Download or open the image in a new tab from the lightbox header.
|
||||
|
||||
## Starring
|
||||
|
||||
Click the **star icon** on any file to favorite it. Starred files sort to the top of the list and can be filtered with the Starred tab.
|
||||
|
||||
Requires the `file_edit` permission.
|
||||
|
||||
## Trash
|
||||
|
||||
Deleting a file moves it to the trash. Switch to the trash view using the **Trash** button in the toolbar. From the trash view you can:
|
||||
|
||||
- **Restore** a file to make it active again.
|
||||
- **Permanently delete** a single file.
|
||||
- **Empty trash** to permanently remove all trashed files at once.
|
||||
|
||||
Restore and delete operations require the `file_delete` permission.
|
||||
|
||||
## Linking files to places, reservations, or assignments
|
||||
|
||||
A file can be attached to multiple places and reservations at the same time (many-to-many). From the file manager, click the **link (pencil) icon** on a file to open the assign modal. From there you can toggle links to any trip place or reservation. You can also add a descriptive note to the file in the same modal.
|
||||
|
||||
From a reservation modal, use the "link existing file" picker to attach files directly.
|
||||
|
||||
## Downloading
|
||||
|
||||
Click the download icon on any file row to download it. `.pkpass` files (Apple Wallet passes) are served with the `application/vnd.apple.pkpass` MIME type, so Safari on iOS and macOS offers to add the pass to Wallet instead of saving it as a generic download.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Controls |
|
||||
|---|---|
|
||||
| `file_upload` | Uploading new files. |
|
||||
| `file_edit` | Starring and linking files. |
|
||||
| `file_delete` | Moving to trash, restoring, and permanently deleting. |
|
||||
|
||||
## See also
|
||||
|
||||
- [Reservations-and-Bookings](Reservations-and-Bookings)
|
||||
- [Admin-Addons](Admin-Addons)
|
||||
- [Trip-Planner-Overview](Trip-Planner-Overview)
|
||||
@@ -0,0 +1,85 @@
|
||||
# Encryption Key Rotation
|
||||
|
||||
## What the encryption key protects
|
||||
|
||||
TREK encrypts sensitive settings at rest using AES-256-GCM. The following values are stored encrypted in the database:
|
||||
|
||||
- Google Maps API key (per user)
|
||||
- Mapbox access token (per user)
|
||||
- OpenWeather API key (per user)
|
||||
- Immich API key (per user)
|
||||
- Synology Photos password, session ID, and device ID (per user)
|
||||
- Per-user webhook URL and ntfy notification token (in `settings` table)
|
||||
- OIDC client secret (global, in `app_settings`)
|
||||
- SMTP password (global, in `app_settings`)
|
||||
- Admin webhook URL and admin ntfy token (global, in `app_settings`)
|
||||
- MFA (TOTP) secrets for all users
|
||||
- Photo passphrases for Synology shared-link photos (in `trek_photos`)
|
||||
|
||||
The encryption derives a key from `ENCRYPTION_KEY` using SHA-256 (with a domain suffix per secret type), so the raw `ENCRYPTION_KEY` value is never stored in the database.
|
||||
|
||||
## Key resolution order
|
||||
|
||||
On startup, TREK resolves the encryption key in this order:
|
||||
|
||||
1. **`ENCRYPTION_KEY` environment variable** — explicit, always takes priority. When set, the value is also written to `./data/.encryption_key` so it survives container restarts if the env var is later removed.
|
||||
2. **`./data/.encryption_key` file** — present on any install that has started at least once.
|
||||
3. **`./data/.jwt_secret` file** — one-time fallback for older installs that pre-date the dedicated encryption key. The value is immediately persisted to `./data/.encryption_key` so future JWT rotations cannot break decryption.
|
||||
4. **Auto-generated** — fresh install with none of the above. A random 32-byte hex key is generated and written to `./data/.encryption_key`.
|
||||
|
||||
## What happens if the key is lost
|
||||
|
||||
All encrypted settings (API keys, SMTP password, OIDC secret, MFA secrets, notification tokens, etc.) become unreadable — TREK cannot decrypt them. They must be re-entered manually after the key is restored or replaced. Unencrypted data (trips, places, users, etc.) is unaffected.
|
||||
|
||||
## Backing up the key
|
||||
|
||||
Your backup ZIP does **not** include the encryption key. Store the key separately from your backups — for example, in a password manager or a secrets manager. See [Backups](Backups).
|
||||
|
||||
To find your current key: check the `ENCRYPTION_KEY` environment variable or read `./data/.encryption_key`.
|
||||
|
||||
## Rotating the key
|
||||
|
||||
Use `scripts/migrate-encryption.ts` to re-encrypt all stored secrets without downtime or manual re-entry.
|
||||
|
||||
**Docker:**
|
||||
|
||||
```bash
|
||||
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||
```
|
||||
|
||||
**Host (run from the `server/` directory):**
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/migrate-encryption.ts
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
1. Prompts for the old and new encryption keys interactively (keys are never echoed to the terminal or written to shell history).
|
||||
2. Asks for confirmation before making any changes.
|
||||
3. Creates a timestamped backup of the database (e.g. `travel.db.backup-1713484800000`) before modifying anything.
|
||||
4. Re-encrypts all stored secrets across all tables:
|
||||
- `app_settings`: `oidc_client_secret`, `smtp_pass`, `admin_webhook_url`, `admin_ntfy_token`
|
||||
- `users` (per user): `maps_api_key`, `openweather_api_key`, `immich_api_key`, `synology_password`, `synology_sid`, `synology_did`, `mfa_secret`
|
||||
- `settings` (per user): `webhook_url`, `ntfy_token`, `mapbox_access_token`
|
||||
- `trip_album_links`: `passphrase`
|
||||
- `trek_photos`: `passphrase`
|
||||
5. Reports counts of migrated, already-migrated, skipped (empty), and errored values.
|
||||
|
||||
After a successful migration:
|
||||
|
||||
1. Update `ENCRYPTION_KEY` in your environment to the new value.
|
||||
2. Restart TREK.
|
||||
|
||||
If any secrets could not be migrated, the script exits with a non-zero status and the original database backup is retained.
|
||||
|
||||
## Upgrading from very old versions
|
||||
|
||||
Old installs may have used `./data/.jwt_secret` as the encryption source (before a dedicated `ENCRYPTION_KEY` was introduced). The key resolution chain above handles this automatically on startup — the JWT secret is read, immediately written to `./data/.encryption_key`, and JWT rotation is then safe without breaking decryption.
|
||||
|
||||
## See also
|
||||
|
||||
- [Backups](Backups)
|
||||
- [Security-Hardening](Security-Hardening)
|
||||
- [Environment-Variables](Environment-Variables)
|
||||
- [User-Settings](User-Settings)
|
||||
@@ -0,0 +1,135 @@
|
||||
# Environment Variables
|
||||
|
||||
Complete reference for all environment variables TREK reads.
|
||||
|
||||
## How to Set Variables
|
||||
|
||||
- **Docker Compose** — use the `environment:` block or a `.env` file alongside `docker-compose.yml`
|
||||
- **Docker run** — pass each variable with `-e VARIABLE=value`
|
||||
- **Helm** — use `env:` for plain values and `secretEnv:` for sensitive values in `values.yaml`
|
||||
- **Unraid** — set in the container template editor
|
||||
|
||||
---
|
||||
|
||||
## Core
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key — see resolution order below | 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 on the login page — see supported codes below | `en` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email notification links | same-origin |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` |
|
||||
| `APP_URL` | Public base URL (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 email notification links. | — |
|
||||
|
||||
### `ENCRYPTION_KEY` — Resolution Order
|
||||
|
||||
`server/src/config.ts` resolves the encryption key in this order:
|
||||
|
||||
1. **`ENCRYPTION_KEY` env var** — explicit value, always takes priority. Persisted to `data/.encryption_key` automatically.
|
||||
2. **`data/.encryption_key` file** — present on any install that has started at least once.
|
||||
3. **`data/.jwt_secret` file** — one-time fallback for existing installs upgrading without a pre-set key. The value is immediately persisted to `data/.encryption_key` so JWT rotation cannot break decryption later.
|
||||
4. **Auto-generated** — fresh install with none of the above; persisted to `data/.encryption_key`.
|
||||
|
||||
Setting `ENCRYPTION_KEY` explicitly is recommended so you can back it up independently of the data volume.
|
||||
|
||||
### `DEFAULT_LANGUAGE` — Supported Codes
|
||||
|
||||
Verified in `server/src/config.ts` (line 107):
|
||||
|
||||
`de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`
|
||||
|
||||
> **Note:** `id` (Indonesian / Bahasa Indonesia) appears in `client/src/i18n/supportedLanguages.ts` but is not in the server's supported-codes list in `config.ts`. Setting `DEFAULT_LANGUAGE=id` will fall back to `en` with a warning in the server log.
|
||||
|
||||
---
|
||||
|
||||
## HTTPS / Proxy
|
||||
|
||||
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy] for the full explanation.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `FORCE_HTTPS` | When `true`: 301-redirects HTTP→HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, forces cookie `secure` flag. Only useful behind a TLS proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `TRUST_PROXY` | Number of trusted proxy hops. Tells Express to read the real client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` automatically in production. Required for `FORCE_HTTPS` to detect the forwarded protocol. | `1` (production) |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived as `true` when `NODE_ENV=production` or `FORCE_HTTPS=true`. Set to `false` only as an escape hatch for LAN testing without TLS — not recommended in production. | auto |
|
||||
|
||||
> **Warning:** `FORCE_HTTPS=true` without `TRUST_PROXY` set causes a redirect loop.
|
||||
|
||||
---
|
||||
|
||||
## OIDC / SSO
|
||||
|
||||
For setup instructions, see [OIDC-SSO].
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `OIDC_ISSUER` | OpenID Connect provider URL (e.g. `https://auth.example.com`) | — |
|
||||
| `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 registration, overrides Admin > Settings toggles, cannot be changed at runtime. First SSO login becomes admin on a fresh instance. | `false` |
|
||||
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users (e.g. `groups`) | — |
|
||||
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role (e.g. `app-trek-admins`) | — |
|
||||
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
|
||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Required for providers with a non-standard path (e.g. Authentik) | — |
|
||||
|
||||
---
|
||||
|
||||
## Email / SMTP
|
||||
|
||||
SMTP settings can be configured via the Admin panel or overridden with environment variables. Env vars take priority over the database values.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | SMTP server hostname (e.g. `smtp.example.com`) | — |
|
||||
| `SMTP_PORT` | SMTP server port. Port `465` enables implicit TLS (`secure: true`); all other ports use STARTTLS or plain. | — |
|
||||
| `SMTP_USER` | SMTP authentication username | — |
|
||||
| `SMTP_PASS` | SMTP authentication password | — |
|
||||
| `SMTP_FROM` | Sender address for outbound emails (e.g. `TREK <noreply@example.com>`) | — |
|
||||
| `SMTP_SKIP_TLS_VERIFY` | Set `true` to disable TLS certificate validation. Useful for self-signed certs on internal SMTP relays — not recommended in production. | `false` |
|
||||
|
||||
`SMTP_HOST`, `SMTP_PORT`, and `SMTP_FROM` are all required for email delivery to work. `SMTP_USER` and `SMTP_PASS` are optional (for unauthenticated relays).
|
||||
|
||||
---
|
||||
|
||||
## Initial Setup
|
||||
|
||||
These variables only take effect on first boot, before any user exists.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `ADMIN_EMAIL` | Email for the first admin account | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin account | random |
|
||||
|
||||
Both variables must be set together. If either is omitted, the account is created with email `admin@trek.local` and a randomly generated password that is printed to the server log. Once any user exists, these variables have no effect.
|
||||
|
||||
---
|
||||
|
||||
## MCP
|
||||
|
||||
For setup instructions, see [MCP-Overview].
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
|
||||
---
|
||||
|
||||
## Other
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets). Not intended for regular use. | `false` |
|
||||
|
||||
---
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [Reverse-Proxy] — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
|
||||
- [OIDC-SSO] — complete OIDC configuration guide
|
||||
- [MCP-Overview] — MCP server setup and rate limiting
|
||||
- [Encryption-Key-Rotation] — rotating the `ENCRYPTION_KEY` without losing data
|
||||
@@ -0,0 +1,42 @@
|
||||
# FAQ
|
||||
|
||||
## Do I need a Google Maps API key?
|
||||
|
||||
No. When no Google Maps key is configured, TREK automatically falls back to OpenStreetMap (Nominatim) for place search — no API key or account required. If you want richer place data (photos, ratings, opening hours), an admin can optionally add a Google Maps key in [User Settings](User-Settings).
|
||||
|
||||
## Can I use TREK offline?
|
||||
|
||||
Yes. TREK is a Progressive Web App. After your first visit, the service worker (powered by Workbox) caches map tiles (Carto and OpenStreetMap), non-sensitive API responses, uploaded covers and avatars, and all static assets. Subsequent visits work without a network connection for already-cached content. See [Offline Mode and PWA](Offline-Mode-and-PWA) for installation instructions.
|
||||
|
||||
> **Note:** Auth, admin, backup, and settings endpoints are intentionally excluded from the offline cache.
|
||||
|
||||
## How many MCP tokens can I create?
|
||||
|
||||
Each user can create up to **10 static API tokens**. Static tokens are deprecated — migrate to OAuth 2.1 when possible.
|
||||
|
||||
For OAuth 2.1, each user can register up to **10 OAuth clients**. The default limit for concurrent MCP sessions is **20 per user** (configurable via `MCP_MAX_SESSION_PER_USER`). See [MCP Setup](MCP-Setup).
|
||||
|
||||
> **Admin:** MCP must be enabled in Admin Panel > Addons before any user can access it.
|
||||
|
||||
## Where is my data stored?
|
||||
|
||||
| Type | Path |
|
||||
|------|------|
|
||||
| Database | `./data/travel.db` (SQLite) |
|
||||
| Uploads | `./uploads/` |
|
||||
| Logs | `./data/logs/trek.log` (auto-rotated) |
|
||||
| Backups | `./data/backups/` |
|
||||
|
||||
When running in Docker, mount `./data` and `./uploads` as volumes so your data survives container updates. See [Install: Docker Compose](Install-Docker-Compose).
|
||||
|
||||
## How do I update TREK?
|
||||
|
||||
Pull the new image and recreate the container. Your data is in the mounted volumes and is never modified by the update process. See [Updating](Updating) for the exact commands.
|
||||
|
||||
## Can I restrict who can register?
|
||||
|
||||
Yes. An admin can disable open registration so that new accounts can only be created via invite links. See [Admin: Users and Invites](Admin-Users-and-Invites).
|
||||
|
||||
## Does TREK support single sign-on?
|
||||
|
||||
Yes, via OpenID Connect (OIDC). Compatible providers include Google, Authentik, Keycloak, and any standard OIDC-compliant IdP. Set `OIDC_ONLY=true` to disable password login entirely. See [OIDC SSO](OIDC-SSO).
|
||||
@@ -0,0 +1,60 @@
|
||||
# TREK Wiki
|
||||
|
||||
TREK is a self-hosted, real-time collaborative travel planner licensed under AGPL-3.0.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### 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** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key needed)
|
||||
- **Day Notes** — timestamped, icon-tagged notes per day
|
||||
- **Route Optimization** — auto-optimize place order and export to Google Maps
|
||||
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key required), historical climate averages as fallback
|
||||
|
||||
### Travel Management
|
||||
- **Reservations & Bookings** — track flights, accommodations, restaurants with confirmation numbers and file attachments
|
||||
- **Budget Tracking** — category-based expenses with pie chart, per-person/per-day splitting, multi-currency support
|
||||
- **Packing Lists** — category-based checklists with user assignment, templates, and progress tracking
|
||||
- **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, and notes
|
||||
|
||||
### Collaboration
|
||||
- **Real-Time Sync** — WebSocket-based live sync; changes appear instantly for all connected users
|
||||
- **Multi-User** — invite members with role-based access
|
||||
- **Invite Links** — one-time registration links with configurable max uses and expiry
|
||||
- **OIDC SSO** — sign in with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **Two-Factor Authentication** — TOTP-based 2FA with QR code setup
|
||||
- **Public Share Links** — share a read-only view of any trip
|
||||
|
||||
### Addons _(admin-toggleable)_
|
||||
- **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking
|
||||
- **Atlas** — interactive world map, bucket list, travel stats, continent breakdown
|
||||
- **Journey** — travel journal linking entries to trips, with contributor roles
|
||||
- **Memories** — photo-focused trip memories
|
||||
- **Collab** — group chat, shared notes, polls, and activity sign-ups
|
||||
- **Dashboard Widgets** — currency converter and timezone clock, toggled per user
|
||||
|
||||
### AI / MCP Integration
|
||||
- **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication
|
||||
- **80+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
|
||||
- **24 OAuth Scopes** — granular permissions across 13 permission groups
|
||||
- **Pre-built Prompts** — `trip-summary`, `packing-list`, and `budget-overview` context loaders
|
||||
|
||||
### Admin
|
||||
- User management, invite links, packing templates, global categories
|
||||
- Addon management, API key storage, scheduled auto-backups
|
||||
- System notices for onboarding and announcements
|
||||
|
||||
> **Admin:** Most configuration lives in the Admin Panel. The first user to register becomes the admin automatically.
|
||||
|
||||
## Get Started
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Quick Start](Quick-Start) | Install in minutes with a single Docker command |
|
||||
| [My Trips Dashboard](My-Trips-Dashboard) | Start planning your first trip |
|
||||
| [Admin Panel](Admin-Panel-Overview) | Configure your instance |
|
||||
| [MCP / AI Integration](MCP-Overview) | Connect Claude, Cursor, or any MCP client |
|
||||
@@ -0,0 +1,120 @@
|
||||
# Install: Docker Compose
|
||||
|
||||
Production-ready setup using Docker Compose with security hardening enabled.
|
||||
|
||||
## Compose File
|
||||
|
||||
Create a `docker-compose.yml` with the following content (taken directly from the repository):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: mauriceboe/trek:latest
|
||||
container_name: trek
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETUID
|
||||
- SETGID
|
||||
tmpfs:
|
||||
- /tmp:noexec,nosuid,size=64m
|
||||
ports:
|
||||
- "3000:3000"
|
||||
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=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
||||
# - APP_URL=https://trek.example.com # Public base URL — required when OIDC is enabled (must match the redirect URI registered with your IdP); also used as base URL for links in email notifications
|
||||
# - 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 true to force SSO-only mode: disables password login and registration, overrides Admin > Settings toggles, 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)
|
||||
# - 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)
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
## Security Hardening Explained
|
||||
|
||||
The compose file ships with several hardening options enabled by default:
|
||||
|
||||
| Setting | What it does |
|
||||
|---|---|
|
||||
| `read_only: true` | Mounts the container filesystem read-only; only the two named volumes and `/tmp` are writable |
|
||||
| `security_opt: no-new-privileges:true` | Prevents the process from gaining additional Linux privileges via setuid/setgid executables |
|
||||
| `cap_drop: [ALL]` | Drops all Linux capabilities from the container |
|
||||
| `cap_add: [CHOWN, SETUID, SETGID]` | Adds back only the capabilities needed for the entrypoint to drop privileges to the `node` user |
|
||||
| `tmpfs: /tmp:noexec,nosuid,size=64m` | Mounts a 64 MB in-memory `/tmp`; required because the container root is read-only |
|
||||
|
||||
## Volumes
|
||||
|
||||
| Host path | Container path | Contents |
|
||||
|---|---|---|
|
||||
| `./data` | `/app/data` | SQLite database, logs, `.jwt_secret`, `.encryption_key` |
|
||||
| `./uploads` | `/app/uploads` | Uploaded files (photos, documents, covers, avatars) |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The compose file reads variables from a `.env` file placed alongside `docker-compose.yml`. At minimum, set:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
ENCRYPTION_KEY=<output of: openssl rand -hex 32>
|
||||
TZ=Europe/Berlin
|
||||
ALLOWED_ORIGINS=https://trek.example.com
|
||||
APP_URL=https://trek.example.com
|
||||
```
|
||||
|
||||
Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables].
|
||||
|
||||
## Start TREK
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Check the logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
## HTTPS and Reverse Proxy
|
||||
|
||||
This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`.
|
||||
|
||||
See [Reverse-Proxy] for complete proxy configuration examples.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Environment-Variables] — full variable reference
|
||||
- [Reverse-Proxy] — HTTPS configuration
|
||||
- [Updating] — how to pull a new image
|
||||
@@ -0,0 +1,76 @@
|
||||
# Install: Docker
|
||||
|
||||
Single-container Docker run — suitable for testing or simple personal installs.
|
||||
|
||||
## Run Command
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name trek \
|
||||
-p 3000:3000 \
|
||||
-v ./data:/app/data \
|
||||
-v ./uploads:/app/uploads \
|
||||
-e ENCRYPTION_KEY=<your-32-byte-hex-key> \
|
||||
--restart unless-stopped \
|
||||
mauriceboe/trek:latest
|
||||
```
|
||||
|
||||
`ENCRYPTION_KEY` is strongly recommended but not strictly required. If omitted, a key is auto-generated on first start and persisted to `data/.encryption_key`. Setting it explicitly means you can recreate the container from scratch (e.g. on a new host) without losing access to stored encrypted data (API keys, SMTP credentials, OIDC secrets, MFA secrets).
|
||||
|
||||
Generate an encryption key with:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
### Common optional variables
|
||||
|
||||
Pass additional `-e` flags for timezone and CORS/email link support:
|
||||
|
||||
```bash
|
||||
-e TZ=Europe/Berlin \
|
||||
-e ALLOWED_ORIGINS=https://trek.example.com \
|
||||
```
|
||||
|
||||
See [Environment-Variables] for the full list.
|
||||
|
||||
## Volume Reference
|
||||
|
||||
| Volume | Container path | What lives there |
|
||||
|---|---|---|
|
||||
| `./data` | `/app/data` | `travel.db` (SQLite database), `logs/trek.log`, `.jwt_secret`, `.encryption_key` |
|
||||
| `./uploads` | `/app/uploads` | Uploaded files (photos, documents, covers, avatars) |
|
||||
|
||||
Both volumes must survive container replacement — they are your persistent state. Never remove them before pulling a new image.
|
||||
|
||||
## Health Check
|
||||
|
||||
The container exposes a health endpoint at:
|
||||
|
||||
```
|
||||
http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
Docker polls it automatically (interval: 30 s, timeout: 5 s, retries: 3, start period: 15 s). You can check it manually:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
## Verify the Container Is Running
|
||||
|
||||
```bash
|
||||
docker ps --filter name=trek
|
||||
docker logs trek
|
||||
```
|
||||
|
||||
## Limitations of `docker run`
|
||||
|
||||
A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose], which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Reverse-Proxy] — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag
|
||||
- [Install-Docker-Compose] — recommended for production
|
||||
- [Environment-Variables] — full list of configurable variables
|
||||
- [Updating] — how to pull a new image without losing data
|
||||
@@ -0,0 +1,195 @@
|
||||
# Install: Helm
|
||||
|
||||
Deploy TREK on Kubernetes using the official Helm chart.
|
||||
|
||||
## Add the Chart Repository
|
||||
|
||||
```bash
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
```
|
||||
|
||||
## Basic Install
|
||||
|
||||
```bash
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
This deploys TREK with default values: a `ClusterIP` service on port 3000, 1 Gi PVCs for data and uploads, and no ingress.
|
||||
|
||||
## Encryption Key
|
||||
|
||||
`ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. There are three ways to handle it:
|
||||
|
||||
**Option 1 — Let the chart generate a random key (recommended for new installs):**
|
||||
|
||||
```bash
|
||||
helm install trek trek/trek --set generateEncryptionKey=true
|
||||
```
|
||||
|
||||
The chart generates a 32-character alphanumeric key at install time and preserves it across upgrades. Note that this differs from the 64-character hex key produced by `openssl rand -hex 32` — both formats are accepted by the server.
|
||||
|
||||
**Option 2 — Set an explicit key:**
|
||||
|
||||
```bash
|
||||
helm install trek trek/trek \
|
||||
--set secretEnv.ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
```
|
||||
|
||||
**Option 3 — Use an existing Kubernetes Secret:**
|
||||
|
||||
```bash
|
||||
kubectl create secret generic trek-secrets \
|
||||
--from-literal=ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
|
||||
helm install trek trek/trek \
|
||||
--set existingSecret=trek-secrets
|
||||
```
|
||||
|
||||
If `existingSecret` uses a different key name than `ENCRYPTION_KEY`, specify it with `--set existingSecretKey=MY_KEY_NAME`.
|
||||
|
||||
> **Note:** If both `generateEncryptionKey` and `existingSecret` are set, `existingSecret` takes precedence. Only one method should be active at a time.
|
||||
|
||||
> **Note:** If `ENCRYPTION_KEY` is left empty, the server resolves it automatically: existing installs fall back to `data/.jwt_secret` (encrypted data stays readable after upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
|
||||
> **Note:** `JWT_SECRET` is managed entirely by the server — auto-generated on first start and persisted to the data PVC. It can be rotated via the admin panel (Settings → Danger Zone → Rotate JWT Secret). No Helm configuration is needed or supported for it.
|
||||
|
||||
## Admin Account
|
||||
|
||||
`ADMIN_EMAIL` and `ADMIN_PASSWORD` are set via `secretEnv`. They are only used on first boot when no users exist yet. **Both must be set together** — if either is missing, the server ignores both values and instead creates the admin account with email `admin@trek.local` and a random password, which is printed to the server log.
|
||||
|
||||
```bash
|
||||
helm install trek trek/trek \
|
||||
--set secretEnv.ADMIN_EMAIL=admin@example.com \
|
||||
--set secretEnv.ADMIN_PASSWORD=<your-secure-password>
|
||||
```
|
||||
|
||||
> **Note:** When `OIDC_ONLY=true` is configured together with `OIDC_ISSUER` and `OIDC_CLIENT_ID`, no local admin account is created on first boot. Instead, the first user to log in via SSO automatically becomes admin.
|
||||
|
||||
## Key `values.yaml` Settings
|
||||
|
||||
### Image
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: mauriceboe/trek
|
||||
# tag: latest # defaults to the chart's appVersion
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Optional: pull secrets for private registries
|
||||
imagePullSecrets: []
|
||||
# - name: my-registry-secret
|
||||
```
|
||||
|
||||
### Service
|
||||
|
||||
```yaml
|
||||
service:
|
||||
type: ClusterIP # change to LoadBalancer or NodePort to expose externally
|
||||
port: 3000
|
||||
```
|
||||
|
||||
### Plain Environment Variables (`env`)
|
||||
|
||||
```yaml
|
||||
env:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
# TZ: "Europe/Berlin" # timezone for logs, reminders, cron jobs
|
||||
# LOG_LEVEL: "info" # "info" = concise, "debug" = verbose
|
||||
# DEFAULT_LANGUAGE: "en" # fallback language on login page; supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
# ALLOWED_ORIGINS: "https://trek.example.com"
|
||||
# APP_URL: "https://trek.example.com"
|
||||
# FORCE_HTTPS: "false" # enable HTTPS redirect + HSTS; requires TRUST_PROXY
|
||||
# TRUST_PROXY: "1" # proxy hops for X-Forwarded-For/Proto; defaults to 1 in production
|
||||
# COOKIE_SECURE: "true" # auto-derived; set "false" only for local HTTP testing
|
||||
# ALLOW_INTERNAL_NETWORK: "false" # set "true" if Immich or other services are on a private network
|
||||
# DEMO_MODE: "false" # enable demo mode (hourly data resets)
|
||||
# MCP_RATE_LIMIT: "300" # max MCP requests per user per minute
|
||||
# OIDC_ISSUER: "https://auth.example.com"
|
||||
# OIDC_CLIENT_ID: "trek"
|
||||
# OIDC_DISPLAY_NAME: "SSO"
|
||||
# OIDC_ONLY: "false" # force SSO-only mode; disables password login
|
||||
# OIDC_ADMIN_CLAIM: "" # OIDC claim used to identify admin users
|
||||
# OIDC_ADMIN_VALUE: "" # value of that claim that grants admin role
|
||||
# OIDC_SCOPE: "openid email profile groups"
|
||||
# OIDC_DISCOVERY_URL: "" # override for providers with non-standard discovery paths (e.g. Authentik)
|
||||
```
|
||||
|
||||
### Sensitive Variables (`secretEnv`)
|
||||
|
||||
These are stored in a Kubernetes Secret and injected as environment variables:
|
||||
|
||||
```yaml
|
||||
secretEnv:
|
||||
ENCRYPTION_KEY: "" # recommended: openssl rand -hex 32
|
||||
ADMIN_EMAIL: "" # initial admin email (first boot only)
|
||||
ADMIN_PASSWORD: "" # initial admin password (first boot only)
|
||||
OIDC_CLIENT_SECRET: "" # set if using OIDC
|
||||
```
|
||||
|
||||
Alternatively, use `generateEncryptionKey: true` to let the chart generate and manage the encryption key, or point `existingSecret` / `existingSecretKey` at an existing Kubernetes Secret.
|
||||
|
||||
### Persistent Storage
|
||||
|
||||
```yaml
|
||||
persistence:
|
||||
enabled: true
|
||||
data:
|
||||
size: 1Gi # SQLite database, logs, secrets
|
||||
uploads:
|
||||
size: 1Gi # uploaded files — increase if you expect large media uploads
|
||||
```
|
||||
|
||||
### Resource Limits
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
```
|
||||
|
||||
### Ingress
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: true
|
||||
className: "nginx" # your ingress class
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "86400" # required for WebSockets
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "500m" # required for backup restore
|
||||
hosts:
|
||||
- host: trek.example.com
|
||||
paths:
|
||||
- /
|
||||
tls:
|
||||
- secretName: trek-tls
|
||||
hosts:
|
||||
- trek.example.com
|
||||
```
|
||||
|
||||
> **Important:** TREK uses WebSockets on `/ws`. Your ingress controller must support WebSocket upgrades. Set `proxy-read-timeout` to at least `86400` and `proxy-body-size` to at least `500m` for backup restores.
|
||||
|
||||
> **Note:** Keep `env.ALLOWED_ORIGINS` in sync with `ingress.hosts` — the chart does not synchronize these automatically.
|
||||
|
||||
> **Note:** When using ingress with TLS termination, set `env.FORCE_HTTPS: "true"` and `env.TRUST_PROXY: "1"` to enable HTTPS redirects, HSTS, and secure cookies.
|
||||
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
helm repo update
|
||||
helm upgrade trek trek/trek
|
||||
```
|
||||
|
||||
## Full Values Reference
|
||||
|
||||
See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for all available values.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Environment-Variables] — full variable reference
|
||||
- [Reverse-Proxy] — proxy configuration for non-Kubernetes deployments
|
||||