Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8f63b82e2 | |||
| 00be0eab05 | |||
| ed97bb1deb | |||
| 51387b0af1 | |||
| 2ab8b401fb | |||
| 49af7a8b0d | |||
| dd90c6d424 | |||
| 3d887f15ab | |||
| 82bb08e685 | |||
| 4f3368502a |
@@ -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
|
||||
|
||||
@@ -53,10 +53,11 @@ management required — just provide the server URL:
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
|
||||
**What happens automatically:**
|
||||
1. The client fetches `/.well-known/oauth-authorization-server` to discover the TREK authorization server.
|
||||
2. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
|
||||
3. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
|
||||
4. The client receives a short-lived access token and a rotating refresh token — no re-authorization needed.
|
||||
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
|
||||
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
|
||||
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
|
||||
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
|
||||
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
|
||||
|
||||
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
|
||||
> discovery to work correctly.
|
||||
|
||||
@@ -1,121 +1,160 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
|
||||
<img src="client/public/logo-light.svg" alt="TREK" height="60" />
|
||||
</picture>
|
||||
<br />
|
||||
<em>Your Trips. Your Plan.</em>
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/TREK" alt="Last Commit" /></a>
|
||||
</p>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
|
||||
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||
</picture>
|
||||
|
||||
<p align="center">
|
||||
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
|
||||
<br />
|
||||
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try TREK without installing. Resets hourly.
|
||||
</p>
|
||||
### Your trips. Your plan. Your server.
|
||||
|
||||

|
||||

