diff --git a/.gitattributes b/.gitattributes index cce0ef3a..9ce14a9d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,5 @@ # Normalize line endings to LF on commit * text=auto eol=lf - # Explicitly enforce LF for source files *.ts text eol=lf *.tsx text eol=lf @@ -14,7 +13,6 @@ *.yaml text eol=lf *.py text eol=lf *.sh text eol=lf - # Binary files — no line ending conversion *.png binary *.jpg binary @@ -27,3 +25,4 @@ *.eot binary *.pdf binary *.zip binary +.github/assets/TREK1.gif filter=lfs diff=lfs merge=lfs -text diff --git a/.github/assets/TREK1.gif b/.github/assets/TREK1.gif new file mode 100644 index 00000000..14d50026 --- /dev/null +++ b/.github/assets/TREK1.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b9153871a41ca2c53ab9188ea400eb45f4065680eae0ee0ebc3fbcf18373d99c +size 95418702 diff --git a/README.md b/README.md index 46a969a5..14649089 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,160 @@ -

- - - - TREK - -
- Your Trips. Your Plan. -

+
-

- Discord - License: AGPL v3 - Docker Pulls - GitHub Stars - Last Commit -

+ + + + TREK + -

- A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more. -
- Live Demo — Try TREK without installing. Resets hourly. -