|
||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://demo-nomad.pakulat.org"><img alt="Live Demo" src="https://img.shields.io/badge/Live_Demo-try_it_now-111827?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" /></a>
|
||||
|
||||
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-community-5865F2?style=for-the-badge&logo=discord&logoColor=white" /></a>
|
||||
|
||||
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-board-0EA5E9?style=for-the-badge&logo=trello&logoColor=white" /></a>
|
||||
<br />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/mauriceboe/test/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## What you get
|
||||
|
||||
<picture>
|
||||
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
|
||||
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
|
||||
</picture>
|
||||
|
||||
<details>
|
||||
<summary>More Screenshots</summary>
|
||||
<summary><b>See all features</b></summary>
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  | |
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧭 Trip planning
|
||||
|
||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
- **Category filter** — show only matching pins on the map
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Travel management
|
||||
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files
|
||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
- **PDF export** — full trip plan as PDF with cover page, images, notes
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 👥 Collaboration
|
||||
|
||||
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
|
||||
- **Multi-user trips** — invite members with role-based access
|
||||
- **Invite links** — one-time or reusable links with expiry
|
||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **2FA** — TOTP + backup codes
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 📱 Mobile & PWA
|
||||
|
||||
- **Installable** — iOS and Android, straight from the browser, no App Store needed
|
||||
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
|
||||
- **Native feel** — fullscreen standalone, themed status bar, splash screen
|
||||
- **Touch optimised** — mobile-specific layouts with safe-area handling
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Journey** — magazine-style travel journal with entries, photos, maps, moods
|
||||
- **Dashboard widgets** — currency converter and timezone clocks
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🤖 AI / MCP
|
||||
|
||||
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
|
||||
- **Granular scopes** — 24 OAuth scopes across 13 permission groups
|
||||
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
||||
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
||||
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
## Features
|
||||
<br />
|
||||
|
||||
### Trip Planning
|
||||
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
|
||||
- **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources
|
||||
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
|
||||
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
|
||||
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
||||
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
|
||||
- **Map Category Filter** — Filter places by category and see only matching pins on the map
|
||||
|
||||
### Travel Management
|
||||
- **Reservations & Bookings** — Track flights, accommodations, restaurants with status, confirmation numbers, and file attachments
|
||||
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
|
||||
- **Packing Lists** — Category-based checklists with user assignment, packing templates, and progress tracking
|
||||
- **Packing Templates** — Create reusable packing templates in the admin panel with categories and items, apply to any trip
|
||||
- **Bag Tracking** — Optional weight tracking and bag assignment for packing items with iOS-style weight distribution (admin-toggleable)
|
||||
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
||||
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
|
||||
|
||||
### Mobile & PWA
|
||||
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
|
||||
- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox
|
||||
- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen
|
||||
- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling
|
||||
|
||||
### Collaboration
|
||||
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
|
||||
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
||||
- **Invite Links** — Create one-time registration links with configurable max uses and expiry for easy onboarding
|
||||
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **Two-Factor Authentication (MFA)** — TOTP-based 2FA with QR code setup, works with Google Authenticator, Authy, etc.
|
||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
||||
|
||||
### Addons (modular, admin-toggleable)
|
||||
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
|
||||
- **Atlas** — Interactive world map with visited countries, bucket list with planned travel dates, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
|
||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
||||
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
|
||||
|
||||
### AI / MCP Integration
|
||||
- **MCP Server** — Built-in [Model Context Protocol](MCP.md) server with OAuth 2.1 authentication exposes 80+ tools and 27 resources so AI assistants (Claude, Cursor, etc.) can read and modify your trips
|
||||
- **Granular Scopes** — 24 OAuth scopes across 13 permission groups let you control exactly what data your AI client can access
|
||||
- **Full Trip Automation** — AI can create trips, plan itineraries, build packing lists, manage budgets, send collab messages, mark countries visited, and more in a single conversation
|
||||
- **Prompts** — Pre-built `trip-summary`, `packing-list`, and `budget-overview` prompts give AI clients instant structured context
|
||||
- **Addon-Aware** — Atlas, Collab, and Vacay features are exposed automatically when those addons are enabled
|
||||
|
||||
### Customization & Admin
|
||||
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
|
||||
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Indonesian, Arabic (with RTL support)
|
||||
- **Admin Panel** — User management, invite links, packing templates, global categories, addon management, API keys, backups, and GitHub release history
|
||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Node.js 22 + Express + SQLite (`better-sqlite3`)
|
||||
- **Frontend**: React 18 + Vite + Tailwind CSS
|
||||
- **PWA**: vite-plugin-pwa + Workbox
|
||||
- **Real-Time**: WebSocket (`ws`)
|
||||
- **State**: Zustand
|
||||
- **Auth**: JWT + OAuth 2.1 + OIDC + TOTP (MFA)
|
||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||
- **Weather**: Open-Meteo API (free, no key required)
|
||||
- **Icons**: lucide-react
|
||||
|
||||
## Helm (Kubernetes)
|
||||
|
||||
A hosted Helm repository is available:
|
||||
|
||||
```sh
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
See [`charts/README.md`](charts/README.md) for configuration options.
|
||||
|
||||
## Quick Start
|
||||
## Get started in 30 seconds
|
||||
|
||||
```bash
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
@@ -123,19 +162,40 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
The app runs on port `3000`. The first user to register becomes the admin.
|
||||
Open `http://localhost:3000`. The first user to register becomes admin.
|
||||
|
||||
### Install as App (PWA)
|
||||
<div align="center">
|
||||
|
||||
TREK works as a Progressive Web App — no App Store needed:
|
||||
· <a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a> ·
|
||||
|
||||
1. Open your TREK instance in the browser (HTTPS required)
|
||||
2. **iOS**: Share button → "Add to Home Screen"
|
||||
3. **Android**: Menu → "Install app" or "Add to Home Screen"
|
||||
4. TREK launches fullscreen with its own icon, just like a native app
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## Tech stack
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
|
||||
<br />
|
||||
|
||||
<h2 id="docker-compose-production">Docker Compose (production)</h2>
|
||||
|
||||
<details>
|
||||
<summary>Docker Compose (recommended for production)</summary>
|
||||
<summary>Full compose example with secure defaults</summary>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -158,30 +218,19 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
|
||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
||||
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
||||
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
|
||||
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
|
||||
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
||||
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
|
||||
# - OIDC_ONLY=false # Set to true to force SSO-only login (disables password login and registration). Equivalent to toggling those off in Admin > Settings, but takes priority over any DB setting and cannot be changed at runtime.
|
||||
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
|
||||
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
|
||||
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
|
||||
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
|
||||
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
|
||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
||||
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
||||
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
|
||||
- TZ=${TZ:-UTC}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||
- APP_URL=${APP_URL:-} # required for OIDC + email links
|
||||
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
|
||||
# - TRUST_PROXY=1
|
||||
# - OIDC_ISSUER=https://auth.example.com
|
||||
# - OIDC_CLIENT_ID=trek
|
||||
# - OIDC_CLIENT_SECRET=supersecret
|
||||
# - OIDC_DISPLAY_NAME=SSO
|
||||
# - OIDC_ADMIN_CLAIM=groups
|
||||
# - OIDC_ADMIN_VALUE=app-trek-admins
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
@@ -194,29 +243,49 @@ services:
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
This example is aimed at reverse-proxy deployments where nginx, Caddy, Traefik, or a similar proxy terminates TLS in front of TREK. The three HTTPS-related variables work together:
|
||||
|
||||
- **`FORCE_HTTPS`** is 100% optional. When set to `true` it does four things: adds an HTTP-to-HTTPS 301 redirect, sends an HSTS header (`max-age=31536000`), adds the CSP `upgrade-insecure-requests` directive, and forces the session cookie `secure` flag on. It only makes sense behind a TLS-terminating proxy.
|
||||
- **`TRUST_PROXY`** tells Express how many proxies sit in front of TREK so it can read the real client IP from `X-Forwarded-For` and the protocol from `X-Forwarded-Proto`. Without it, `FORCE_HTTPS` redirects will loop because Express never sees the request as secure. In production (`NODE_ENV=production`) this defaults to `1` automatically; in development it is off unless explicitly set.
|
||||
- **`COOKIE_SECURE`** is normally auto-derived — the session cookie is marked `secure` whenever `NODE_ENV=production` or `FORCE_HTTPS=true`. Setting `COOKIE_SECURE=false` is an escape hatch that disables the `secure` flag even in production (e.g. testing over plain HTTP on a LAN). Do not disable it in real deployments.
|
||||
|
||||
If you access TREK directly on `http://<host>:3000` with no reverse proxy, leave `FORCE_HTTPS` unset (or remove it) and remove `TRUST_PROXY` to avoid redirect loops to a non-existent HTTPS endpoint.
|
||||
Then:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
|
||||
</details>
|
||||
|
||||
### Updating
|
||||
<br />
|
||||
|
||||
**Docker Compose** (recommended):
|
||||
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
|
||||
|
||||
```bash
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
|
||||
|
||||
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
|
||||
|
||||
TREK works as a Progressive Web App — no App Store needed.
|
||||
|
||||
1. Open TREK in the browser (HTTPS required)
|
||||
2. **iOS**: Share ▸ *Add to Home Screen*
|
||||
3. **Android**: Menu ▸ *Install app* (or *Add to Home Screen*)
|
||||
|
||||
TREK then launches fullscreen with its own icon, just like a native app.
|
||||
|
||||
<br />
|
||||
|
||||
## Updating
|
||||
|
||||
**Docker Compose:**
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
**Docker Run** — use the same volume paths from your original `docker run` command:
|
||||
**Docker run** — reuse the original volume paths:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/trek
|
||||
@@ -224,27 +293,23 @@ docker rm -f trek
|
||||
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
||||
```
|
||||
|
||||
> **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||
|
||||
### Rotating the Encryption Key
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app:
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||
|
||||
```bash
|
||||
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||
```
|
||||
|
||||
The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails.
|
||||
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
|
||||
|
||||
**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key".
|
||||
<h2 id="reverse-proxy">Reverse Proxy</h2>
|
||||
|
||||
### Reverse Proxy (recommended)
|
||||
|
||||
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
||||
|
||||
> **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
|
||||
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
@@ -260,8 +325,19 @@ server {
|
||||
listen 443 ssl http2;
|
||||
server_name trek.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
ssl_certificate /etc/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/privkey.pem;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://localhost:3000;
|
||||
@@ -269,21 +345,6 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
# File uploads are capped at 50 MB; backup restore ZIPs can include the full
|
||||
# uploads directory and may exceed that — raise this value if restores fail.
|
||||
client_max_body_size 500m;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -293,17 +354,24 @@ server {
|
||||
<details>
|
||||
<summary>Caddy</summary>
|
||||
|
||||
Caddy handles WebSocket upgrades automatically:
|
||||
|
||||
```
|
||||
```caddy
|
||||
trek.yourdomain.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
Caddy handles TLS and WebSockets automatically.
|
||||
|
||||
</details>
|
||||
|
||||
## Environment Variables
|
||||
<br />
|
||||
|
||||
## Environment variables
|
||||
|
||||
<details>
|
||||
<summary><b>Full reference</b></summary>
|
||||
|
||||
<br />
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
@@ -313,58 +381,46 @@ trek.yourdomain.com {
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `DEFAULT_LANGUAGE` | Default language shown on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback when no match is found. Supported values: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Activates automatically in production (defaults to `1`); off in development unless explicitly set. Must be set for `FORCE_HTTPS` redirects to work correctly. | `1` (when active) |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
|
||||
| `OIDC_ONLY` | Force SSO-only mode: disables password login and password registration, regardless of the granular toggles in Admin > Settings. The first SSO login becomes admin. Use when you want this enforced at the infrastructure level and not overridable via the UI. | `false` |
|
||||
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
|
||||
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
|
||||
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
|
||||
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
|
||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Useful for providers that expose it at a non-standard path (e.g. Authentik: `https://auth.example.com/application/o/trek/.well-known/openid-configuration`) | — |
|
||||
| **Initial Setup** | | |
|
||||
| `ADMIN_EMAIL` | Email for the first admin account created on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is generated and printed to the server log. Has no effect once any user exists. | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
|
||||
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
|
||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
|
||||
| **Initial setup** | | |
|
||||
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
|
||||
## Optional API Keys
|
||||
</details>
|
||||
|
||||
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
|
||||
|
||||
### Google Maps (Place Search & Photos)
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a project and enable the **Places API (New)**
|
||||
3. Create an API key under Credentials
|
||||
4. In TREK: Admin Panel → Settings → Google Maps
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mauriceboe/TREK.git
|
||||
cd TREK
|
||||
docker build -t trek .
|
||||
```
|
||||
<br />
|
||||
|
||||
## Data & Backups
|
||||
|
||||
- **Database**: SQLite, stored in `./data/travel.db`
|
||||
- **Uploads**: Stored in `./uploads/`
|
||||
- **Logs**: `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups**: Create and restore via Admin Panel
|
||||
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
||||
- **Database** — SQLite, stored in `./data/travel.db`
|
||||
- **Uploads** — stored in `./uploads/`
|
||||
- **Logs** — `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups** — create and restore via Admin Panel
|
||||
- **Auto-Backups** — configurable schedule and retention in Admin Panel
|
||||
|
||||
<br />
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { useAddonStore } from './store/addonStore'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TripPlannerPage from './pages/TripPlannerPage'
|
||||
import FilesPage from './pages/FilesPage'
|
||||
@@ -197,7 +199,10 @@ export default function App() {
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode, isSharedPage])
|
||||
|
||||
const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register')
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
|| location.pathname.startsWith('/forgot-password')
|
||||
|| location.pathname.startsWith('/reset-password')
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
@@ -210,6 +215,8 @@ export default function App() {
|
||||
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
|
||||
<Route path="/register" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
||||
<Route
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Menu, X, type LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface PageSidebarTab {
|
||||
id: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
interface PageSidebarProps {
|
||||
/** Uppercase label shown above the tab list, e.g. "SETTINGS". */
|
||||
sidebarLabel: string
|
||||
tabs: PageSidebarTab[]
|
||||
activeTab: string
|
||||
onTabChange: (id: string) => void
|
||||
children: React.ReactNode
|
||||
/** Small text at the very bottom of the sidebar (e.g. "v3.0 · self-hosted"). */
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-sidebar + right-panel layout used by the Settings and Admin pages.
|
||||
*
|
||||
* Desktop (>=1024px): sidebar is always visible at 260px; panel fills rest.
|
||||
* Mobile: sidebar collapses behind a hamburger at the top of the panel; tap
|
||||
* the hamburger to slide the sidebar in as an overlay, tap a tab to close.
|
||||
*/
|
||||
export default function PageSidebar({
|
||||
sidebarLabel,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
children,
|
||||
footer,
|
||||
}: PageSidebarProps): React.ReactElement {
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const activeLabel = tabs.find(t => t.id === activeTab)?.label ?? ''
|
||||
|
||||
// Close the mobile drawer on Escape or on outside click.
|
||||
const drawerRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMobileOpen(false) }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [mobileOpen])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden flex flex-col lg:flex-row relative"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
minHeight: 'min(820px, calc(100vh - var(--nav-h) - 120px))',
|
||||
}}
|
||||
>
|
||||
{/* Mobile top bar with hamburger */}
|
||||
<div
|
||||
className="lg:hidden flex items-center justify-between px-4 py-3 border-b"
|
||||
style={{ borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
|
||||
aria-label="Open navigation"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{activeLabel}
|
||||
</div>
|
||||
<div className="w-9" />
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar (always visible on lg) */}
|
||||
<aside
|
||||
className="hidden lg:flex flex-col shrink-0 relative"
|
||||
style={{
|
||||
width: 260,
|
||||
background: 'var(--bg-secondary)',
|
||||
borderRight: '1px solid var(--border-primary)',
|
||||
padding: '24px 14px',
|
||||
}}
|
||||
>
|
||||
<SidebarInner
|
||||
sidebarLabel={sidebarLabel}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
footer={footer}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<>
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40"
|
||||
style={{ background: 'rgba(0,0,0,0.35)' }}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
className="lg:hidden fixed top-0 left-0 bottom-0 z-50 flex flex-col shadow-2xl"
|
||||
style={{
|
||||
width: 280,
|
||||
background: 'var(--bg-secondary)',
|
||||
padding: '18px 14px',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 px-2">
|
||||
<span
|
||||
className="text-[11px] font-bold tracking-widest uppercase"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{sidebarLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
|
||||
aria-label="Close navigation"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<SidebarInner
|
||||
sidebarLabel={null}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => {
|
||||
onTabChange(id)
|
||||
setMobileOpen(false)
|
||||
}}
|
||||
footer={footer}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
<div className="flex-1 min-w-0" style={{ padding: '26px 28px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInner({
|
||||
sidebarLabel,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
footer,
|
||||
}: {
|
||||
sidebarLabel: string | null
|
||||
tabs: PageSidebarTab[]
|
||||
activeTab: string
|
||||
onTabChange: (id: string) => void
|
||||
footer?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{sidebarLabel && (
|
||||
<div
|
||||
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{sidebarLabel}
|
||||
</div>
|
||||
)}
|
||||
<nav className="flex flex-col gap-1 flex-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const active = tab.id === activeTab
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors"
|
||||
style={{
|
||||
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: active ? 600 : 500,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) e.currentTarget.style.background = 'var(--bg-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" />
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{footer && (
|
||||
<div
|
||||
className="mt-4 pt-3 px-3 text-[10px] tracking-wide"
|
||||
style={{ color: 'var(--text-faint)', borderTop: '1px solid var(--border-primary)' }}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -71,6 +71,7 @@ function TagChip({ tag }: { tag: string }) {
|
||||
}
|
||||
|
||||
function StyleDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -94,7 +95,7 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-slate-900 dark:text-white truncate">
|
||||
{selected ? selected.name : 'Select a Mapbox style'}
|
||||
{selected ? selected.name : t('settings.mapStylePlaceholder')}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
@@ -213,7 +214,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
{/* Provider picker — big cards so the choice is obvious */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Map Provider</label>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -227,7 +228,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
<Layers size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">Leaflet</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">Classic 2D, any raster tiles</div>
|
||||
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapLeafletSubtitle')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -240,17 +241,17 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
}`}
|
||||
>
|
||||
<span className="absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
||||
Experimental
|
||||
{t('settings.mapExperimental')}
|
||||
</span>
|
||||
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">Mapbox GL</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">Vector tiles, 3D buildings & terrain</div>
|
||||
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
Affects Trip Planner and Journey maps. Atlas always uses Leaflet.
|
||||
{t('settings.mapProviderHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -281,7 +282,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
{provider === 'mapbox-gl' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Mapbox Access Token</label>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
@@ -290,15 +291,15 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Public token (pk.*) from{' '}
|
||||
{t('settings.mapMapboxTokenHint')}{' '}
|
||||
<a href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noreferrer" className="underline">
|
||||
mapbox.com → Access tokens
|
||||
{t('settings.mapMapboxTokenLink')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Map Style</label>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label>
|
||||
<div className="mb-2">
|
||||
<StyleDropdown value={mapboxStyle} onChange={setMapboxStyle} />
|
||||
</div>
|
||||
@@ -310,7 +311,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Preset or your own <code className="text-[11px]">mapbox://styles/USER/ID</code> URL
|
||||
{t('settings.mapStyleHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -320,9 +321,9 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
: 'border-slate-200 opacity-60 dark:border-slate-700'
|
||||
}`}>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">3D Buildings & Terrain</div>
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">{t('settings.map3dBuildings')}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
Pitch + real 3D building extrusions — works on every style, including satellite.
|
||||
{t('settings.map3dHint')}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
@@ -333,22 +334,22 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white flex items-center gap-2">
|
||||
High Quality Mode
|
||||
<span className="text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
||||
Experimental
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="order-2 sm:order-1">{t('settings.mapHighQuality')}</span>
|
||||
<span className="order-1 sm:order-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
||||
{t('settings.mapExperimental')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
Antialiasing + globe projection for sharper edges and a realistic world view.{' '}
|
||||
<span className="text-amber-600 dark:text-amber-400">May impact performance on lower-end devices.</span>
|
||||
{t('settings.mapHighQualityHint')}{' '}
|
||||
<span className="text-amber-600 dark:text-amber-400">{t('settings.mapHighQualityWarning')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={mapboxQuality} onToggle={() => setMapboxQuality(!mapboxQuality)} />
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
|
||||
<strong className="text-slate-600 dark:text-slate-300">Tip:</strong> right-click and drag to rotate/pitch the map. Middle-click to add a place (right-click is reserved for rotation).
|
||||
<strong className="text-slate-600 dark:text-slate-300">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -161,6 +161,24 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'اتركه فارغًا لاستخدام OpenStreetMap افتراضيًا',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'قالب URL لبلاطات الخريطة',
|
||||
'settings.mapProvider': 'مزود الخريطة',
|
||||
'settings.mapProviderHint': 'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.',
|
||||
'settings.mapLeafletSubtitle': '2D كلاسيكي، أي بلاطات نقطية',
|
||||
'settings.mapMapboxSubtitle': 'بلاطات متجهية ومبانٍ ثلاثية الأبعاد وتضاريس',
|
||||
'settings.mapExperimental': 'تجريبي',
|
||||
'settings.mapMapboxToken': 'رمز وصول Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'الرمز العام (pk.*) من',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com ← رموز الوصول',
|
||||
'settings.mapStyle': 'نمط الخريطة',
|
||||
'settings.mapStylePlaceholder': 'اختر نمط Mapbox',
|
||||
'settings.mapStyleHint': 'إعداد مسبق أو عنوان URL mapbox://styles/USER/ID خاص بك',
|
||||
'settings.map3dBuildings': 'مبانٍ ثلاثية الأبعاد وتضاريس',
|
||||
'settings.map3dHint': 'إمالة + مبانٍ ثلاثية الأبعاد حقيقية — يعمل مع كل نمط بما في ذلك الأقمار الصناعية.',
|
||||
'settings.mapHighQuality': 'وضع الجودة العالية',
|
||||
'settings.mapHighQualityHint': 'تحسين الحواف + إسقاط كروي لحواف أكثر حدة وعرض واقعي للعالم.',
|
||||
'settings.mapHighQualityWarning': 'قد يؤثر على الأداء في الأجهزة الأقل قدرة.',
|
||||
'settings.mapTipLabel': 'نصيحة:',
|
||||
'settings.mapTip': 'انقر بزر الماوس الأيمن واسحب لتدوير/إمالة الخريطة. النقر الأوسط لإضافة مكان (النقر الأيمن مخصص للتدوير).',
|
||||
'settings.latitude': 'خط العرض',
|
||||
'settings.longitude': 'خط الطول',
|
||||
'settings.saveMap': 'حفظ الخريطة',
|
||||
@@ -445,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': 'كلمتا المرور غير متطابقتين',
|
||||
|
||||
@@ -156,6 +156,24 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL do modelo de blocos do mapa',
|
||||
'settings.mapProvider': 'Provedor de mapa',
|
||||
'settings.mapProviderHint': 'Afeta os mapas do Planejador de Viagem e Diário. Atlas sempre usa Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Clássico 2D, quaisquer blocos raster',
|
||||
'settings.mapMapboxSubtitle': 'Blocos vetoriais, prédios 3D & terreno',
|
||||
'settings.mapExperimental': 'Experimental',
|
||||
'settings.mapMapboxToken': 'Token de acesso Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Tokens de acesso',
|
||||
'settings.mapStyle': 'Estilo do mapa',
|
||||
'settings.mapStylePlaceholder': 'Selecionar um estilo Mapbox',
|
||||
'settings.mapStyleHint': 'Preset ou sua própria URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': 'Prédios 3D & terreno',
|
||||
'settings.map3dHint': 'Inclinação + extrusões 3D reais de prédios — funciona em todo estilo, incluindo satélite.',
|
||||
'settings.mapHighQuality': 'Modo alta qualidade',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + projeção global para bordas mais nítidas e uma visão realista do mundo.',
|
||||
'settings.mapHighQualityWarning': 'Pode afetar o desempenho em dispositivos menos potentes.',
|
||||
'settings.mapTipLabel': 'Dica:',
|
||||
'settings.mapTip': 'Clique direito e arraste para girar/inclinar o mapa. Clique do meio para adicionar um local (o clique direito é reservado para rotação).',
|
||||
'settings.latitude': 'Latitude',
|
||||
'settings.longitude': 'Longitude',
|
||||
'settings.saveMap': 'Salvar mapa',
|
||||
@@ -440,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',
|
||||
|
||||
@@ -157,6 +157,24 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Ponechte prázdné pro OpenStreetMap (výchozí)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL šablony pro mapové dlaždice',
|
||||
'settings.mapProvider': 'Poskytovatel mapy',
|
||||
'settings.mapProviderHint': 'Ovlivňuje mapy v Trip Planneru a Journey. Atlas vždy používá Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Klasické 2D, libovolné rastrové dlaždice',
|
||||
'settings.mapMapboxSubtitle': 'Vektorové dlaždice, 3D budovy a terén',
|
||||
'settings.mapExperimental': 'Experimentální',
|
||||
'settings.mapMapboxToken': 'Mapbox přístupový token',
|
||||
'settings.mapMapboxTokenHint': 'Veřejný token (pk.*) z',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Přístupové tokeny',
|
||||
'settings.mapStyle': 'Styl mapy',
|
||||
'settings.mapStylePlaceholder': 'Vyberte styl Mapbox',
|
||||
'settings.mapStyleHint': 'Preset nebo vaše vlastní URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': '3D budovy a terén',
|
||||
'settings.map3dHint': 'Náklon + skutečné 3D vyvýšení budov — funguje s každým stylem, včetně satelitu.',
|
||||
'settings.mapHighQuality': 'Režim vysoké kvality',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + zobrazení glóbu pro ostřejší hrany a realistický pohled na svět.',
|
||||
'settings.mapHighQualityWarning': 'Může ovlivnit výkon na slabších zařízeních.',
|
||||
'settings.mapTipLabel': 'Tip:',
|
||||
'settings.mapTip': 'Pravé tlačítko myši a táhněte pro rotaci/náklon mapy. Prostřední tlačítko pro přidání místa (pravé tlačítko je vyhrazeno pro rotaci).',
|
||||
'settings.latitude': 'Zeměpisná šířka',
|
||||
'settings.longitude': 'Zeměpisná délka',
|
||||
'settings.saveMap': 'Uložit nastavení mapy',
|
||||
@@ -440,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í',
|
||||
|
||||
@@ -159,6 +159,24 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Leer lassen für OpenStreetMap (Standard)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL-Template für die Kartenkacheln',
|
||||
'settings.mapProvider': 'Kartenanbieter',
|
||||
'settings.mapProviderHint': 'Gilt für Trip Planner und Journey. Atlas nutzt immer Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Klassisch 2D, beliebige Raster-Kacheln',
|
||||
'settings.mapMapboxSubtitle': 'Vektor-Kacheln, 3D-Gebäude & Terrain',
|
||||
'settings.mapExperimental': 'Experimentell',
|
||||
'settings.mapMapboxToken': 'Mapbox Access Token',
|
||||
'settings.mapMapboxTokenHint': 'Öffentliches Token (pk.*) von',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Access Tokens',
|
||||
'settings.mapStyle': 'Kartenstil',
|
||||
'settings.mapStylePlaceholder': 'Mapbox-Stil wählen',
|
||||
'settings.mapStyleHint': 'Preset oder eigene mapbox://styles/USER/ID URL',
|
||||
'settings.map3dBuildings': '3D-Gebäude & Terrain',
|
||||
'settings.map3dHint': 'Neigung + echte 3D-Gebäude-Extrusionen — funktioniert mit jedem Stil, auch Satellit.',
|
||||
'settings.mapHighQuality': 'Hochqualitäts-Modus',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + Globus-Projektion für schärfere Kanten und eine realistische Weltsicht.',
|
||||
'settings.mapHighQualityWarning': 'Kann die Performance auf schwächeren Geräten beeinträchtigen.',
|
||||
'settings.mapTipLabel': 'Tipp:',
|
||||
'settings.mapTip': 'Rechtsklick und ziehen, um die Karte zu drehen/neigen. Mittelklick, um einen Ort hinzuzufügen (Rechtsklick ist für die Rotation reserviert).',
|
||||
'settings.latitude': 'Breitengrad',
|
||||
'settings.longitude': 'Längengrad',
|
||||
'settings.saveMap': 'Karte speichern',
|
||||
@@ -445,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',
|
||||
|
||||
@@ -159,6 +159,24 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Leave empty for OpenStreetMap (default)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL template for map tiles',
|
||||
'settings.mapProvider': 'Map Provider',
|
||||
'settings.mapProviderHint': 'Affects Trip Planner and Journey maps. Atlas always uses Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Classic 2D, any raster tiles',
|
||||
'settings.mapMapboxSubtitle': 'Vector tiles, 3D buildings & terrain',
|
||||
'settings.mapExperimental': 'Experimental',
|
||||
'settings.mapMapboxToken': 'Mapbox Access Token',
|
||||
'settings.mapMapboxTokenHint': 'Public token (pk.*) from',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Access tokens',
|
||||
'settings.mapStyle': 'Map Style',
|
||||
'settings.mapStylePlaceholder': 'Select a Mapbox style',
|
||||
'settings.mapStyleHint': 'Preset or your own mapbox://styles/USER/ID URL',
|
||||
'settings.map3dBuildings': '3D Buildings & Terrain',
|
||||
'settings.map3dHint': 'Pitch + real 3D building extrusions — works on every style, including satellite.',
|
||||
'settings.mapHighQuality': 'High Quality Mode',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + globe projection for sharper edges and a realistic world view.',
|
||||
'settings.mapHighQualityWarning': 'May impact performance on lower-end devices.',
|
||||
'settings.mapTipLabel': 'Tip:',
|
||||
'settings.mapTip': 'right-click and drag to rotate/pitch the map. Middle-click to add a place (right-click is reserved for rotation).',
|
||||
'settings.latitude': 'Latitude',
|
||||
'settings.longitude': 'Longitude',
|
||||
'settings.saveMap': 'Save Map',
|
||||
@@ -504,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',
|
||||
|
||||
@@ -157,6 +157,24 @@ const es: Record<string, string> = {
|
||||
'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa',
|
||||
'settings.mapProvider': 'Proveedor de mapa',
|
||||
'settings.mapProviderHint': 'Afecta a los mapas de Trip Planner y Journey. Atlas siempre usa Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Clásico 2D, cualquier mosaico raster',
|
||||
'settings.mapMapboxSubtitle': 'Mosaicos vectoriales, edificios 3D y terreno',
|
||||
'settings.mapExperimental': 'Experimental',
|
||||
'settings.mapMapboxToken': 'Token de acceso de Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Tokens de acceso',
|
||||
'settings.mapStyle': 'Estilo de mapa',
|
||||
'settings.mapStylePlaceholder': 'Seleccionar un estilo de Mapbox',
|
||||
'settings.mapStyleHint': 'Preset o tu propia URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': 'Edificios 3D y terreno',
|
||||
'settings.map3dHint': 'Inclinación + extrusiones 3D reales de edificios — funciona con todos los estilos, incluyendo satélite.',
|
||||
'settings.mapHighQuality': 'Modo de alta calidad',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + proyección global para bordes más nítidos y una vista realista del mundo.',
|
||||
'settings.mapHighQualityWarning': 'Puede afectar el rendimiento en dispositivos menos potentes.',
|
||||
'settings.mapTipLabel': 'Consejo:',
|
||||
'settings.mapTip': 'Clic derecho y arrastrar para rotar/inclinar el mapa. Clic central para añadir un lugar (el clic derecho está reservado para la rotación).',
|
||||
'settings.latitude': 'Latitud',
|
||||
'settings.longitude': 'Longitud',
|
||||
'settings.saveMap': 'Guardar mapa',
|
||||
@@ -432,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',
|
||||
|
||||
@@ -156,6 +156,24 @@ const fr: Record<string, string> = {
|
||||
'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Modèle d\'URL pour les tuiles de carte',
|
||||
'settings.mapProvider': 'Fournisseur de carte',
|
||||
'settings.mapProviderHint': 'Affecte les cartes Trip Planner et Journey. Atlas utilise toujours Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Classique 2D, toutes tuiles raster',
|
||||
'settings.mapMapboxSubtitle': 'Tuiles vectorielles, bâtiments 3D & terrain',
|
||||
'settings.mapExperimental': 'Expérimental',
|
||||
'settings.mapMapboxToken': 'Jeton d\'accès Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Jeton public (pk.*) depuis',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Jetons d\'accès',
|
||||
'settings.mapStyle': 'Style de carte',
|
||||
'settings.mapStylePlaceholder': 'Sélectionner un style Mapbox',
|
||||
'settings.mapStyleHint': 'Preset ou votre propre URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': 'Bâtiments 3D & terrain',
|
||||
'settings.map3dHint': 'Inclinaison + extrusions 3D réelles des bâtiments — fonctionne avec tous les styles, y compris satellite.',
|
||||
'settings.mapHighQuality': 'Mode haute qualité',
|
||||
'settings.mapHighQualityHint': 'Anticrénelage + projection globe pour des bords plus nets et une vue réaliste du monde.',
|
||||
'settings.mapHighQualityWarning': 'Peut affecter les performances sur les appareils moins puissants.',
|
||||
'settings.mapTipLabel': 'Astuce :',
|
||||
'settings.mapTip': 'Clic droit et glisser pour pivoter/incliner la carte. Clic milieu pour ajouter un lieu (le clic droit est réservé à la rotation).',
|
||||
'settings.latitude': 'Latitude',
|
||||
'settings.longitude': 'Longitude',
|
||||
'settings.saveMap': 'Enregistrer la carte',
|
||||
@@ -433,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',
|
||||
|
||||
@@ -156,6 +156,24 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Hagyd üresen az OpenStreetMap használatához (alapértelmezett)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL sablon a térképcsempékhez',
|
||||
'settings.mapProvider': 'Térkép szolgáltató',
|
||||
'settings.mapProviderHint': 'A Trip Planner és Journey térképekre érvényes. Az Atlas mindig Leafletet használ.',
|
||||
'settings.mapLeafletSubtitle': 'Klasszikus 2D, bármilyen raszter csempe',
|
||||
'settings.mapMapboxSubtitle': 'Vektoros csempék, 3D épületek és terep',
|
||||
'settings.mapExperimental': 'Kísérleti',
|
||||
'settings.mapMapboxToken': 'Mapbox hozzáférési token',
|
||||
'settings.mapMapboxTokenHint': 'Publikus token (pk.*) innen:',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Hozzáférési tokenek',
|
||||
'settings.mapStyle': 'Térkép stílus',
|
||||
'settings.mapStylePlaceholder': 'Válassz Mapbox stílust',
|
||||
'settings.mapStyleHint': 'Preset vagy saját mapbox://styles/USER/ID URL',
|
||||
'settings.map3dBuildings': '3D épületek és terep',
|
||||
'settings.map3dHint': 'Dőlés + valódi 3D épület-kiemelés — minden stílussal működik, beleértve a műholdast.',
|
||||
'settings.mapHighQuality': 'Magas minőség mód',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + földgömb-vetítés az élesebb kontúrokért és egy valósághű világnézethez.',
|
||||
'settings.mapHighQualityWarning': 'Gyengébb eszközökön befolyásolhatja a teljesítményt.',
|
||||
'settings.mapTipLabel': 'Tipp:',
|
||||
'settings.mapTip': 'Jobb klikk és húzás a térkép forgatásához/döntéséhez. Középső kattintás hely hozzáadásához (a jobb klikk a forgatáshoz van fenntartva).',
|
||||
'settings.latitude': 'Szélességi fok',
|
||||
'settings.longitude': 'Hosszúsági fok',
|
||||
'settings.saveMap': 'Térkép mentése',
|
||||
@@ -440,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',
|
||||
|
||||
@@ -159,6 +159,24 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Kosongkan untuk OpenStreetMap (default)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Template URL untuk tile peta',
|
||||
'settings.mapProvider': 'Penyedia peta',
|
||||
'settings.mapProviderHint': 'Berlaku untuk peta Trip Planner dan Journey. Atlas selalu menggunakan Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Klasik 2D, tile raster apa pun',
|
||||
'settings.mapMapboxSubtitle': 'Tile vektor, bangunan 3D & medan',
|
||||
'settings.mapExperimental': 'Eksperimental',
|
||||
'settings.mapMapboxToken': 'Token akses Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Token publik (pk.*) dari',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Token akses',
|
||||
'settings.mapStyle': 'Gaya peta',
|
||||
'settings.mapStylePlaceholder': 'Pilih gaya Mapbox',
|
||||
'settings.mapStyleHint': 'Preset atau URL mapbox://styles/USER/ID milikmu',
|
||||
'settings.map3dBuildings': 'Bangunan 3D & medan',
|
||||
'settings.map3dHint': 'Kemiringan + ekstrusi bangunan 3D nyata — bekerja di semua gaya, termasuk satelit.',
|
||||
'settings.mapHighQuality': 'Mode kualitas tinggi',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + proyeksi globe untuk tepi yang lebih tajam dan tampilan dunia realistis.',
|
||||
'settings.mapHighQualityWarning': 'Dapat memengaruhi performa pada perangkat kelas bawah.',
|
||||
'settings.mapTipLabel': 'Tip:',
|
||||
'settings.mapTip': 'Klik kanan dan seret untuk memutar/memiringkan peta. Klik tengah untuk menambah tempat (klik kanan untuk rotasi).',
|
||||
'settings.latitude': 'Lintang',
|
||||
'settings.longitude': 'Bujur',
|
||||
'settings.saveMap': 'Simpan Peta',
|
||||
@@ -502,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',
|
||||
|
||||
@@ -156,6 +156,24 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Lascia vuoto per OpenStreetMap (predefinito)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Modello URL per i tile della mappa',
|
||||
'settings.mapProvider': 'Provider mappa',
|
||||
'settings.mapProviderHint': 'Influisce sulle mappe Trip Planner e Journey. Atlas usa sempre Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Classica 2D, qualsiasi tile raster',
|
||||
'settings.mapMapboxSubtitle': 'Tile vettoriali, edifici 3D e terreno',
|
||||
'settings.mapExperimental': 'Sperimentale',
|
||||
'settings.mapMapboxToken': 'Token di accesso Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Token pubblico (pk.*) da',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Token di accesso',
|
||||
'settings.mapStyle': 'Stile mappa',
|
||||
'settings.mapStylePlaceholder': 'Seleziona uno stile Mapbox',
|
||||
'settings.mapStyleHint': 'Preset o il tuo URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': 'Edifici 3D e terreno',
|
||||
'settings.map3dHint': 'Inclinazione + estrusioni 3D reali degli edifici — funziona con ogni stile, incluso satellite.',
|
||||
'settings.mapHighQuality': 'Modalità alta qualità',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + proiezione globo per bordi più nitidi e una vista realistica del mondo.',
|
||||
'settings.mapHighQualityWarning': 'Può influire sulle prestazioni su dispositivi meno potenti.',
|
||||
'settings.mapTipLabel': 'Suggerimento:',
|
||||
'settings.mapTip': 'Click destro e trascina per ruotare/inclinare la mappa. Click centrale per aggiungere un luogo (il click destro è riservato alla rotazione).',
|
||||
'settings.latitude': 'Latitudine',
|
||||
'settings.longitude': 'Longitudine',
|
||||
'settings.saveMap': 'Salva Mappa',
|
||||
@@ -440,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',
|
||||
|
||||
@@ -156,6 +156,24 @@ const nl: Record<string, string> = {
|
||||
'settings.mapDefaultHint': 'Laat leeg voor OpenStreetMap (standaard)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL-sjabloon voor kaarttegels',
|
||||
'settings.mapProvider': 'Kaartprovider',
|
||||
'settings.mapProviderHint': 'Geldt voor Trip Planner en Journey kaarten. Atlas gebruikt altijd Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Klassiek 2D, elke raster-tile',
|
||||
'settings.mapMapboxSubtitle': 'Vector tiles, 3D-gebouwen & terrein',
|
||||
'settings.mapExperimental': 'Experimenteel',
|
||||
'settings.mapMapboxToken': 'Mapbox Access Token',
|
||||
'settings.mapMapboxTokenHint': 'Openbaar token (pk.*) van',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Access tokens',
|
||||
'settings.mapStyle': 'Kaartstijl',
|
||||
'settings.mapStylePlaceholder': 'Kies een Mapbox-stijl',
|
||||
'settings.mapStyleHint': 'Preset of eigen mapbox://styles/USER/ID URL',
|
||||
'settings.map3dBuildings': '3D-gebouwen & terrein',
|
||||
'settings.map3dHint': 'Kanteling + echte 3D-gebouwenextrusies — werkt op elke stijl, inclusief satelliet.',
|
||||
'settings.mapHighQuality': 'Hoge kwaliteit modus',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + globeprojectie voor scherpere randen en een realistische wereldweergave.',
|
||||
'settings.mapHighQualityWarning': 'Kan de prestaties op minder krachtige apparaten beïnvloeden.',
|
||||
'settings.mapTipLabel': 'Tip:',
|
||||
'settings.mapTip': 'Rechts-klik en sleep om de kaart te roteren/kantelen. Middenklik om een locatie toe te voegen (rechts-klik is voor rotatie).',
|
||||
'settings.latitude': 'Breedtegraad',
|
||||
'settings.longitude': 'Lengtegraad',
|
||||
'settings.saveMap': 'Kaart opslaan',
|
||||
@@ -433,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',
|
||||
|
||||
@@ -139,6 +139,24 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Pozostaw puste dla OpenStreetMap (domyślnie)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Szablon URL dla kafelków mapy',
|
||||
'settings.mapProvider': 'Dostawca mapy',
|
||||
'settings.mapProviderHint': 'Dotyczy map Trip Planner i Journey. Atlas zawsze używa Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Klasyczne 2D, dowolne kafelki rastrowe',
|
||||
'settings.mapMapboxSubtitle': 'Kafelki wektorowe, budynki 3D i teren',
|
||||
'settings.mapExperimental': 'Eksperymentalne',
|
||||
'settings.mapMapboxToken': 'Token dostępu Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Token publiczny (pk.*) z',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Tokeny dostępu',
|
||||
'settings.mapStyle': 'Styl mapy',
|
||||
'settings.mapStylePlaceholder': 'Wybierz styl Mapbox',
|
||||
'settings.mapStyleHint': 'Preset lub własny URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': 'Budynki 3D i teren',
|
||||
'settings.map3dHint': 'Nachylenie + prawdziwe wytłaczanie budynków 3D — działa w każdym stylu, także satelitarnym.',
|
||||
'settings.mapHighQuality': 'Tryb wysokiej jakości',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + projekcja globusa dla ostrzejszych krawędzi i realistycznego widoku świata.',
|
||||
'settings.mapHighQualityWarning': 'Może wpływać na wydajność na słabszych urządzeniach.',
|
||||
'settings.mapTipLabel': 'Wskazówka:',
|
||||
'settings.mapTip': 'Kliknij prawym przyciskiem i przeciągnij, aby obrócić/pochylić mapę. Środkowy przycisk dodaje miejsce (prawy jest zarezerwowany dla obrotu).',
|
||||
'settings.latitude': 'Szerokość',
|
||||
'settings.longitude': 'Długość',
|
||||
'settings.saveMap': 'Zapisz mapę',
|
||||
@@ -407,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',
|
||||
|
||||
@@ -156,6 +156,24 @@ const ru: Record<string, string> = {
|
||||
'settings.mapDefaultHint': 'Оставьте пустым для OpenStreetMap (по умолчанию)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL-шаблон для тайлов карты',
|
||||
'settings.mapProvider': 'Провайдер карты',
|
||||
'settings.mapProviderHint': 'Применяется к Trip Planner и Journey. Atlas всегда использует Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Классические 2D, любые растровые тайлы',
|
||||
'settings.mapMapboxSubtitle': 'Векторные тайлы, 3D-здания и рельеф',
|
||||
'settings.mapExperimental': 'Экспериментально',
|
||||
'settings.mapMapboxToken': 'Токен доступа Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Публичный токен (pk.*) с',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Токены доступа',
|
||||
'settings.mapStyle': 'Стиль карты',
|
||||
'settings.mapStylePlaceholder': 'Выберите стиль Mapbox',
|
||||
'settings.mapStyleHint': 'Preset или собственный URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': '3D-здания и рельеф',
|
||||
'settings.map3dHint': 'Наклон + настоящие 3D-здания — работает со всеми стилями, включая спутник.',
|
||||
'settings.mapHighQuality': 'Режим высокого качества',
|
||||
'settings.mapHighQualityHint': 'Сглаживание + проекция глобуса для более чётких краёв и реалистичного вида мира.',
|
||||
'settings.mapHighQualityWarning': 'Может повлиять на производительность на слабых устройствах.',
|
||||
'settings.mapTipLabel': 'Совет:',
|
||||
'settings.mapTip': 'Зажмите правую кнопку мыши и перетащите, чтобы повернуть/наклонить карту. Клик средней кнопкой — добавить место (правая кнопка зарезервирована для вращения).',
|
||||
'settings.latitude': 'Широта',
|
||||
'settings.longitude': 'Долгота',
|
||||
'settings.saveMap': 'Сохранить карту',
|
||||
@@ -433,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': 'Ошибка демо-входа',
|
||||
|
||||
@@ -156,6 +156,24 @@ const zh: Record<string, string> = {
|
||||
'settings.mapDefaultHint': '留空则使用 OpenStreetMap(默认)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': '地图瓦片 URL 模板',
|
||||
'settings.mapProvider': '地图提供商',
|
||||
'settings.mapProviderHint': '影响行程规划和旅程地图。Atlas 始终使用 Leaflet。',
|
||||
'settings.mapLeafletSubtitle': '经典 2D,任何栅格瓦片',
|
||||
'settings.mapMapboxSubtitle': '矢量瓦片、3D 建筑和地形',
|
||||
'settings.mapExperimental': '实验性',
|
||||
'settings.mapMapboxToken': 'Mapbox 访问令牌',
|
||||
'settings.mapMapboxTokenHint': '公共令牌 (pk.*) 来自',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → 访问令牌',
|
||||
'settings.mapStyle': '地图样式',
|
||||
'settings.mapStylePlaceholder': '选择 Mapbox 样式',
|
||||
'settings.mapStyleHint': '预设或您自己的 mapbox://styles/USER/ID URL',
|
||||
'settings.map3dBuildings': '3D 建筑和地形',
|
||||
'settings.map3dHint': '倾斜 + 真实 3D 建筑拉伸 — 适用于所有样式,包括卫星。',
|
||||
'settings.mapHighQuality': '高画质模式',
|
||||
'settings.mapHighQualityHint': '抗锯齿 + 地球投影,带来更清晰的边缘和更真实的世界视图。',
|
||||
'settings.mapHighQualityWarning': '可能影响低端设备的性能。',
|
||||
'settings.mapTipLabel': '提示:',
|
||||
'settings.mapTip': '右键点击并拖动以旋转/倾斜地图。中键点击添加地点(右键用于旋转)。',
|
||||
'settings.latitude': '纬度',
|
||||
'settings.longitude': '经度',
|
||||
'settings.saveMap': '保存地图',
|
||||
@@ -433,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': '演示登录失败',
|
||||
|
||||
@@ -156,6 +156,24 @@ const zhTw: Record<string, string> = {
|
||||
'settings.mapDefaultHint': '留空則使用 OpenStreetMap(預設)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': '地圖瓦片 URL 模板',
|
||||
'settings.mapProvider': '地圖提供商',
|
||||
'settings.mapProviderHint': '影響行程規劃和旅程地圖。Atlas 始終使用 Leaflet。',
|
||||
'settings.mapLeafletSubtitle': '經典 2D,任何柵格瓦片',
|
||||
'settings.mapMapboxSubtitle': '向量瓦片、3D 建築和地形',
|
||||
'settings.mapExperimental': '實驗性',
|
||||
'settings.mapMapboxToken': 'Mapbox 存取權杖',
|
||||
'settings.mapMapboxTokenHint': '公開權杖 (pk.*) 來自',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → 存取權杖',
|
||||
'settings.mapStyle': '地圖樣式',
|
||||
'settings.mapStylePlaceholder': '選擇 Mapbox 樣式',
|
||||
'settings.mapStyleHint': '預設或您自己的 mapbox://styles/USER/ID URL',
|
||||
'settings.map3dBuildings': '3D 建築和地形',
|
||||
'settings.map3dHint': '傾斜 + 真實 3D 建築拉伸 — 適用於所有樣式,包括衛星。',
|
||||
'settings.mapHighQuality': '高畫質模式',
|
||||
'settings.mapHighQualityHint': '抗鋸齒 + 地球投影,帶來更清晰的邊緣和更真實的世界視圖。',
|
||||
'settings.mapHighQualityWarning': '可能影響低階裝置的效能。',
|
||||
'settings.mapTipLabel': '提示:',
|
||||
'settings.mapTip': '右鍵點擊並拖曳以旋轉/傾斜地圖。中鍵點擊新增地點(右鍵用於旋轉)。',
|
||||
'settings.latitude': '緯度',
|
||||
'settings.longitude': '經度',
|
||||
'settings.saveMap': '儲存地圖',
|
||||
@@ -492,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': '演示登入失敗',
|
||||
|
||||
@@ -20,8 +20,9 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
||||
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
|
||||
import PermissionsPanel from '../components/Admin/PermissionsPanel'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle, SlidersHorizontal, UserCog, Puzzle, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
|
||||
|
||||
interface AdminUser {
|
||||
id: number
|
||||
@@ -183,18 +184,18 @@ export default function AdminPage(): React.ReactElement {
|
||||
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const mcpEnabled = useAddonStore(s => s.isEnabled('mcp'))
|
||||
const devMode = useAuthStore(s => s.devMode)
|
||||
const TABS = [
|
||||
{ id: 'users', label: t('admin.tabs.users') },
|
||||
{ id: 'config', label: t('admin.tabs.config') },
|
||||
{ id: 'defaults', label: t('admin.tabs.defaults') },
|
||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'notifications', label: t('admin.tabs.notifications') },
|
||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||
{ id: 'audit', label: t('admin.tabs.audit') },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []),
|
||||
{ id: 'github', label: t('admin.tabs.github') },
|
||||
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications' }] : []),
|
||||
const TABS: PageSidebarTab[] = [
|
||||
{ id: 'users', label: t('admin.tabs.users'), icon: Users },
|
||||
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal },
|
||||
{ id: 'defaults', label: t('admin.tabs.defaults'), icon: UserCog },
|
||||
{ id: 'addons', label: t('admin.tabs.addons'), icon: Puzzle },
|
||||
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon },
|
||||
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell },
|
||||
{ id: 'backup', label: t('admin.tabs.backup'), icon: Database },
|
||||
{ id: 'audit', label: t('admin.tabs.audit'), icon: ScrollText },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: KeyRound }] : []),
|
||||
{ id: 'github', label: t('admin.tabs.github'), icon: GitBranch },
|
||||
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug }] : []),
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>('users')
|
||||
@@ -500,7 +501,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
<Navbar />
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center">
|
||||
@@ -586,24 +587,15 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="grid grid-cols-3 sm:flex gap-1 mb-6 rounded-xl p-1" style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)' }}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{/* Sidebar layout — nav on the left, active panel on the right */}
|
||||
<PageSidebar
|
||||
sidebarLabel={t('admin.title').toUpperCase()}
|
||||
tabs={TABS}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer="admin · self-hosted"
|
||||
>
|
||||
{/* Tab content */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||
@@ -1618,6 +1610,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
{activeTab === 'defaults' && <DefaultUserSettingsTab />}
|
||||
|
||||
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
|
||||
</PageSidebar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { Settings, Palette, Map, Bell, Plug, CloudOff, User, Info } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
|
||||
import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab'
|
||||
import MapSettingsTab from '../components/Settings/MapSettingsTab'
|
||||
import NotificationsTab from '../components/Settings/NotificationsTab'
|
||||
@@ -37,14 +38,18 @@ export default function SettingsPage(): React.ReactElement {
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const TABS = [
|
||||
{ id: 'display', label: t('settings.tabs.display') },
|
||||
{ id: 'map', label: t('settings.tabs.map') },
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications') },
|
||||
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations') }] : []),
|
||||
{ id: 'offline', label: t('settings.tabs.offline') },
|
||||
{ id: 'account', label: t('settings.tabs.account') },
|
||||
...(appVersion ? [{ id: 'about', label: t('settings.tabs.about') }] : []),
|
||||
const tabs: PageSidebarTab[] = [
|
||||
{ id: 'display', label: t('settings.tabs.display'), icon: Palette },
|
||||
{ id: 'map', label: t('settings.tabs.map'), icon: Map },
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications'), icon: Bell },
|
||||
...(hasIntegrations
|
||||
? [{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug }]
|
||||
: []),
|
||||
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
|
||||
{ id: 'account', label: t('settings.tabs.account'), icon: User },
|
||||
...(appVersion
|
||||
? [{ id: 'about', label: t('settings.tabs.about'), icon: Info }]
|
||||
: []),
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -52,7 +57,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
<Navbar />
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: 'var(--bg-tertiary)' }}>
|
||||
@@ -64,33 +69,24 @@ export default function SettingsPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="grid grid-cols-3 sm:flex gap-1 mb-6 rounded-xl p-1" style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)' }}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'display' && <DisplaySettingsTab />}
|
||||
{activeTab === 'map' && <MapSettingsTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'integrations' && hasIntegrations && <IntegrationsTab />}
|
||||
{activeTab === 'offline' && <OfflineTab />}
|
||||
{activeTab === 'account' && <AccountTab />}
|
||||
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
|
||||
{/* Sidebar layout */}
|
||||
<PageSidebar
|
||||
sidebarLabel={t('settings.title').toUpperCase()}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={appVersion ? `v${appVersion} · self-hosted` : 'self-hosted'}
|
||||
>
|
||||
{activeTab === 'display' && <DisplaySettingsTab />}
|
||||
{activeTab === 'map' && <MapSettingsTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'integrations' && hasIntegrations && <IntegrationsTab />}
|
||||
{activeTab === 'offline' && <OfflineTab />}
|
||||
{activeTab === 'account' && <AccountTab />}
|
||||
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
|
||||
</PageSidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
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 |
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||