+### Your trips. Your plan. Your server. -![TREK Screenshot](docs/screenshot.png) -![TREK Screenshot 2](docs/screenshot-2.png) +A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in. + +
+ +Live Demo +  +Docker +  +Discord +  +Roadmap +
+License +Latest Release +Docker Pulls +Stars + +
+ +--- + +
+ +TREK — 60-second tour + +
+ +
+ +
+ Dashboard + Trip planner with 3D map + Journey journal + Budget tracker + Atlas · visited countries + Vacay planner + Iceland Ring Road + Admin panel +
+ +--- + +## What you get + + + + TREK feature tiles +
-More Screenshots +See all features -| | | -|---|---| -| ![Plan Detail](docs/screenshot-plan-detail.png) | ![Bookings](docs/screenshot-bookings.png) | -| ![Budget](docs/screenshot-budget.png) | ![Packing List](docs/screenshot-packing.png) | -| ![Collab](docs/screenshot-collab.png) | | + + + + + + + + + + + + + + + + +
+ +#### 🧭 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 + + + +#### 🧳 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 + +
+ +#### 👥 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 + + + +#### 📱 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 + +
+ +#### 🧩 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 + + + +#### 🤖 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 + +
+ +#### ⚙️ 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 + +
-## Features +
-### 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) +
-TREK works as a Progressive Web App — no App Store needed: +  ·  Docker Compose  ·  Helm / Kubernetes  ·  Install as PWA  ·  Reverse Proxy  ·   -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 +
+ +
+ +## Tech stack + +
+ +![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat-square&logo=node.js&logoColor=white) +![Express](https://img.shields.io/badge/Express-000000?style=flat-square&logo=express&logoColor=white) +![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white) +![React](https://img.shields.io/badge/React_18-61DAFB?style=flat-square&logo=react&logoColor=black) +![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat-square&logo=vite&logoColor=white) +![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white) +![Tailwind](https://img.shields.io/badge/Tailwind-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white) +![Leaflet](https://img.shields.io/badge/Leaflet-199900?style=flat-square&logo=leaflet&logoColor=white) +![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat-square&logo=docker&logoColor=white) + +
+ +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. + +
+ +

Docker Compose (production)

-Docker Compose (recommended for production) +Full compose example with secure defaults ```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://: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. +
-### Updating +
-**Docker Compose** (recommended): +

Helm (Kubernetes)

+ +```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. + +

Install as App (PWA)

+ +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. + +
+ +## 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 +

Rotating the Encryption Key

-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". +

Reverse Proxy

-### 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`.
Nginx @@ -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 {
Caddy -Caddy handles WebSocket upgrades automatically: - -``` +```caddy trek.yourdomain.com { reverse_proxy localhost:3000 } ``` +Caddy handles TLS and WebSockets automatically. +
-## Environment Variables +
+ +## Environment variables + +
+Full reference + +
| 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 +
-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 . -``` +
## 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 + +
## 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. + diff --git a/client/src/App.tsx b/client/src/App.tsx index 941492c2..5e0f5ed2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 ( @@ -210,6 +215,8 @@ export default function App() { } /> } /> } /> + } /> + } /> {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} } /> 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: { diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 2cafd31e..19f0a57a 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -463,6 +463,28 @@ const ar: Record = { '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': 'كلمتا المرور غير متطابقتين', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 0375068c..4219f9d6 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -458,6 +458,28 @@ const br: Record = { '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', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 2e65e451..24086d27 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -458,6 +458,28 @@ const cs: Record = { '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í', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 7319c2b4..6b79672d 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -463,6 +463,28 @@ const de: Record = { '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', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 106f8b06..be712440 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -522,6 +522,28 @@ const en: Record = { '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', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index c615d44c..36904a6f 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -450,6 +450,28 @@ const es: Record = { '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', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 230a7512..fda73908 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -451,6 +451,28 @@ const fr: Record = { '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', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 6500783b..21e9ce52 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -458,6 +458,28 @@ const hu: Record = { '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', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 20417ac6..ab52258d 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -520,6 +520,28 @@ const id: Record = { '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', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index dd3480a8..8c5c95c4 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -458,6 +458,28 @@ const it: Record = { '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', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 8c116b00..485d801b 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -451,6 +451,28 @@ const nl: Record = { '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', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index de065544..585f5e61 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -425,6 +425,28 @@ const pl: Record = { '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', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index d5c16fae..a0042c8b 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -451,6 +451,28 @@ const ru: Record = { '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': 'Ошибка демо-входа', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 7d8b6ba6..f0a9fd93 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -451,6 +451,28 @@ const zh: Record = { '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': '演示登录失败', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 27b4036f..5e2fa776 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -510,6 +510,28 @@ const zhTw: Record = { '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': '演示登入失敗', diff --git a/client/src/pages/ForgotPasswordPage.tsx b/client/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 00000000..e05ea8bf --- /dev/null +++ b/client/src/pages/ForgotPasswordPage.tsx @@ -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(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 ( +
+
+ + + {submitted ? ( +
+
+ +
+

+ {t('login.forgotPasswordSentTitle')} +

+

+ {t('login.forgotPasswordSentBody')} +

+ {smtpConfigured === false && ( +
+ +

+ {t('login.forgotPasswordSmtpHintOff')} +

+
+ )} + +
+ ) : ( + <> +

+ {t('login.forgotPasswordTitle')} +

+

+ {t('login.forgotPasswordBody')} +

+ {smtpConfigured === false && ( +
+ +

+ {t('login.forgotPasswordSmtpHintOff')} +

+
+ )} +
+
+ +
+ + ) => 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' }} + /> +
+
+ +
+ + )} +
+
+ ) +} + +export default ForgotPasswordPage diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index c9a3668d..d4646635 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -781,6 +781,17 @@ export default function LoginPage(): React.ReactElement { }} /> + {mode === 'login' && ( +
+ +
+ )} )} diff --git a/client/src/pages/ResetPasswordPage.tsx b/client/src/pages/ResetPasswordPage.tsx new file mode 100644 index 00000000..33c7c21b --- /dev/null +++ b/client/src/pages/ResetPasswordPage.tsx @@ -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) => ( +
+
{inner}
+
+ ) + + if (success) { + return shell( +
+
+

+ {t('login.resetPasswordSuccessTitle')} +

+

+ {t('login.resetPasswordSuccessBody')} +

+ +
+ ) + } + + if (!token) { + return shell( +
+
+

+ {t('login.resetPasswordInvalidLink')} +

+

+ {t('login.resetPasswordInvalidLinkBody')} +

+ +
+ ) + } + + return shell( + <> +

+ {t('login.resetPasswordTitle')} +

+

+ {mfaRequired ? t('login.resetPasswordMfaBody') : t('login.resetPasswordBody')} +

+ {error && ( +
{error}
+ )} +
+ {!mfaRequired && ( + <> +
+ +
+ + ) => setPw(e.target.value)} + required placeholder="••••••••" style={inputBase} + onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }} + onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }} + /> + +
+
+
+ +
+ + ) => setPw2(e.target.value)} + required placeholder="••••••••" style={inputBase} + onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }} + onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }} + /> +
+
+ + )} + {mfaRequired && ( +
+ +
+ + ) => 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' }} + /> +
+
+ )} + +
+ + ) +} + +export default ResetPasswordPage diff --git a/docs/logo-trek-dark.gif b/docs/logo-trek-dark.gif new file mode 100644 index 00000000..028cec16 Binary files /dev/null and b/docs/logo-trek-dark.gif differ diff --git a/docs/logo-trek-light.gif b/docs/logo-trek-light.gif new file mode 100644 index 00000000..00021473 Binary files /dev/null and b/docs/logo-trek-light.gif differ diff --git a/docs/screenshot-2.png b/docs/screenshot-2.png deleted file mode 100644 index b6bbde40..00000000 Binary files a/docs/screenshot-2.png and /dev/null differ diff --git a/docs/screenshot-bookings.png b/docs/screenshot-bookings.png deleted file mode 100644 index 367ff0f5..00000000 Binary files a/docs/screenshot-bookings.png and /dev/null differ diff --git a/docs/screenshot-budget.png b/docs/screenshot-budget.png deleted file mode 100644 index 3b5b25ef..00000000 Binary files a/docs/screenshot-budget.png and /dev/null differ diff --git a/docs/screenshot-collab.png b/docs/screenshot-collab.png deleted file mode 100644 index 112a6d36..00000000 Binary files a/docs/screenshot-collab.png and /dev/null differ diff --git a/docs/screenshot-packing.png b/docs/screenshot-packing.png deleted file mode 100644 index feda06ce..00000000 Binary files a/docs/screenshot-packing.png and /dev/null differ diff --git a/docs/screenshot-plan-detail.png b/docs/screenshot-plan-detail.png deleted file mode 100644 index c469e67e..00000000 Binary files a/docs/screenshot-plan-detail.png and /dev/null differ diff --git a/docs/screenshot-trip-mcp.png b/docs/screenshot-trip-mcp.png deleted file mode 100644 index 30a19e68..00000000 Binary files a/docs/screenshot-trip-mcp.png and /dev/null differ diff --git a/docs/screenshot.png b/docs/screenshot.png deleted file mode 100644 index 34650dca..00000000 Binary files a/docs/screenshot.png and /dev/null differ diff --git a/docs/screenshots/admin.png b/docs/screenshots/admin.png new file mode 100644 index 00000000..8797275b Binary files /dev/null and b/docs/screenshots/admin.png differ diff --git a/docs/screenshots/atlas.png b/docs/screenshots/atlas.png new file mode 100644 index 00000000..8b86a576 Binary files /dev/null and b/docs/screenshots/atlas.png differ diff --git a/docs/screenshots/budget.png b/docs/screenshots/budget.png new file mode 100644 index 00000000..e599246a Binary files /dev/null and b/docs/screenshots/budget.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 00000000..6535080e Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/journey.png b/docs/screenshots/journey.png new file mode 100644 index 00000000..6b9d7786 Binary files /dev/null and b/docs/screenshots/journey.png differ diff --git a/docs/screenshots/trip-iceland.png b/docs/screenshots/trip-iceland.png new file mode 100644 index 00000000..6c6448fb Binary files /dev/null and b/docs/screenshots/trip-iceland.png differ diff --git a/docs/screenshots/trip-planner.png b/docs/screenshots/trip-planner.png new file mode 100644 index 00000000..2f425db6 Binary files /dev/null and b/docs/screenshots/trip-planner.png differ diff --git a/docs/screenshots/vacay.png b/docs/screenshots/vacay.png new file mode 100644 index 00000000..b3e7f19e Binary files /dev/null and b/docs/screenshots/vacay.png differ diff --git a/docs/tiles/grid-desktop.svg b/docs/tiles/grid-desktop.svg new file mode 100644 index 00000000..5070a59a --- /dev/null +++ b/docs/tiles/grid-desktop.svg @@ -0,0 +1,146 @@ + + + + + + + + + + + + TRIP PLANNER + Drag and drop + day by day + Reorder, move across days, optimise + + + + + + + + + + + + + MAPS + See it all + on the map + Leaflet + Mapbox GL, 3D buildings + + + + + + + + + + + + + COLLAB + Plan together + in real time + WebSocket sync, chat, polls, notes + + + + + + + + + + + + + BUDGET + Track costs + per person + Pie chart, multi-currency, splits + + + + + + + + + + + + + PACKING + Lists, sorted. + by category + Templates, bag tracking, weights + + + + + + + + + + + + + JOURNEY + A journal for + every trip + Magazine entries, photos, maps + + + + + + + + + + + + + VACAY + Vacation days, + visualised + Calendar, 100+ country holidays + + + + + + + + + + + + + AI / MCP + Let AI plan + your trips + 80+ tools, OAuth 2.1, Claude-ready + + + + + + + + + + + + + SELF-HOSTED + Runs on + your server + Docker, SQLite, AGPL — your data, yours + + + \ No newline at end of file diff --git a/docs/tiles/grid-mobile.svg b/docs/tiles/grid-mobile.svg new file mode 100644 index 00000000..e4d74d1b --- /dev/null +++ b/docs/tiles/grid-mobile.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + TRIP PLANNER + Drag and drop + day by day + Reorder, move across days, optimise + + + + + + + + + + + + + MAPS + See it all + on the map + Leaflet + Mapbox GL, 3D buildings + + + + + + + + + + + + + COLLAB + Plan together + in real time + WebSocket sync, chat, polls, notes + + + + + + + + + + + + + BUDGET + Track costs + per person + Pie chart, multi-currency, splits + + + + + + + + + + + + + PACKING + Lists, sorted. + by category + Templates, bag tracking, weights + + + + + + + + + + + + + JOURNEY + A journal for + every trip + Magazine entries, photos, maps + + + + + + + + + + + + + VACAY + Vacation days, + visualised + Calendar, 100+ country holidays + + + + + + + + + + + + + AI / MCP + Let AI plan + your trips + 80+ tools, OAuth 2.1, Claude-ready + + + \ No newline at end of file diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f2b476ab..af0f4c35 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1772,6 +1772,26 @@ function runMigrations(db: Database.Database): void { 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) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 7fad6e6b..289e1851 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -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, diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index d2dddf39..31f573ba 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -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(); }; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index f3846ced..a99ababb 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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(); const mfaAttempts = new Map(); +const forgotAttempts = new Map(); +const resetAttempts = new Map(); 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); diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index b091bf58..de8a32c3 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -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(); +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 // --------------------------------------------------------------------------- diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index 0773a279..1f28765d 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -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 = { + 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 = ` +

${safeGreeting},

+

${safeBody}

+

+ ${safeCta} +

+

${safeExpiry}

+

${safeIgnore}

+ `; + 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 { const config = getSmtpConfig(); if (!config) return false;