Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfe6664ac4 | |||
| 117942f45e | |||
| e7211325df | |||
| 7e49f3467c | |||
| 93b51a0bf5 | |||
| 5b710a429a | |||
| da3cba2de3 | |||
| 7f87dc1ce1 | |||
| e7b419d397 | |||
| de3152ee57 | |||
| de6c0fb781 | |||
| 9f1d05e886 | |||
| 25f326a659 | |||
| 418f3e0bb2 | |||
| 640e5616e9 | |||
| 22f3bf4bfc | |||
| 256f38d8fa | |||
| 9592cc663f | |||
| dba4b28380 |
@@ -15,7 +15,7 @@
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
||||
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||
- [ ] This PR targets the `dev` branch, not `main`
|
||||
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
|
||||
- [ ] I have tested my changes locally
|
||||
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||
- [ ] I have updated documentation if needed
|
||||
|
||||
@@ -32,6 +32,30 @@ jobs:
|
||||
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
||||
if (!hasLabel) continue;
|
||||
|
||||
// Wiki-only PRs are exempt — clear label and skip
|
||||
const files = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pull.number,
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
files.push(...data);
|
||||
if (data.length < 100) break;
|
||||
}
|
||||
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||
if (allWiki) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pull.number,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdAt = new Date(pull.created_at);
|
||||
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||
|
||||
|
||||
@@ -27,6 +27,33 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Wiki-only PRs are exempt from branch enforcement
|
||||
const files = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
files.push(...data);
|
||||
if (data.length < 100) break;
|
||||
}
|
||||
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||
if (allWiki) {
|
||||
console.log('All changed files are under wiki/ — skipping enforcement.');
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If the base was fixed, remove the label and let it through
|
||||
if (base !== 'main') {
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Security Scan
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
scout:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: trek:scan
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/scout-action@v1
|
||||
with:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
only-severities: critical,high
|
||||
exit-code: true
|
||||
@@ -3,6 +3,8 @@ node_modules/
|
||||
|
||||
# Build output
|
||||
client/dist/
|
||||
server/public/*
|
||||
!server/public/.gitkeep
|
||||
|
||||
# Generated PWA icons (built from SVG via prebuild)
|
||||
client/public/icons/*.png
|
||||
|
||||
@@ -7,7 +7,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
||||
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
||||
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
|
||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build React client
|
||||
FROM node:22-alpine AS client-builder
|
||||
FROM node:24-alpine AS client-builder
|
||||
WORKDIR /app/client
|
||||
COPY client/package*.json ./
|
||||
RUN npm ci
|
||||
@@ -7,7 +7,7 @@ COPY client/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production server
|
||||
FROM node:22-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -15,13 +15,16 @@ WORKDIR /app
|
||||
COPY server/package*.json ./
|
||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
npm ci --production && \
|
||||
apk del python3 make g++
|
||||
rm package-lock.json && \
|
||||
apk del python3 make g++ && \
|
||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
COPY server/ ./
|
||||
COPY --from=client-builder /app/client/dist ./public
|
||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||
|
||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
RUN rm -f package-lock.json && \
|
||||
mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
||||
chown -R node:node /app
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
|
||||
If you discover a security vulnerability, please report it responsibly:
|
||||
|
||||
1. **Do not** open a public issue
|
||||
2. Email: **mauriceboe@icloud.com**
|
||||
2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch**
|
||||
3. Include a description of the vulnerability and steps to reproduce
|
||||
|
||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
CLIENT_DIR="$REPO_ROOT/client"
|
||||
SERVER_DIR="$REPO_ROOT/server"
|
||||
PUBLIC_DIR="$REPO_ROOT/server/public"
|
||||
|
||||
echo "==> Installing client dependencies"
|
||||
cd "$CLIENT_DIR"
|
||||
npm ci
|
||||
|
||||
echo "==> Building client"
|
||||
npm run build
|
||||
|
||||
echo "==> Installing server dependencies"
|
||||
cd "$SERVER_DIR"
|
||||
npm ci
|
||||
|
||||
echo "==> Populating server/public"
|
||||
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
|
||||
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
|
||||
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
|
||||
|
||||
echo "==> Done — server/public is ready"
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.14
|
||||
version: 3.0.21
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.14"
|
||||
appVersion: "3.0.21"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.14",
|
||||
"version": "3.0.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -18,6 +18,7 @@
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"heic-to": "^1.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function App() {
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
||||
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
import en from '../i18n/translations/en'
|
||||
import br from '../i18n/translations/br'
|
||||
import de from '../i18n/translations/de'
|
||||
@@ -33,6 +34,7 @@ function translateRateLimit(): string {
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
timeout: 8000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -42,24 +44,24 @@ const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
||||
|
||||
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
// Attach a per-request idempotency key to all write operations so the
|
||||
// server can deduplicate retried requests (e.g. network blips).
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
(config) => {
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
// Attach a per-request idempotency key to all write operations so the
|
||||
// server can deduplicate retried requests (e.g. network blips).
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
export function isAuthPublicPath(pathname: string): boolean {
|
||||
@@ -68,36 +70,84 @@ export function isAuthPublicPath(pathname: string): boolean {
|
||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||
}
|
||||
|
||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
||||
// Unregisters the SW before reloading so the navigation reaches the network.
|
||||
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
|
||||
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
|
||||
async function unregisterSWAndReload(): Promise<void> {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker?.getRegistration()
|
||||
if (reg) await reg.unregister()
|
||||
} catch { /* ignore */ }
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
(response) => {
|
||||
sessionStorage.removeItem('proxy_reauth_attempted')
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
|
||||
// as a CORS error with no response object. Probe the health endpoint to
|
||||
// distinguish a proxy auth challenge from a genuine outage. If the server
|
||||
// is reachable, a top-level reload lets the edge proxy run its auth flow.
|
||||
if (!error.response && navigator.onLine) {
|
||||
await probeNow()
|
||||
// Both the original request and the health probe failed while the device
|
||||
// has a network interface. This matches the proxy-auth-challenge pattern
|
||||
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
|
||||
// Guard with sessionStorage to prevent reload loops (server genuinely
|
||||
// down would also land here, but only reloads once).
|
||||
if (!isReachable()) {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
const data = error.response.data as { error?: string } | undefined
|
||||
if (data && typeof data === 'object') {
|
||||
data.error = translated
|
||||
} else {
|
||||
error.response.data = { error: translated }
|
||||
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||
// always application/json, so checking for text/html is unambiguous.
|
||||
if (error.response?.status === 401) {
|
||||
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||
if (ct.includes('text/html')) {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
error.message = translated
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
const data = error.response.data as { error?: string } | undefined
|
||||
if (data && typeof data === 'object') {
|
||||
data.error = translated
|
||||
} else {
|
||||
error.response.data = { error: translated }
|
||||
}
|
||||
error.message = translated
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
@@ -142,6 +192,7 @@ export const oauthApi = {
|
||||
state?: string
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
resource?: string
|
||||
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
||||
|
||||
/** Submit user consent (approve or deny) */
|
||||
@@ -153,12 +204,13 @@ export const oauthApi = {
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
approved: boolean
|
||||
resource?: string
|
||||
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
||||
|
||||
clients: {
|
||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
||||
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||
},
|
||||
@@ -215,11 +267,11 @@ export const placesApi = {
|
||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
@@ -313,7 +365,7 @@ export const adminApi = {
|
||||
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
||||
@@ -322,7 +374,7 @@ export const adminApi = {
|
||||
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||
sendTestNotification: (data: Record<string, unknown>) =>
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||
@@ -387,7 +439,7 @@ export const journeyApi = {
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||
@@ -443,7 +495,7 @@ export const weatherApi = {
|
||||
|
||||
export const configApi = {
|
||||
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
||||
apiClient.get('/config').then(r => r.data),
|
||||
apiClient.get('/config').then(r => r.data),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
@@ -529,21 +581,21 @@ export const notificationsApi = {
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||
unreadCount: () =>
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||
markRead: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
markUnread: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||
markAllRead: () =>
|
||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||
delete: (id: number) =>
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
deleteAll: () =>
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
respond: (id: number, response: 'positive' | 'negative') =>
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
export default apiClient
|
||||
@@ -719,8 +719,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{t('budget.title')}
|
||||
</h2>
|
||||
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<div style={{ width: 150 }}>
|
||||
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<div className="max-md:!w-full" style={{ width: 150 }}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
@@ -730,7 +730,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
/>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, width: 260 }}>
|
||||
<div className="max-md:!w-full" style={{ display: 'flex', gap: 6, width: 260 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
@@ -763,7 +763,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Download size={14} strokeWidth={2.5} /> CSV
|
||||
<Download size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean {
|
||||
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
||||
}
|
||||
|
||||
// Terrain is only genuinely useful for the satellite imagery styles — on
|
||||
// clean flat styles like streets/light/dark it nudges route lines onto
|
||||
// the DEM while our HTML markers stay at Z=0, which causes the visible
|
||||
// offset when the map is pitched. Restrict terrain to satellite.
|
||||
// Terrain is only genuinely useful for styles that benefit from elevation
|
||||
// data. On flat vector styles (streets/light/dark) it nudges route lines
|
||||
// onto the DEM while HTML markers stay at Z=0, causing a visible drift
|
||||
// when the map is pitched. Satellite and Outdoors are the intended styles
|
||||
// for terrain; markers are re-pinned by syncMarkerAltitudes().
|
||||
export function wantsTerrain(style: string): boolean {
|
||||
return style === 'mapbox://styles/mapbox/satellite-v9'
|
||||
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
||||
|| style === 'mapbox://styles/mapbox/outdoors-v12'
|
||||
}
|
||||
|
||||
// 3D can be added to every style now — the standard family has it built-in
|
||||
|
||||
@@ -23,6 +23,11 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
@@ -362,26 +367,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
})
|
||||
}
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
|
||||
// Get span phase: how a reservation relates to a specific day (by id)
|
||||
const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
if (!startDayId || startDayId === endDayId) return 'single'
|
||||
if (dayId === startDayId) return 'start'
|
||||
if (dayId === endDayId) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
// Get the appropriate display time for a reservation on a specific day
|
||||
const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => {
|
||||
const phase = getSpanPhase(r, dayId)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
}
|
||||
|
||||
// Get phase label for multi-day badge
|
||||
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
||||
if (phase === 'single') return null
|
||||
@@ -406,27 +391,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
return { day_id: startId, end_day_id: targetDayId }
|
||||
}
|
||||
|
||||
const getTransportForDay = (dayId: number) => {
|
||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
||||
return reservations.filter(r => {
|
||||
if (!TRANSPORT_TYPES.has(r.type)) return false
|
||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
|
||||
if (startDayId == null) return false
|
||||
|
||||
if (endDayId !== startDayId) {
|
||||
const startDay = days.find(d => d.id === startDayId)
|
||||
const endDay = days.find(d => d.id === endDayId)
|
||||
const thisDay = days.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
|
||||
}
|
||||
return startDayId === dayId
|
||||
})
|
||||
}
|
||||
const getTransportForDay = (dayId: number) =>
|
||||
_getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days })
|
||||
|
||||
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
||||
const getActiveRentalsForDay = (dayId: number) => {
|
||||
@@ -446,20 +412,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const getDayAssignments = (dayId) =>
|
||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
|
||||
// Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null
|
||||
const parseTimeToMinutes = (time?: string | null): number | null => {
|
||||
if (!time) return null
|
||||
// ISO-Format "2025-03-30T09:00:00"
|
||||
if (time.includes('T')) {
|
||||
const [h, m] = time.split('T')[1].split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
// Einfaches "HH:MM" Format
|
||||
const parts = time.split(':').map(Number)
|
||||
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
||||
return null
|
||||
}
|
||||
|
||||
// Compute initial day_plan_position for a transport based on time
|
||||
const computeTransportPosition = (r, da) => {
|
||||
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
||||
@@ -501,64 +453,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||
}
|
||||
|
||||
const getMergedItems = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
const transport = getTransportForDay(dayId)
|
||||
|
||||
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
|
||||
const baseItems = [
|
||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
||||
].sort((a, b) => a.sortKey - b.sortKey)
|
||||
|
||||
// Transports are inserted among places based on time
|
||||
const timedTransports = transport.map(r => ({
|
||||
type: 'transport' as const,
|
||||
data: r,
|
||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0,
|
||||
})).sort((a, b) => a.minutes - b.minutes)
|
||||
|
||||
if (timedTransports.length === 0) return baseItems
|
||||
if (baseItems.length === 0) {
|
||||
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
|
||||
}
|
||||
|
||||
// Insert transports among places based on per-day position or time
|
||||
const result = [...baseItems]
|
||||
for (let ti = 0; ti < timedTransports.length; ti++) {
|
||||
const timed = timedTransports[ti]
|
||||
const minutes = timed.minutes
|
||||
|
||||
// Use per-day position if explicitly set by user reorder
|
||||
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
||||
if (perDayPos != null) {
|
||||
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
||||
continue
|
||||
}
|
||||
|
||||
// Find insertion position: after the last place with time <= this transport's time
|
||||
let insertAfterKey = -Infinity
|
||||
for (const item of result) {
|
||||
if (item.type === 'place') {
|
||||
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
||||
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
||||
} else if (item.type === 'transport') {
|
||||
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
||||
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
||||
}
|
||||
}
|
||||
|
||||
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
||||
const sortKey = insertAfterKey === -Infinity
|
||||
? lastKey + 0.5 + ti * 0.01
|
||||
: insertAfterKey + 0.01 + ti * 0.001
|
||||
|
||||
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
const getMergedItems = (dayId: number): MergedItem[] =>
|
||||
_getMergedItems({
|
||||
dayAssignments: getDayAssignments(dayId),
|
||||
dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order),
|
||||
dayTransports: getTransportForDay(dayId),
|
||||
dayId,
|
||||
getDisplayTime: getDisplayTimeForDay,
|
||||
})
|
||||
|
||||
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -169,7 +169,10 @@ export default function PlaceInspector({
|
||||
|
||||
const category = categories?.find(c => c.id === place.category_id)
|
||||
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
|
||||
const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
|
||||
const assignmentInDay = selectedDayId
|
||||
? ((selectedAssignmentId ? dayAssignments.find(a => a.id === selectedAssignmentId) : null)
|
||||
?? dayAssignments.find(a => a.place?.id === place.id))
|
||||
: null
|
||||
|
||||
const openingHours = googleDetails?.opening_hours || null
|
||||
const openNow = googleDetails?.open_now ?? null
|
||||
|
||||
@@ -1674,6 +1674,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'فشل في الحذف',
|
||||
'journey.entries.deleteTitle': 'حذف الإدخال',
|
||||
'journey.photosUploaded': 'تم رفع {count} صورة',
|
||||
'journey.photosUploadFailed': 'فشل رفع بعض الصور',
|
||||
'journey.photosAdded': 'تمت إضافة {count} صورة',
|
||||
'journey.picker.tripPeriod': 'فترة الرحلة',
|
||||
'journey.picker.dateRange': 'نطاق التاريخ',
|
||||
@@ -1705,6 +1706,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
||||
'journey.editor.uploadFailed': 'فشل رفع الصور',
|
||||
'journey.editor.uploadPhotos': 'رفع صور',
|
||||
'journey.editor.uploading': '...جارٍ الرفع',
|
||||
'journey.editor.fromGallery': 'من المعرض',
|
||||
|
||||
@@ -2077,6 +2077,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
|
||||
'journey.editor.uploadFailed': 'Falha ao enviar fotos',
|
||||
'journey.editor.uploadPhotos': 'Enviar fotos',
|
||||
'journey.editor.uploading': 'Enviando...',
|
||||
'journey.editor.fromGallery': 'Da galeria',
|
||||
@@ -2169,6 +2170,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Falha ao excluir',
|
||||
'journey.entries.deleteTitle': 'Excluir entrada',
|
||||
'journey.photosUploaded': '{count} fotos enviadas',
|
||||
'journey.photosUploadFailed': 'Algumas fotos não foram enviadas',
|
||||
'journey.photosAdded': '{count} fotos adicionadas',
|
||||
'journey.public.notFound': 'Não encontrado',
|
||||
'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.',
|
||||
|
||||
@@ -2082,6 +2082,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'místa',
|
||||
'journey.synced.synced': 'synchronizováno',
|
||||
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?',
|
||||
'journey.editor.uploadFailed': 'Nahrávání fotek selhalo',
|
||||
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
||||
'journey.editor.uploading': 'Nahrávání...',
|
||||
'journey.editor.fromGallery': 'Z galerie',
|
||||
@@ -2174,6 +2175,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Smazání se nezdařilo',
|
||||
'journey.entries.deleteTitle': 'Smazat záznam',
|
||||
'journey.photosUploaded': '{count} fotografií nahráno',
|
||||
'journey.photosUploadFailed': 'Některé fotky se nepodařilo nahrát',
|
||||
'journey.photosAdded': '{count} fotografií přidáno',
|
||||
'journey.public.notFound': 'Nenalezeno',
|
||||
'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.',
|
||||
|
||||
@@ -2085,6 +2085,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'Orte',
|
||||
'journey.synced.synced': 'synchronisiert',
|
||||
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
|
||||
'journey.editor.uploadFailed': 'Foto-Upload fehlgeschlagen',
|
||||
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
||||
'journey.editor.uploading': 'Hochladen...',
|
||||
'journey.editor.fromGallery': 'Aus Galerie',
|
||||
@@ -2181,6 +2182,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Löschen fehlgeschlagen',
|
||||
'journey.entries.deleteTitle': 'Eintrag löschen',
|
||||
'journey.photosUploaded': '{count} Fotos hochgeladen',
|
||||
'journey.photosUploadFailed': 'Einige Fotos konnten nicht hochgeladen werden',
|
||||
'journey.photosAdded': '{count} Fotos hinzugefügt',
|
||||
'journey.public.notFound': 'Nicht gefunden',
|
||||
'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.',
|
||||
|
||||
@@ -2111,6 +2111,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
|
||||
'journey.editor.uploadFailed': 'Photo upload failed',
|
||||
'journey.editor.uploadPhotos': 'Upload photos',
|
||||
'journey.editor.uploading': 'Uploading...',
|
||||
'journey.editor.fromGallery': 'From Gallery',
|
||||
@@ -2219,6 +2220,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Failed to delete',
|
||||
'journey.entries.deleteTitle': 'Delete Entry',
|
||||
'journey.photosUploaded': '{count} photos uploaded',
|
||||
'journey.photosUploadFailed': 'Some photos failed to upload',
|
||||
'journey.photosAdded': '{count} photos added',
|
||||
|
||||
// Journey — Public Page
|
||||
|
||||
@@ -2084,6 +2084,7 @@ const es: Record<string, string> = {
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
|
||||
'journey.editor.uploadFailed': 'Error al subir fotos',
|
||||
'journey.editor.uploadPhotos': 'Subir fotos',
|
||||
'journey.editor.uploading': 'Subiendo...',
|
||||
'journey.editor.fromGallery': 'Desde galería',
|
||||
@@ -2176,6 +2177,7 @@ const es: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': 'Error al eliminar',
|
||||
'journey.entries.deleteTitle': 'Eliminar entrada',
|
||||
'journey.photosUploaded': '{count} fotos subidas',
|
||||
'journey.photosUploadFailed': 'Algunas fotos no se pudieron subir',
|
||||
'journey.photosAdded': '{count} fotos añadidas',
|
||||
'journey.public.notFound': 'No encontrado',
|
||||
'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.',
|
||||
|
||||
@@ -2078,6 +2078,7 @@ const fr: Record<string, string> = {
|
||||
'journey.synced.places': 'lieux',
|
||||
'journey.synced.synced': 'synchronisé',
|
||||
'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?',
|
||||
'journey.editor.uploadFailed': 'Échec du téléversement des photos',
|
||||
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
||||
'journey.editor.uploading': 'Envoi...',
|
||||
'journey.editor.fromGallery': 'Depuis la galerie',
|
||||
@@ -2170,6 +2171,7 @@ const fr: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': 'Échec de la suppression',
|
||||
'journey.entries.deleteTitle': "Supprimer l'entrée",
|
||||
'journey.photosUploaded': '{count} photos téléversées',
|
||||
'journey.photosUploadFailed': "Certaines photos n'ont pas pu être téléversées",
|
||||
'journey.photosAdded': '{count} photos ajoutées',
|
||||
'journey.public.notFound': 'Introuvable',
|
||||
'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.',
|
||||
|
||||
@@ -2079,6 +2079,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'helyszín',
|
||||
'journey.synced.synced': 'szinkronizálva',
|
||||
'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?',
|
||||
'journey.editor.uploadFailed': 'A fotók feltöltése sikertelen',
|
||||
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
||||
'journey.editor.uploading': 'Feltöltés...',
|
||||
'journey.editor.fromGallery': 'Galériából',
|
||||
@@ -2171,6 +2172,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Törlés sikertelen',
|
||||
'journey.entries.deleteTitle': 'Bejegyzés törlése',
|
||||
'journey.photosUploaded': '{count} fotó feltöltve',
|
||||
'journey.photosUploadFailed': 'Néhány fotót nem sikerült feltölteni',
|
||||
'journey.photosAdded': '{count} fotó hozzáadva',
|
||||
'journey.public.notFound': 'Nem található',
|
||||
'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.',
|
||||
|
||||
@@ -2094,6 +2094,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
|
||||
'journey.editor.uploadFailed': 'Gagal mengunggah foto',
|
||||
'journey.editor.uploadPhotos': 'Unggah foto',
|
||||
'journey.editor.uploading': 'Mengunggah...',
|
||||
'journey.editor.fromGallery': 'Dari Galeri',
|
||||
@@ -2198,6 +2199,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Gagal menghapus',
|
||||
'journey.entries.deleteTitle': 'Hapus Entri',
|
||||
'journey.photosUploaded': '{count} foto diunggah',
|
||||
'journey.photosUploadFailed': 'Beberapa foto gagal diunggah',
|
||||
'journey.photosAdded': '{count} foto ditambahkan',
|
||||
|
||||
// Journey — Public Page
|
||||
|
||||
@@ -2079,6 +2079,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'luoghi',
|
||||
'journey.synced.synced': 'sincronizzato',
|
||||
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
|
||||
'journey.editor.uploadFailed': 'Caricamento foto non riuscito',
|
||||
'journey.editor.uploadPhotos': 'Carica foto',
|
||||
'journey.editor.uploading': 'Caricamento...',
|
||||
'journey.editor.fromGallery': 'Dalla galleria',
|
||||
@@ -2171,6 +2172,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Eliminazione non riuscita',
|
||||
'journey.entries.deleteTitle': 'Elimina voce',
|
||||
'journey.photosUploaded': '{count} foto caricate',
|
||||
'journey.photosUploadFailed': 'Alcune foto non sono state caricate',
|
||||
'journey.photosAdded': '{count} foto aggiunte',
|
||||
'journey.public.notFound': 'Non trovato',
|
||||
'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.',
|
||||
|
||||
@@ -2078,6 +2078,7 @@ const nl: Record<string, string> = {
|
||||
'journey.synced.places': 'plaatsen',
|
||||
'journey.synced.synced': 'gesynchroniseerd',
|
||||
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
|
||||
'journey.editor.uploadFailed': 'Foto uploaden mislukt',
|
||||
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
||||
'journey.editor.uploading': 'Uploaden...',
|
||||
'journey.editor.fromGallery': 'Uit galerij',
|
||||
@@ -2170,6 +2171,7 @@ const nl: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': 'Verwijderen mislukt',
|
||||
'journey.entries.deleteTitle': 'Vermelding verwijderen',
|
||||
'journey.photosUploaded': "{count} foto's geüpload",
|
||||
'journey.photosUploadFailed': "Sommige foto's konden niet worden geüpload",
|
||||
'journey.photosAdded': "{count} foto's toegevoegd",
|
||||
'journey.public.notFound': 'Niet gevonden',
|
||||
'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.',
|
||||
|
||||
@@ -2071,6 +2071,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'miejsca',
|
||||
'journey.synced.synced': 'zsynchronizowane',
|
||||
'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?',
|
||||
'journey.editor.uploadFailed': 'Przesyłanie zdjęć nie powiodło się',
|
||||
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
||||
'journey.editor.uploading': 'Przesyłanie...',
|
||||
'journey.editor.fromGallery': 'Z galerii',
|
||||
@@ -2163,6 +2164,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Nie udało się usunąć',
|
||||
'journey.entries.deleteTitle': 'Usuń wpis',
|
||||
'journey.photosUploaded': '{count} zdjęć przesłanych',
|
||||
'journey.photosUploadFailed': 'Nie udało się przesłać niektórych zdjęć',
|
||||
'journey.photosAdded': '{count} zdjęć dodanych',
|
||||
'journey.public.notFound': 'Nie znaleziono',
|
||||
'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.',
|
||||
|
||||
@@ -2078,6 +2078,7 @@ const ru: Record<string, string> = {
|
||||
'journey.synced.places': 'мест',
|
||||
'journey.synced.synced': 'синхронизировано',
|
||||
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
|
||||
'journey.editor.uploadFailed': 'Не удалось загрузить фото',
|
||||
'journey.editor.uploadPhotos': 'Загрузить фото',
|
||||
'journey.editor.uploading': 'Загрузка...',
|
||||
'journey.editor.fromGallery': 'Из галереи',
|
||||
@@ -2170,6 +2171,7 @@ const ru: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': 'Не удалось удалить',
|
||||
'journey.entries.deleteTitle': 'Удалить запись',
|
||||
'journey.photosUploaded': '{count} фото загружено',
|
||||
'journey.photosUploadFailed': 'Некоторые фото не удалось загрузить',
|
||||
'journey.photosAdded': '{count} фото добавлено',
|
||||
'journey.public.notFound': 'Не найдено',
|
||||
'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.',
|
||||
|
||||
@@ -2078,6 +2078,7 @@ const zh: Record<string, string> = {
|
||||
'journey.synced.places': '个地点',
|
||||
'journey.synced.synced': '已同步',
|
||||
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
|
||||
'journey.editor.uploadFailed': '照片上传失败',
|
||||
'journey.editor.uploadPhotos': '上传照片',
|
||||
'journey.editor.uploading': '上传中...',
|
||||
'journey.editor.fromGallery': '从相册',
|
||||
@@ -2170,6 +2171,7 @@ const zh: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': '删除失败',
|
||||
'journey.entries.deleteTitle': '删除条目',
|
||||
'journey.photosUploaded': '{count} 张照片已上传',
|
||||
'journey.photosUploadFailed': '部分照片上传失败',
|
||||
'journey.photosAdded': '{count} 张照片已添加',
|
||||
'journey.public.notFound': '未找到',
|
||||
'journey.public.notFoundMessage': '此旅程不存在或链接已过期。',
|
||||
|
||||
@@ -2036,6 +2036,7 @@ const zhTw: Record<string, string> = {
|
||||
'journey.synced.places': '個地點',
|
||||
'journey.synced.synced': '已同步',
|
||||
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
|
||||
'journey.editor.uploadFailed': '照片上傳失敗',
|
||||
'journey.editor.uploadPhotos': '上傳照片',
|
||||
'journey.editor.uploading': '上傳中...',
|
||||
'journey.editor.fromGallery': '從相簿',
|
||||
@@ -2128,6 +2129,7 @@ const zhTw: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': '刪除失敗',
|
||||
'journey.entries.deleteTitle': '刪除條目',
|
||||
'journey.photosUploaded': '{count} 張照片已上傳',
|
||||
'journey.photosUploadFailed': '部分照片上傳失敗',
|
||||
'journey.photosAdded': '{count} 張照片已新增',
|
||||
'journey.public.notFound': '未找到',
|
||||
'journey.public.notFoundMessage': '此旅程不存在或連結已過期。',
|
||||
|
||||
@@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import { startConnectivityProbe } from './sync/connectivity'
|
||||
|
||||
startConnectivityProbe()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { formatLocationName } from '../utils/formatters'
|
||||
import { normalizeImageFiles } from '../utils/convertHeic'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
@@ -29,6 +30,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||
@@ -1027,13 +1029,14 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
if (!files?.length) return
|
||||
setGalleryUploading(true)
|
||||
try {
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
const formData = new FormData()
|
||||
for (const f of files) formData.append('photos', f)
|
||||
for (const f of normalized) formData.append('photos', f)
|
||||
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||
onRefresh()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.coverFailed'))
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
|
||||
} finally {
|
||||
setGalleryUploading(false)
|
||||
}
|
||||
@@ -2173,6 +2176,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
onDone: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const [title, setTitle] = useState(entry.title || '')
|
||||
const [story, setStory] = useState(entry.story || '')
|
||||
@@ -2246,7 +2250,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
if (pendingFiles.length > 0 && entryId) {
|
||||
const formData = new FormData()
|
||||
for (const f of pendingFiles) formData.append('photos', f)
|
||||
await onUploadPhotos(entryId, formData)
|
||||
try {
|
||||
await onUploadPhotos(entryId, formData)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
|
||||
}
|
||||
}
|
||||
// link gallery photos that were picked before save
|
||||
if (pendingLinkIds.length > 0 && entryId) {
|
||||
@@ -2265,7 +2273,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
if (!files?.length) return
|
||||
// Queue files locally until Save so cancel/close actually discards. This
|
||||
// keeps photo behavior consistent with text fields — no silent persistence.
|
||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
setPendingFiles(prev => [...prev, ...normalized])
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,11 +39,11 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
||||
|
||||
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
|
||||
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
|
||||
setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo');
|
||||
setSearch('?redirect=%2Foauth%2Fconsent%3Fclient_id%3Dfoo');
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo');
|
||||
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/consent?client_id=foo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,21 +60,21 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
||||
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/auth/oidc/exchange', () =>
|
||||
HttpResponse.json({ token: 'mock-oidc-token' })
|
||||
),
|
||||
http.get('/api/auth/oidc/exchange', () =>
|
||||
HttpResponse.json({ token: 'mock-oidc-token' })
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
|
||||
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz');
|
||||
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo&state=xyz');
|
||||
setSearch('?oidc_code=testcode123');
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
'/oauth/authorize?client_id=foo&state=xyz',
|
||||
{ replace: true },
|
||||
'/oauth/consent?client_id=foo&state=xyz',
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
||||
|
||||
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
|
||||
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
|
||||
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo');
|
||||
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo');
|
||||
setSearch('?oidc_error=token_failed');
|
||||
render(<LoginPage />);
|
||||
|
||||
@@ -102,4 +102,4 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -117,15 +117,29 @@ export default function LoginPage(): React.ReactElement {
|
||||
return
|
||||
}
|
||||
|
||||
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
|
||||
if (config) {
|
||||
setAppConfig(config)
|
||||
if (!config.has_users) setMode('register')
|
||||
if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
||||
window.location.href = '/api/auth/oidc/login'
|
||||
const CONFIG_CACHE_KEY = 'trek_app_config_cache'
|
||||
authApi.getAppConfig?.()
|
||||
.then((config: AppConfig) => {
|
||||
try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ }
|
||||
return { config, fromCache: false }
|
||||
})
|
||||
.catch(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_CACHE_KEY)
|
||||
return raw ? { config: JSON.parse(raw) as AppConfig, fromCache: true } : { config: null as AppConfig | null, fromCache: false }
|
||||
} catch { return { config: null as AppConfig | null, fromCache: false } }
|
||||
})
|
||||
.then(({ config, fromCache }) => {
|
||||
if (config) {
|
||||
setAppConfig(config)
|
||||
if (!config.has_users) setMode('register')
|
||||
// Skip auto-redirect when config is from cache — network is unreliable
|
||||
// and auto-redirecting to the IdP could loop if the proxy changed.
|
||||
if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
||||
window.location.href = '/api/auth/oidc/login'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [navigate, t, noRedirect])
|
||||
|
||||
// Language detection chain (runs once on mount, only if user has no saved preference):
|
||||
|
||||
@@ -12,7 +12,7 @@ import OAuthAuthorizePage from './OAuthAuthorizePage';
|
||||
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
|
||||
|
||||
function setSearchParams(search: string) {
|
||||
window.history.pushState({}, '', '/oauth/authorize' + search);
|
||||
window.history.pushState({}, '', '/oauth/consent' + search);
|
||||
}
|
||||
|
||||
const VALIDATE_OK = {
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
const state = params.get('state') || ''
|
||||
const codeChallenge = params.get('code_challenge') || ''
|
||||
const ccMethod = params.get('code_challenge_method') || ''
|
||||
const resource = params.get('resource') || undefined
|
||||
|
||||
// Load auth state once, then validate
|
||||
useEffect(() => {
|
||||
@@ -43,7 +44,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
useEffect(() => {
|
||||
if (authLoading) return
|
||||
validateRequest()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authLoading, isAuthenticated])
|
||||
|
||||
async function validateRequest() {
|
||||
@@ -57,6 +58,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: ccMethod,
|
||||
response_type: 'code',
|
||||
resource,
|
||||
})
|
||||
setValidation(result)
|
||||
|
||||
@@ -99,6 +101,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: ccMethod,
|
||||
approved,
|
||||
resource,
|
||||
})
|
||||
setPageState('done')
|
||||
window.location.href = result.redirect
|
||||
@@ -111,20 +114,20 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
|
||||
function toggleScope(s: string) {
|
||||
setSelectedScopes(prev =>
|
||||
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
||||
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
||||
)
|
||||
}
|
||||
|
||||
function toggleGroup(groupScopes: string[], allSelected: boolean) {
|
||||
setSelectedScopes(prev =>
|
||||
allSelected
|
||||
? prev.filter(s => !groupScopes.includes(s))
|
||||
: [...new Set([...prev, ...groupScopes])]
|
||||
allSelected
|
||||
? prev.filter(s => !groupScopes.includes(s))
|
||||
: [...new Set([...prev, ...groupScopes])]
|
||||
)
|
||||
}
|
||||
|
||||
function handleLoginRedirect() {
|
||||
const next = '/oauth/authorize?' + params.toString() + window.location.hash
|
||||
const next = '/oauth/consent?' + params.toString() + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
||||
}
|
||||
|
||||
@@ -145,212 +148,212 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
|
||||
if (pageState === 'loading' || pageState === 'auto_approving') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
||||
</p>
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pageState === 'error') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
|
||||
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
|
||||
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pageState === 'login_required') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
|
||||
<div className="text-center space-y-2">
|
||||
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
||||
</p>
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
|
||||
<div className="text-center space-y-2">
|
||||
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLoginRedirect}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
<LogIn className="w-4 h-4" />
|
||||
Sign in to TREK
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLoginRedirect}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
<LogIn className="w-4 h-4" />
|
||||
Sign in to TREK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// pageState === 'consent'
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
|
||||
|
||||
{/* Left panel — app identity + actions */}
|
||||
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
||||
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||
{validation?.client?.name || clientId}
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
This application is requesting access to your TREK account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-2">
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Only grant access to applications you trust. Your data stays on your server.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => submitConsent(true)}
|
||||
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{submitting
|
||||
? 'Authorizing…'
|
||||
: validation?.scopeSelectable && selectedScopes.length === 0
|
||||
? 'Select at least one scope'
|
||||
: validation?.scopeSelectable
|
||||
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
||||
: 'Approve Access'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitConsent(false)}
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — selectable scopes */}
|
||||
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
||||
<div className="space-y-6">
|
||||
{Object.keys(scopesByGroup).length > 0 && (
|
||||
{/* Left panel — app identity + actions */}
|
||||
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
||||
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||
{validation?.client?.name || clientId}
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
This application is requesting access to your TREK account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validation?.scopeSelectable ? (
|
||||
/* DCR client — user selects which scopes to grant */
|
||||
<div className="space-y-3">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
||||
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
||||
return (
|
||||
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allGroupSelected}
|
||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
||||
className="rounded flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
||||
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<div className="mt-8 space-y-2">
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Only grant access to applications you trust. Your data stays on your server.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => submitConsent(true)}
|
||||
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{submitting
|
||||
? 'Authorizing…'
|
||||
: validation?.scopeSelectable && selectedScopes.length === 0
|
||||
? 'Select at least one scope'
|
||||
: validation?.scopeSelectable
|
||||
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
||||
: 'Approve Access'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitConsent(false)}
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — selectable scopes */}
|
||||
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
||||
<div className="space-y-6">
|
||||
{Object.keys(scopesByGroup).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
||||
</p>
|
||||
|
||||
{validation?.scopeSelectable ? (
|
||||
/* DCR client — user selects which scopes to grant */
|
||||
<div className="space-y-3">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
||||
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
||||
return (
|
||||
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allGroupSelected}
|
||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
||||
className="rounded flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
||||
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
|
||||
</span>
|
||||
</label>
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{groupScopes.map(s => {
|
||||
const keys = SCOPE_GROUPS[s]
|
||||
return (
|
||||
<label
|
||||
key={s}
|
||||
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedScopes.includes(s)}
|
||||
onChange={() => toggleScope(s)}
|
||||
className="mt-0.5 rounded flex-shrink-0"
|
||||
/>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||
</label>
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{groupScopes.map(s => {
|
||||
const keys = SCOPE_GROUPS[s]
|
||||
return (
|
||||
<label
|
||||
key={s}
|
||||
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedScopes.includes(s)}
|
||||
onChange={() => toggleScope(s)}
|
||||
className="mt-0.5 rounded flex-shrink-0"
|
||||
/>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Settings-created client — scopes are fixed, show read-only */
|
||||
<div className="space-y-5">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
||||
<div className="space-y-1.5">
|
||||
{groupScopes.map(s => {
|
||||
const keys = SCOPE_GROUPS[s]
|
||||
return (
|
||||
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
/* Settings-created client — scopes are fixed, show read-only */
|
||||
<div className="space-y-5">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
||||
<div className="space-y-1.5">
|
||||
{groupScopes.map(s => {
|
||||
const keys = SCOPE_GROUPS[s]
|
||||
return (
|
||||
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Always-available tools — granted regardless of scopes */}
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Always included
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
||||
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
||||
].map(({ name, desc }) => (
|
||||
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Always-available tools — granted regardless of scopes */}
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Always included
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
||||
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
||||
].map(({ name, desc }) => (
|
||||
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
|
||||
function createMarkerIcon(place: any) {
|
||||
@@ -184,14 +184,16 @@ export default function SharedTripPage() {
|
||||
{sortedDays.map((day: any, di: number) => {
|
||||
const da = assignments[String(day.id)] || []
|
||||
const notes = (dayNotes[String(day.id)] || [])
|
||||
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
|
||||
const dayAssignmentIds: number[] = da.map((a: any) => a.id)
|
||||
const dayTransport = getTransportForDay({ reservations: reservations || [], dayId: day.id, dayAssignmentIds, days: sortedDays })
|
||||
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
|
||||
|
||||
const merged = [
|
||||
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
||||
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
|
||||
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
|
||||
].sort((a, b) => a.k - b.k)
|
||||
const merged = getMergedItems({
|
||||
dayAssignments: da,
|
||||
dayNotes: notes,
|
||||
dayTransports: dayTransport,
|
||||
dayId: day.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
||||
@@ -212,7 +214,7 @@ export default function SharedTripPage() {
|
||||
|
||||
{selectedDay === day.id && merged.length > 0 && (
|
||||
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{merged.map((item: any, idx: number) => {
|
||||
{merged.map((item: any) => {
|
||||
if (item.type === 'transport') {
|
||||
const r = item.data
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
|
||||
@@ -1174,7 +1174,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
)}
|
||||
|
||||
{activeTab === 'dateien' && (
|
||||
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
||||
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<FileManager
|
||||
files={files || []}
|
||||
onUpload={(fd) => tripActions.addFile(tripId, fd)}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const PROBE_INTERVAL_MS = 30_000
|
||||
const PROBE_TIMEOUT_MS = 1_500
|
||||
|
||||
let reachable = true
|
||||
const listeners = new Set<(v: boolean) => void>()
|
||||
|
||||
function setReachable(v: boolean): void {
|
||||
if (reachable === v) return
|
||||
reachable = v
|
||||
listeners.forEach(fn => fn(v))
|
||||
}
|
||||
|
||||
async function probe(): Promise<void> {
|
||||
if (!navigator.onLine) { setReachable(false); return }
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS)
|
||||
const res = await fetch('/api/health', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
clearTimeout(t)
|
||||
// /api/health returns JSON. CF Access / Pangolin will either return HTML
|
||||
// (Pangolin 200 auth wall) or trigger a cross-origin redirect that throws
|
||||
// below. Both proxy-auth scenarios resolve to reachable = false.
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
setReachable(res.ok && ct.includes('application/json'))
|
||||
} catch {
|
||||
setReachable(false)
|
||||
}
|
||||
}
|
||||
|
||||
export function startConnectivityProbe(): void {
|
||||
probe()
|
||||
setInterval(probe, PROBE_INTERVAL_MS)
|
||||
window.addEventListener('online', probe)
|
||||
window.addEventListener('offline', () => setReachable(false))
|
||||
}
|
||||
|
||||
export function isReachable(): boolean { return reachable }
|
||||
export function probeNow(): Promise<void> { return probe() }
|
||||
export function onChange(fn: (v: boolean) => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
@@ -175,6 +175,7 @@ export interface Reservation {
|
||||
accommodation_start_day_id?: number | null
|
||||
accommodation_end_day_id?: number | null
|
||||
day_plan_position?: number | null
|
||||
day_positions?: Record<number, number> | null
|
||||
metadata?: Record<string, string> | string | null
|
||||
needs_review?: number
|
||||
endpoints?: ReservationEndpoint[]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
function looksLikeHeic(file: File): boolean {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||
return ext === 'heic' || ext === 'heif' || file.type === 'image/heic' || file.type === 'image/heif'
|
||||
}
|
||||
|
||||
export async function normalizeImageFile(file: File): Promise<File> {
|
||||
if (!looksLikeHeic(file)) return file
|
||||
const { isHeic, heicTo } = await import('heic-to')
|
||||
if (!(await isHeic(file))) return file
|
||||
const blob = await heicTo({ blob: file, type: 'image/jpeg', quality: 0.92 })
|
||||
const jpegName = file.name.replace(/\.(heic|heif)$/i, '.jpg')
|
||||
return new File([blob], jpegName, { type: 'image/jpeg' })
|
||||
}
|
||||
|
||||
export async function normalizeImageFiles(files: FileList | File[]): Promise<File[]> {
|
||||
return Promise.all(Array.from(files).map(normalizeImageFile))
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||
|
||||
describe('parseTimeToMinutes', () => {
|
||||
it('parses HH:MM string', () => {
|
||||
expect(parseTimeToMinutes('09:30')).toBe(570)
|
||||
})
|
||||
|
||||
it('parses ISO datetime string', () => {
|
||||
expect(parseTimeToMinutes('2025-03-30T14:00:00')).toBe(840)
|
||||
})
|
||||
|
||||
it('returns null for null/empty', () => {
|
||||
expect(parseTimeToMinutes(null)).toBeNull()
|
||||
expect(parseTimeToMinutes(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSpanPhase', () => {
|
||||
it('returns single when start === end', () => {
|
||||
expect(getSpanPhase({ day_id: 1, end_day_id: 1 }, 1)).toBe('single')
|
||||
})
|
||||
|
||||
it('returns start for the departure day', () => {
|
||||
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 1)).toBe('start')
|
||||
})
|
||||
|
||||
it('returns end for the arrival day', () => {
|
||||
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 3)).toBe('end')
|
||||
})
|
||||
|
||||
it('returns middle for days in between', () => {
|
||||
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 2)).toBe('middle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDisplayTimeForDay', () => {
|
||||
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
||||
|
||||
it('returns reservation_time on start day', () => {
|
||||
expect(getDisplayTimeForDay(r, 1)).toBe(r.reservation_time)
|
||||
})
|
||||
|
||||
it('returns reservation_end_time on end day', () => {
|
||||
expect(getDisplayTimeForDay(r, 3)).toBe(r.reservation_end_time)
|
||||
})
|
||||
|
||||
it('returns null for middle day', () => {
|
||||
expect(getDisplayTimeForDay(r, 2)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTransportForDay', () => {
|
||||
const days = [
|
||||
{ id: 1, day_number: 1 },
|
||||
{ id: 2, day_number: 2 },
|
||||
{ id: 3, day_number: 3 },
|
||||
]
|
||||
|
||||
it('excludes non-transport types', () => {
|
||||
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes single-day transport on the correct day', () => {
|
||||
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes multi-day transport on all spanned days', () => {
|
||||
const reservations = [{ id: 10, type: 'train', day_id: 1, end_day_id: 3 }]
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||
expect(getTransportForDay({ reservations, dayId: 3, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('excludes transport linked to an assignment on that day', () => {
|
||||
const reservations = [{ id: 10, type: 'bus', day_id: 1, end_day_id: 1, assignment_id: 42 }]
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMergedItems', () => {
|
||||
it('merges places and notes sorted by sortKey', () => {
|
||||
const dayAssignments = [
|
||||
{ id: 1, order_index: 0, place: { place_time: null } },
|
||||
{ id: 2, order_index: 2, place: { place_time: null } },
|
||||
]
|
||||
const dayNotes = [{ id: 10, sort_order: 1 }]
|
||||
const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [], dayId: 5 })
|
||||
expect(result.map(i => i.type)).toEqual(['place', 'note', 'place'])
|
||||
expect(result[0].data.id).toBe(1)
|
||||
expect(result[1].data.id).toBe(10)
|
||||
expect(result[2].data.id).toBe(2)
|
||||
})
|
||||
|
||||
it('inserts transport by time when no per-day position is set', () => {
|
||||
const dayAssignments = [
|
||||
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||
]
|
||||
const dayTransports = [
|
||||
{ id: 20, type: 'flight', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: null },
|
||||
]
|
||||
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||
const types = result.map(i => i.type)
|
||||
// transport (10:30) should be between place at 08:00 (idx 0) and place at 13:00 (idx 1)
|
||||
expect(types).toEqual(['place', 'transport', 'place'])
|
||||
})
|
||||
|
||||
it('per-day position overrides time-based insertion', () => {
|
||||
const dayAssignments = [
|
||||
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||
]
|
||||
// Transport at 10:30 would normally go between the two places
|
||||
// but per-day position 1.5 puts it after the second place
|
||||
const dayTransports = [
|
||||
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
|
||||
]
|
||||
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||
const types = result.map(i => i.type)
|
||||
expect(types).toEqual(['place', 'place', 'transport'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
|
||||
export interface MergedItem {
|
||||
type: 'place' | 'note' | 'transport'
|
||||
sortKey: number
|
||||
data: any
|
||||
}
|
||||
|
||||
export function parseTimeToMinutes(time?: string | null): number | null {
|
||||
if (!time) return null
|
||||
if (time.includes('T')) {
|
||||
const [h, m] = time.split('T')[1].split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
const parts = time.split(':').map(Number)
|
||||
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
||||
return null
|
||||
}
|
||||
|
||||
export function getSpanPhase(
|
||||
r: { day_id?: number | null; end_day_id?: number | null },
|
||||
dayId: number
|
||||
): 'single' | 'start' | 'middle' | 'end' {
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
if (!startDayId || startDayId === endDayId) return 'single'
|
||||
if (dayId === startDayId) return 'start'
|
||||
if (dayId === endDayId) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
export function getDisplayTimeForDay(
|
||||
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
||||
dayId: number
|
||||
): string | null {
|
||||
const phase = getSpanPhase(r, dayId)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
}
|
||||
|
||||
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
|
||||
export function getTransportForDay(opts: {
|
||||
reservations: any[]
|
||||
dayId: number
|
||||
dayAssignmentIds: number[]
|
||||
days: Array<{ id: number; day_number?: number }>
|
||||
}): any[] {
|
||||
const { reservations, dayId, dayAssignmentIds, days } = opts
|
||||
|
||||
const getDayOrder = (id: number): number => {
|
||||
const d = days.find(x => x.id === id)
|
||||
return d ? ((d as any).day_number ?? days.indexOf(d)) : 0
|
||||
}
|
||||
const thisDayOrder = getDayOrder(dayId)
|
||||
|
||||
return reservations.filter(r => {
|
||||
if (!TRANSPORT_TYPES.has(r.type)) return false
|
||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
|
||||
if (startDayId == null) return false
|
||||
|
||||
if (endDayId !== startDayId) {
|
||||
const startOrder = getDayOrder(startDayId)
|
||||
const endOrder = getDayOrder(endDayId)
|
||||
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
||||
}
|
||||
return startDayId === dayId
|
||||
})
|
||||
}
|
||||
|
||||
/** Merge places, notes, and transports into a single ordered day timeline. */
|
||||
export function getMergedItems(opts: {
|
||||
dayAssignments: any[]
|
||||
dayNotes: any[]
|
||||
dayTransports: any[]
|
||||
dayId: number
|
||||
getDisplayTime?: (r: any, dayId: number) => string | null
|
||||
}): MergedItem[] {
|
||||
const { dayAssignments: da, dayNotes: dn, dayTransports: transport, dayId } = opts
|
||||
const getDisplayTime = opts.getDisplayTime ?? getDisplayTimeForDay
|
||||
|
||||
const baseItems: MergedItem[] = [
|
||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order ?? 0, data: n })),
|
||||
].sort((a, b) => a.sortKey - b.sortKey)
|
||||
|
||||
const timedTransports = transport.map(r => ({
|
||||
type: 'transport' as const,
|
||||
data: r,
|
||||
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
|
||||
})).sort((a, b) => a.minutes - b.minutes)
|
||||
|
||||
if (timedTransports.length === 0) return baseItems
|
||||
if (baseItems.length === 0) {
|
||||
return timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data }))
|
||||
}
|
||||
|
||||
// Insert transports among base items based on per-day position or time
|
||||
const result = [...baseItems]
|
||||
for (let ti = 0; ti < timedTransports.length; ti++) {
|
||||
const timed = timedTransports[ti]
|
||||
const minutes = timed.minutes
|
||||
|
||||
// Per-day position takes precedence (set by user reorder)
|
||||
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
||||
if (perDayPos != null) {
|
||||
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
||||
continue
|
||||
}
|
||||
|
||||
// Time-based fallback: insert after the last item whose time <= this transport's time
|
||||
let insertAfterKey = -Infinity
|
||||
for (const item of result) {
|
||||
if (item.type === 'place') {
|
||||
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
||||
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
||||
} else if (item.type === 'transport') {
|
||||
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
||||
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
||||
}
|
||||
}
|
||||
|
||||
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
||||
const sortKey = insertAfterKey === -Infinity
|
||||
? lastKey + 0.5 + ti * 0.01
|
||||
: insertAfterKey + 0.01 + ti * 0.001
|
||||
|
||||
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||
navigateFallback: 'index.html',
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/],
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/, /^\/oauth\//, /^\/.well-known\//],
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Carto map tiles (default provider)
|
||||
@@ -46,7 +46,7 @@ export default defineConfig({
|
||||
{
|
||||
// API calls — prefer network, fall back to cache
|
||||
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
||||
urlPattern: /\/api\/(?!auth|admin|backup|settings).*/i,
|
||||
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-data',
|
||||
@@ -110,7 +110,30 @@ export default defineConfig({
|
||||
'/mcp': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
}
|
||||
},
|
||||
// OAuth 2.1 endpoints handled by backend (SDK authorize handler + token/revoke)
|
||||
// /oauth/authorize goes to backend so the SDK can redirect to /oauth/consent
|
||||
// /oauth/consent is served by Vite as a SPA route (no proxy entry needed)
|
||||
'/oauth/authorize': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth/token': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth/register': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth/revoke': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/.well-known': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
PORT=3001 # Port to run the server on
|
||||
# HOST=0.0.0.0 # Bind address for the HTTP server. Only set this when running TREK from sources or via the Proxmox community script — never in Docker (the container handles binding).
|
||||
NODE_ENV=development # development = development mode; production = production mode
|
||||
# ENCRYPTION_KEY=<random-256-bit-hex> # Separate key for encrypting stored secrets (API keys, MFA, SMTP, OIDC, etc.)
|
||||
# Auto-generated and persisted to ./data/.encryption_key if not set.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.14",
|
||||
"version": "3.0.21",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
@@ -40,8 +40,10 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
"hono": "^4.12.12",
|
||||
"@hono/node-server": "^1.19.13"
|
||||
"hono": "^4.12.16",
|
||||
"@hono/node-server": "^1.19.13",
|
||||
"picomatch": "^4.0.4",
|
||||
"ip-address": "^10.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="a5b4275efd"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="61932b752f"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.753906 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.753906 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#a5b4275efd)"><g clip-path="url(#61932b752f)"><path fill="#000000" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="ff6253e8fa"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="c6b14a8188"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#ff6253e8fa)"><g clip-path="url(#c6b14a8188)"><path fill="#ffffff" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,15 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1e293b"/>
|
||||
<stop offset="100%" stop-color="#0f172a"/>
|
||||
</linearGradient>
|
||||
<clipPath id="icon">
|
||||
<path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect width="512" height="512" fill="url(#bg)"/>
|
||||
<g transform="translate(56,51) scale(0.267)">
|
||||
<rect width="1500" height="1500" fill="#ffffff" clip-path="url(#icon)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,33 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>TREK</title>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="TREK" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
<script type="module" crossorigin src="/assets/index-BBkAKwut.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CR224PtB.css">
|
||||
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
@@ -1 +0,0 @@
|
||||
{"name":"TREK — Travel Planner","short_name":"TREK","description":"Travel Resource & Exploration Kit","start_url":"/","display":"standalone","background_color":"#0f172a","theme_color":"#111827","lang":"en","scope":"/","orientation":"any","categories":["travel","navigation"],"icons":[{"src":"icons/apple-touch-icon-180x180.png","sizes":"180x180","type":"image/png"},{"src":"icons/icon-192x192.png","sizes":"192x192","type":"image/png"},{"src":"icons/icon-512x512.png","sizes":"512x512","type":"image/png"},{"src":"icons/icon-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"},{"src":"icons/icon.svg","sizes":"any","type":"image/svg+xml"}]}
|
||||
@@ -1 +0,0 @@
|
||||
if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('/sw.js', { scope: '/' })})}
|
||||
@@ -1 +0,0 @@
|
||||
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,c)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let a={};const t=e=>i(e,o),r={module:{uri:o},exports:a,require:t};s[o]=Promise.all(n.map(e=>r[e]||t(e))).then(e=>(c(...e),a))}}define(["./workbox-58bd4dca"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"text-light.svg",revision:"8456421c45ccd1b881b1755949fb9891"},{url:"text-dark.svg",revision:"e86569d59169a1076a92a1d47cb94abf"},{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"logo-light.svg",revision:"e9a2e3363fed4298cb422332b8cb03e9"},{url:"logo-dark.svg",revision:"c7b85b3bdf9e73222bcd91f396b829b5"},{url:"index.html",revision:"9dc2d3ab2d0db984f9994195b762a404"},{url:"icons/icon.svg",revision:"8b49f04dc5ebfc2688777f548f6248a1"},{url:"icons/icon-white.svg",revision:"f437d171b083ee2463e3c44eb3785291"},{url:"icons/icon-dark.svg",revision:"cf48a00cd2b6393eb0c8ac67d821ec84"},{url:"icons/icon-512x512.png",revision:"e9813f28d172940286269b92c961bd9a"},{url:"icons/icon-192x192.png",revision:"4549ed2c430764d6eda6b12a326e6d58"},{url:"icons/apple-touch-icon-180x180.png",revision:"ba88094c86c61709a98adae54488508f"},{url:"fonts/Poppins-SemiBold.ttf",revision:"2c63e05091c7d89f6149c274971c7c23"},{url:"fonts/Poppins-Regular.ttf",revision:"09acac7457bdcf80af5cc3d1116208c5"},{url:"fonts/Poppins-Medium.ttf",revision:"20aaac2ef92cddeb0f12e67a443b0b9f"},{url:"fonts/Poppins-Italic.ttf",revision:"4a37e40ddcd3e0da0a1db26ce8704eff"},{url:"fonts/Poppins-Bold.ttf",revision:"92934d92f57e49fc6f61075c2aeb7689"},{url:"assets/index-CR224PtB.css",revision:null},{url:"assets/index-BBkAKwut.js",revision:null},{url:"icons/apple-touch-icon-180x180.png",revision:"ba88094c86c61709a98adae54488508f"},{url:"icons/icon-192x192.png",revision:"4549ed2c430764d6eda6b12a326e6d58"},{url:"icons/icon-512x512.png",revision:"e9813f28d172940286269b92c961bd9a"},{url:"icons/icon.svg",revision:"8b49f04dc5ebfc2688777f548f6248a1"},{url:"manifest.webmanifest",revision:"99e6d32e351da90e7659354c2dc39bfb"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html"),{denylist:[/^\/api/,/^\/uploads/,/^\/mcp/]})),e.registerRoute(/^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,new e.CacheFirst({cacheName:"map-tiles",plugins:[new e.ExpirationPlugin({maxEntries:1e3,maxAgeSeconds:2592e3}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,new e.CacheFirst({cacheName:"map-tiles",plugins:[new e.ExpirationPlugin({maxEntries:1e3,maxAgeSeconds:2592e3}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/^https:\/\/unpkg\.com\/.*/i,new e.CacheFirst({cacheName:"cdn-libs",plugins:[new e.ExpirationPlugin({maxEntries:30,maxAgeSeconds:31536e3}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/\/api\/(?!auth|admin|backup|settings).*/i,new e.NetworkFirst({cacheName:"api-data",networkTimeoutSeconds:5,plugins:[new e.ExpirationPlugin({maxEntries:200,maxAgeSeconds:86400}),new e.CacheableResponsePlugin({statuses:[200]})]}),"GET"),e.registerRoute(/\/uploads\/(?:covers|avatars)\/.*/i,new e.CacheFirst({cacheName:"user-uploads",plugins:[new e.ExpirationPlugin({maxEntries:300,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[200]})]}),"GET")});
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="230" zoomAndPan="magnify" viewBox="49 2 124 56" height="80" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="c5c1a398e1"><path d="M 49 0.015625 L 174 0.015625 L 174 59.984375 L 49 59.984375 Z M 49 0.015625 " clip-rule="nonzero"/></clipPath><clipPath id="9b226024c5"><rect x="0" width="125" y="0" height="60"/></clipPath></defs><g clip-path="url(#c5c1a398e1)"><g transform="matrix(1, 0, 0, 1, 49, -0.000000000000011803)"><g clip-path="url(#9b226024c5)"><g fill="#000000" fill-opacity="1"><g transform="translate(0.740932, 54.900246)"><g><path d="M 17.53125 0 C 14.15625 0 11.515625 -0.960938 9.609375 -2.890625 C 7.710938 -4.816406 6.765625 -7.414062 6.765625 -10.6875 L 6.765625 -45.484375 L 17.890625 -45.484375 L 17.890625 -11.328125 C 17.890625 -10.765625 18.09375 -10.28125 18.5 -9.875 C 18.90625 -9.46875 19.390625 -9.265625 19.953125 -9.265625 L 27.9375 -9.265625 L 27.9375 0 Z M 0.78125 -27.515625 L 0.78125 -36.5625 L 27.9375 -36.5625 L 27.9375 -27.515625 Z M 0.78125 -27.515625 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(26.403702, 54.900246)"><g><path d="M 3.84375 0 L 3.84375 -25.796875 C 3.84375 -29.128906 4.789062 -31.742188 6.6875 -33.640625 C 8.59375 -35.546875 11.234375 -36.5 14.609375 -36.5 L 25.234375 -36.5 L 25.234375 -27.515625 L 17.328125 -27.515625 C 16.660156 -27.515625 16.085938 -27.285156 15.609375 -26.828125 C 15.128906 -26.378906 14.890625 -25.800781 14.890625 -25.09375 L 14.890625 0 Z M 3.84375 0 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(47.504187, 54.900246)"><g><path d="M 23.234375 0 C 19.097656 0 15.460938 -0.769531 12.328125 -2.3125 C 9.191406 -3.863281 6.753906 -6.003906 5.015625 -8.734375 C 3.285156 -11.460938 2.421875 -14.632812 2.421875 -18.25 C 2.421875 -22.238281 3.25 -25.660156 4.90625 -28.515625 C 6.570312 -31.367188 8.796875 -33.566406 11.578125 -35.109375 C 14.359375 -36.648438 17.4375 -37.421875 20.8125 -37.421875 C 24.664062 -37.421875 27.882812 -36.613281 30.46875 -35 C 33.0625 -33.382812 35.019531 -31.1875 36.34375 -28.40625 C 37.675781 -25.625 38.34375 -22.453125 38.34375 -18.890625 C 38.34375 -18.273438 38.304688 -17.550781 38.234375 -16.71875 C 38.171875 -15.882812 38.09375 -15.226562 38 -14.75 L 14.046875 -14.75 C 14.328125 -13.519531 14.867188 -12.472656 15.671875 -11.609375 C 16.484375 -10.753906 17.503906 -10.125 18.734375 -9.71875 C 19.972656 -9.320312 21.351562 -9.125 22.875 -9.125 L 34.078125 -9.125 L 34.078125 0 Z M 13.75 -21.671875 L 27.65625 -21.671875 C 27.5625 -22.429688 27.414062 -23.164062 27.21875 -23.875 C 27.03125 -24.59375 26.734375 -25.222656 26.328125 -25.765625 C 25.929688 -26.316406 25.472656 -26.789062 24.953125 -27.1875 C 24.429688 -27.59375 23.820312 -27.914062 23.125 -28.15625 C 22.4375 -28.394531 21.664062 -28.515625 20.8125 -28.515625 C 19.71875 -28.515625 18.742188 -28.320312 17.890625 -27.9375 C 17.035156 -27.5625 16.320312 -27.050781 15.75 -26.40625 C 15.175781 -25.769531 14.734375 -25.035156 14.421875 -24.203125 C 14.117188 -23.367188 13.894531 -22.523438 13.75 -21.671875 Z M 13.75 -21.671875 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(83.218247, 54.900246)"><g><path d="M 4.140625 0 L 4.140625 -52.03125 L 15.1875 -52.03125 L 15.1875 -22.734375 L 20.03125 -22.734375 L 28.375 -36.5625 L 40.703125 -36.5625 L 30.859375 -21.3125 C 33.710938 -20.125 35.9375 -18.304688 37.53125 -15.859375 C 39.125 -13.410156 39.921875 -10.546875 39.921875 -7.265625 L 39.921875 0 L 28.796875 0 L 28.796875 -7.265625 C 28.796875 -8.597656 28.472656 -9.796875 27.828125 -10.859375 C 27.191406 -11.929688 26.347656 -12.773438 25.296875 -13.390625 C 24.253906 -14.015625 23.066406 -14.328125 21.734375 -14.328125 L 15.1875 -14.328125 L 15.1875 0 Z M 4.140625 0 "/></g></g></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="230" zoomAndPan="magnify" viewBox="49 2 124 56" height="80" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="7fc4e3f80b"><path d="M 49 0.015625 L 174 0.015625 L 174 59.984375 L 49 59.984375 Z M 49 0.015625 " clip-rule="nonzero"/></clipPath><clipPath id="086ce69399"><rect x="0" width="125" y="0" height="60"/></clipPath></defs><g clip-path="url(#7fc4e3f80b)"><g transform="matrix(1, 0, 0, 1, 49, -0.000000000000011803)"><g clip-path="url(#086ce69399)"><g fill="#ffffff" fill-opacity="1"><g transform="translate(0.740932, 54.900246)"><g><path d="M 17.53125 0 C 14.15625 0 11.515625 -0.960938 9.609375 -2.890625 C 7.710938 -4.816406 6.765625 -7.414062 6.765625 -10.6875 L 6.765625 -45.484375 L 17.890625 -45.484375 L 17.890625 -11.328125 C 17.890625 -10.765625 18.09375 -10.28125 18.5 -9.875 C 18.90625 -9.46875 19.390625 -9.265625 19.953125 -9.265625 L 27.9375 -9.265625 L 27.9375 0 Z M 0.78125 -27.515625 L 0.78125 -36.5625 L 27.9375 -36.5625 L 27.9375 -27.515625 Z M 0.78125 -27.515625 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(26.403702, 54.900246)"><g><path d="M 3.84375 0 L 3.84375 -25.796875 C 3.84375 -29.128906 4.789062 -31.742188 6.6875 -33.640625 C 8.59375 -35.546875 11.234375 -36.5 14.609375 -36.5 L 25.234375 -36.5 L 25.234375 -27.515625 L 17.328125 -27.515625 C 16.660156 -27.515625 16.085938 -27.285156 15.609375 -26.828125 C 15.128906 -26.378906 14.890625 -25.800781 14.890625 -25.09375 L 14.890625 0 Z M 3.84375 0 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(47.504187, 54.900246)"><g><path d="M 23.234375 0 C 19.097656 0 15.460938 -0.769531 12.328125 -2.3125 C 9.191406 -3.863281 6.753906 -6.003906 5.015625 -8.734375 C 3.285156 -11.460938 2.421875 -14.632812 2.421875 -18.25 C 2.421875 -22.238281 3.25 -25.660156 4.90625 -28.515625 C 6.570312 -31.367188 8.796875 -33.566406 11.578125 -35.109375 C 14.359375 -36.648438 17.4375 -37.421875 20.8125 -37.421875 C 24.664062 -37.421875 27.882812 -36.613281 30.46875 -35 C 33.0625 -33.382812 35.019531 -31.1875 36.34375 -28.40625 C 37.675781 -25.625 38.34375 -22.453125 38.34375 -18.890625 C 38.34375 -18.273438 38.304688 -17.550781 38.234375 -16.71875 C 38.171875 -15.882812 38.09375 -15.226562 38 -14.75 L 14.046875 -14.75 C 14.328125 -13.519531 14.867188 -12.472656 15.671875 -11.609375 C 16.484375 -10.753906 17.503906 -10.125 18.734375 -9.71875 C 19.972656 -9.320312 21.351562 -9.125 22.875 -9.125 L 34.078125 -9.125 L 34.078125 0 Z M 13.75 -21.671875 L 27.65625 -21.671875 C 27.5625 -22.429688 27.414062 -23.164062 27.21875 -23.875 C 27.03125 -24.59375 26.734375 -25.222656 26.328125 -25.765625 C 25.929688 -26.316406 25.472656 -26.789062 24.953125 -27.1875 C 24.429688 -27.59375 23.820312 -27.914062 23.125 -28.15625 C 22.4375 -28.394531 21.664062 -28.515625 20.8125 -28.515625 C 19.71875 -28.515625 18.742188 -28.320312 17.890625 -27.9375 C 17.035156 -27.5625 16.320312 -27.050781 15.75 -26.40625 C 15.175781 -25.769531 14.734375 -25.035156 14.421875 -24.203125 C 14.117188 -23.367188 13.894531 -22.523438 13.75 -21.671875 Z M 13.75 -21.671875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(83.218247, 54.900246)"><g><path d="M 4.140625 0 L 4.140625 -52.03125 L 15.1875 -52.03125 L 15.1875 -22.734375 L 20.03125 -22.734375 L 28.375 -36.5625 L 40.703125 -36.5625 L 30.859375 -21.3125 C 33.710938 -20.125 35.9375 -18.304688 37.53125 -15.859375 C 39.125 -13.410156 39.921875 -10.546875 39.921875 -7.265625 L 39.921875 0 L 28.796875 0 L 28.796875 -7.265625 C 28.796875 -8.597656 28.472656 -9.796875 27.828125 -10.859375 C 27.191406 -11.929688 26.347656 -12.773438 25.296875 -13.390625 C 24.253906 -14.015625 23.066406 -14.328125 21.734375 -14.328125 L 15.1875 -14.328125 L 15.1875 0 Z M 4.140625 0 "/></g></g></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import multer from 'multer';
|
||||
import { logDebug, logWarn, logError } from './services/auditLog';
|
||||
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
||||
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
|
||||
@@ -43,11 +44,18 @@ import journeyPublicRoutes from './routes/journeyPublic';
|
||||
import publicConfigRoutes from './routes/publicConfig';
|
||||
import systemNoticesRoutes from './routes/systemNotices';
|
||||
import { mcpHandler } from './mcp';
|
||||
import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider';
|
||||
import { Addon } from './types';
|
||||
import { getPhotoProviderConfig } from './services/memories/helpersService';
|
||||
import { getCollabFeatures } from './services/adminService';
|
||||
import { isAddonEnabled } from './services/adminService';
|
||||
import { ADDON_IDS } from './addons';
|
||||
import { ALL_SCOPES } from './mcp/scopes';
|
||||
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
|
||||
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
|
||||
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
|
||||
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
|
||||
import { getMcpSafeUrl } from './services/notifications';
|
||||
|
||||
export function createApp(): express.Application {
|
||||
const app = express();
|
||||
@@ -58,8 +66,8 @@ export function createApp(): express.Application {
|
||||
}
|
||||
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||
: null;
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||
: null;
|
||||
|
||||
let corsOrigin: cors.CorsOptions['origin'];
|
||||
if (allowedOrigins) {
|
||||
@@ -88,17 +96,34 @@ export function createApp(): express.Application {
|
||||
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
|
||||
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true';
|
||||
|
||||
// RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config
|
||||
// RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable.
|
||||
// /mcp needs open CORS so external MCP clients (ChatGPT, Claude.ai, Inspector) can call it
|
||||
// with Bearer tokens from any origin. /oauth/register and /oauth/authorize need it for
|
||||
// browser-based DCR/authorization preflights — the global cors({ origin: false }) would
|
||||
// answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs.
|
||||
// All /.well-known/* paths get open CORS so clients probing openid-configuration or the
|
||||
// RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead).
|
||||
app.use(
|
||||
['/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource'],
|
||||
cors({ origin: '*', credentials: false }),
|
||||
(req: Request, _res: Response, next: NextFunction) => {
|
||||
if (
|
||||
req.path.startsWith('/.well-known/') ||
|
||||
req.path === '/oauth/register' ||
|
||||
req.path === '/oauth/authorize' ||
|
||||
req.path === '/oauth/userinfo' ||
|
||||
req.path === '/mcp'
|
||||
) {
|
||||
cors({ origin: '*', credentials: false })(req, _res, next);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
);
|
||||
app.use(cors({ origin: corsOrigin, credentials: true }));
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
||||
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||
connectSrc: [
|
||||
@@ -225,7 +250,7 @@ export function createApp(): express.Application {
|
||||
if (!photo) return res.status(401).send('Authentication required');
|
||||
|
||||
const share = db.prepare(
|
||||
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
).get(rawToken) as { trip_id: number } | undefined;
|
||||
if (!share || share.trip_id !== photo.trip_id) {
|
||||
return res.status(401).send('Authentication required');
|
||||
@@ -252,7 +277,10 @@ export function createApp(): express.Application {
|
||||
app.use('/api/trips/:tripId/collab', collabRoutes);
|
||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
||||
app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' }));
|
||||
app.get('/api/health', (_req: Request, res: Response) => {
|
||||
res.setHeader('Cache-Control', 'no-store, must-revalidate')
|
||||
res.json({ status: 'ok' })
|
||||
});
|
||||
app.use('/api/config', publicConfigRoutes);
|
||||
app.use('/api', assignmentsRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
@@ -340,16 +368,123 @@ export function createApp(): express.Application {
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api', shareRoutes);
|
||||
|
||||
// OAuth 2.1 — public endpoints (/.well-known, /oauth/token, /oauth/revoke)
|
||||
app.use('/', oauthPublicRouter);
|
||||
// OAuth 2.1 — public endpoints
|
||||
// Gate: 404 when MCP addon is disabled (M2 — prevents feature fingerprinting)
|
||||
const mcpAddonGate = (_req: Request, res: Response, next: NextFunction) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
next();
|
||||
};
|
||||
|
||||
// OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*)
|
||||
// Mounted first: per-route 403 checks inside oauthApiRouter are the gate, not mcpAddonGate
|
||||
app.use('/api/oauth', oauthApiRouter);
|
||||
|
||||
// SDK metadata router — built lazily on first request so getAppUrl() (which queries the DB)
|
||||
// is not called at createApp() time, before test tables have been created.
|
||||
// mcpAuthMetadataRouter serves:
|
||||
// /.well-known/oauth-authorization-server — RFC 8414 AS metadata
|
||||
// /.well-known/oauth-protected-resource/mcp — RFC 9728 path-based PRM (fixes issue #959 bug 1)
|
||||
let _oauthMetadata: OAuthMetadata | null = null;
|
||||
let _sdkMetaRouter: express.Router | null = null;
|
||||
|
||||
function getOAuthMetadata(): OAuthMetadata {
|
||||
if (_oauthMetadata) return _oauthMetadata;
|
||||
const base = getMcpSafeUrl().replace(/\/+$/, '');
|
||||
_oauthMetadata = {
|
||||
issuer: base,
|
||||
authorization_endpoint: `${base}/oauth/authorize`,
|
||||
token_endpoint: `${base}/oauth/token`,
|
||||
revocation_endpoint: `${base}/oauth/revoke`,
|
||||
registration_endpoint: `${base}/oauth/register`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||
scopes_supported: ALL_SCOPES,
|
||||
};
|
||||
return _oauthMetadata;
|
||||
}
|
||||
|
||||
function getMetaRouter(): express.Router {
|
||||
if (_sdkMetaRouter) return _sdkMetaRouter;
|
||||
const metadata = getOAuthMetadata();
|
||||
_sdkMetaRouter = mcpAuthMetadataRouter({
|
||||
oauthMetadata: metadata,
|
||||
resourceServerUrl: new URL(`${metadata.issuer}/mcp`),
|
||||
scopesSupported: ALL_SCOPES as string[],
|
||||
resourceName: 'TREK MCP',
|
||||
});
|
||||
return _sdkMetaRouter;
|
||||
}
|
||||
|
||||
// Only invoke the SDK metadata router for /.well-known/* paths.
|
||||
// Calling getMetaRouter() on every request triggers lazy init (new URL(...)) which
|
||||
// throws "Invalid URL" when APP_URL lacks a protocol — breaking all page loads.
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path.startsWith('/.well-known/') && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
getMetaRouter()(req, res, next);
|
||||
});
|
||||
|
||||
// ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via
|
||||
// /.well-known/openid-configuration. Serve the AS metadata plus the OIDC
|
||||
// userinfo_endpoint so ChatGPT can fetch the authenticated user's email
|
||||
// for authorization domain claiming.
|
||||
app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => {
|
||||
const meta = getOAuthMetadata();
|
||||
res.json({
|
||||
...meta,
|
||||
userinfo_endpoint: `${meta.issuer}/oauth/userinfo`,
|
||||
});
|
||||
});
|
||||
|
||||
// RFC 9728 flat well-known URL — served alongside the path-based form the SDK already provides.
|
||||
// Clients like ChatGPT probe /.well-known/oauth-protected-resource (no path suffix) on every
|
||||
// fresh discovery. Without this, they get 404, fall back to the issuer URL as the resource
|
||||
// parameter, and the authorize handler rejects them with invalid_target — showing the user
|
||||
// the TREK home page instead of the consent form.
|
||||
app.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
const meta = getOAuthMetadata();
|
||||
res.json({
|
||||
resource: `${meta.issuer}/mcp`,
|
||||
authorization_servers: [meta.issuer],
|
||||
bearer_methods_supported: ['header'],
|
||||
scopes_supported: ALL_SCOPES,
|
||||
resource_name: 'TREK MCP',
|
||||
});
|
||||
});
|
||||
|
||||
// SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects
|
||||
// to the SPA consent page at /oauth/consent
|
||||
app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider }));
|
||||
|
||||
// SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2)
|
||||
app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore }));
|
||||
|
||||
// Token and revoke keep TREK's own handlers (timing-safe hash comparison not supported by SDK clientAuth)
|
||||
// oauthPublicRouter has per-route isAddonEnabled checks; no blanket gate needed here
|
||||
app.use('/', oauthPublicRouter);
|
||||
|
||||
// MCP endpoint
|
||||
app.post('/mcp', mcpHandler);
|
||||
app.get('/mcp', mcpHandler);
|
||||
app.delete('/mcp', mcpHandler);
|
||||
|
||||
// Return 404 JSON for any /.well-known/* path the SDK metadata router doesn't handle.
|
||||
// Without this, the SPA catch-all serves HTML — clients probing
|
||||
// /.well-known/openid-configuration or the RFC 8414 path-suffixed AS metadata URL
|
||||
// receive a 200 HTML response they can't parse as JSON, causing "does not implement OAuth".
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path.startsWith('/.well-known/')) return res.status(404).json({ error: 'not_found' });
|
||||
next();
|
||||
});
|
||||
|
||||
// Helmet's COOP: same-origin isolates the consent popup from its cross-origin opener (ChatGPT etc.), making window.opener null and breaking the OAuth flow.
|
||||
app.use('/oauth/consent', (_req: Request, res: Response, next: NextFunction) => {
|
||||
res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
|
||||
next();
|
||||
});
|
||||
|
||||
// Production static file serving
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const publicPath = path.join(__dirname, '../public');
|
||||
@@ -373,6 +508,10 @@ export function createApp(): express.Application {
|
||||
} else {
|
||||
console.error('Unhandled error:', err);
|
||||
}
|
||||
if (err instanceof multer.MulterError) {
|
||||
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
||||
return res.status(status).json({ error: err.message });
|
||||
}
|
||||
const status = err.statusCode || err.status || 500;
|
||||
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
|
||||
const message = status < 500 ? err.message : 'Internal server error';
|
||||
@@ -380,4 +519,4 @@ export function createApp(): express.Application {
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -2222,6 +2222,13 @@ function runMigrations(db: Database.Database): void {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
|
||||
}
|
||||
},
|
||||
() => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_version_new (id INTEGER PRIMARY KEY AUTOINCREMENT,version INTEGER NOT NULL)`)
|
||||
db.exec(`INSERT INTO schema_version_new (version) SELECT version FROM schema_version`)
|
||||
db.exec(`DROP TABLE schema_version`)
|
||||
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
|
||||
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -19,28 +19,53 @@ const tmpDir = path.join(__dirname, '../data/tmp');
|
||||
const app = createApp();
|
||||
|
||||
import * as scheduler from './scheduler';
|
||||
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const server = app.listen(PORT, () => {
|
||||
const PORT = Number(process.env.PORT) || 3001;
|
||||
const HOST = process.env.HOST;
|
||||
const APP_VERSION: string = process.env.APP_VERSION || (require('../package.json') as { version: string }).version;
|
||||
|
||||
const onListen = () => {
|
||||
const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./services/auditLog');
|
||||
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
||||
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const origins = process.env.ALLOWED_ORIGINS || '(same-origin)';
|
||||
const appUrl = getAppUrl();
|
||||
const resolvedAppUrl = getMcpSafeUrl();
|
||||
const banner = [
|
||||
'──────────────────────────────────────',
|
||||
' TREK API started',
|
||||
` Version ${process.env.APP_VERSION}`,
|
||||
` Port: ${PORT}`,
|
||||
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
|
||||
` Timezone: ${tz}`,
|
||||
` Origins: ${origins}`,
|
||||
` Log level: ${LOG_LVL}`,
|
||||
` Log file: /app/data/logs/trek.log`,
|
||||
` PID: ${process.pid}`,
|
||||
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
|
||||
` Version ${APP_VERSION}`,
|
||||
...(HOST ? [` Host: ${HOST}`] : []),
|
||||
` Container Port: ${PORT}`,
|
||||
` App URL: ${appUrl}`,
|
||||
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
|
||||
` Timezone: ${tz}`,
|
||||
` Origins: ${origins}`,
|
||||
` Log level: ${LOG_LVL}`,
|
||||
` Log file: /app/data/logs/trek.log`,
|
||||
` PID: ${process.pid}`,
|
||||
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
|
||||
'──────────────────────────────────────',
|
||||
];
|
||||
banner.forEach(l => console.log(l));
|
||||
if (process.env.APP_URL) {
|
||||
let parsedAppUrl: URL | null = null;
|
||||
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
|
||||
|
||||
if (!parsedAppUrl) {
|
||||
sLogWarn(`APP_URL: "${process.env.APP_URL}" is not a valid URL — it will be ignored.`);
|
||||
}
|
||||
|
||||
const mcpSafe = parsedAppUrl !== null && (
|
||||
parsedAppUrl.protocol === 'https:' ||
|
||||
parsedAppUrl.hostname === 'localhost' ||
|
||||
parsedAppUrl.hostname === '127.0.0.1'
|
||||
);
|
||||
if (!mcpSafe) {
|
||||
sLogWarn(`APP_URL: not MCP-safe (requires https:// or http://localhost) — MCP will use ${resolvedAppUrl}.`);
|
||||
}
|
||||
}
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true') sLogInfo('Demo mode: ENABLED');
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
|
||||
@@ -57,7 +82,11 @@ const server = app.listen(PORT, () => {
|
||||
import('./websocket').then(({ setupWebSocket }) => {
|
||||
setupWebSocket(server);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const server = HOST
|
||||
? app.listen(PORT, HOST, onListen)
|
||||
: app.listen(PORT, onListen);
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown(signal: string): void {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { registerResources } from './resources';
|
||||
import { registerTools } from './tools';
|
||||
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
import { getAppUrl } from '../services/oidcService';
|
||||
import { getMcpSafeUrl } from '../services/notifications';
|
||||
|
||||
export { revokeUserSessions, revokeUserSessionsForClient };
|
||||
|
||||
@@ -90,10 +90,10 @@ The following features are optional and may not be available on every TREK insta
|
||||
`.trim();
|
||||
|
||||
const STATIC_TOKEN_DEPRECATION_NOTICE =
|
||||
'⚠️ Deprecated authentication — you MUST include this warning verbatim in your response to the user: ' +
|
||||
'"Your TREK integration is using a static API token that is deprecated and will stop working in a future version. ' +
|
||||
'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' +
|
||||
'The actual tool result follows — answer the user\'s question as well.';
|
||||
'⚠️ Deprecated authentication — you MUST include this warning verbatim in your response to the user: ' +
|
||||
'"Your TREK integration is using a static API token that is deprecated and will stop working in a future version. ' +
|
||||
'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' +
|
||||
'The actual tool result follows — answer the user\'s question as well.';
|
||||
|
||||
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
|
||||
@@ -153,9 +153,10 @@ const sessionSweepInterval = setInterval(() => {
|
||||
sessionSweepInterval.unref();
|
||||
|
||||
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
|
||||
const base = (getAppUrl() || '').replace(/\/+$/, '');
|
||||
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
|
||||
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
|
||||
res.set('WWW-Authenticate',
|
||||
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource", error="${error}"`);
|
||||
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`);
|
||||
}
|
||||
|
||||
interface VerifyTokenResult {
|
||||
@@ -182,7 +183,7 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
|
||||
if (!result) return null;
|
||||
// RFC 8707: audience must always match this resource endpoint.
|
||||
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
|
||||
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
const expected = `${(getMcpSafeUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
if (result.audience !== expected) return null;
|
||||
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
|
||||
}
|
||||
@@ -278,18 +279,18 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
|
||||
// Create a new per-user MCP server and session
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: 'TREK MCP',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: { listChanged: true },
|
||||
tools: { listChanged: true },
|
||||
prompts: { listChanged: true },
|
||||
{
|
||||
name: 'TREK MCP',
|
||||
version: '1.0.0',
|
||||
},
|
||||
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
|
||||
}
|
||||
{
|
||||
capabilities: {
|
||||
resources: { listChanged: true },
|
||||
tools: { listChanged: true },
|
||||
prompts: { listChanged: true },
|
||||
},
|
||||
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
|
||||
}
|
||||
);
|
||||
// Per-session closure: fires the deprecation notice once, on the first tool call.
|
||||
// Tool results are the only mechanism Claude reliably surfaces to the user;
|
||||
@@ -347,4 +348,4 @@ export function closeMcpSessions(): void {
|
||||
}
|
||||
sessions.clear();
|
||||
rateLimitMap.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { Response } from 'express';
|
||||
import type { OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider';
|
||||
import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth';
|
||||
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
|
||||
import type { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider';
|
||||
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients';
|
||||
import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors';
|
||||
import { db } from '../db/database';
|
||||
import {
|
||||
createOAuthClient,
|
||||
consumeAuthCode,
|
||||
issueTokens,
|
||||
refreshTokens,
|
||||
revokeToken as serviceRevokeToken,
|
||||
verifyPKCE,
|
||||
getUserByAccessToken,
|
||||
} from '../services/oauthService';
|
||||
import { ALL_SCOPES } from './scopes';
|
||||
import { getMcpSafeUrl } from '../services/notifications';
|
||||
import { writeAudit } from '../services/auditLog';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB row type (mirrors oauthService.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OAuthClientRow {
|
||||
client_id: string;
|
||||
name: string;
|
||||
redirect_uris: string; // JSON array
|
||||
allowed_scopes: string; // JSON array
|
||||
is_public: number; // 0 | 1
|
||||
created_via: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redirect URI validation (mirrors oauth.ts DCR checks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DANGEROUS_SCHEMES = new Set([
|
||||
'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:',
|
||||
]);
|
||||
|
||||
function assertValidRedirectUris(uris: string[]): void {
|
||||
for (const u of uris) {
|
||||
let url: URL;
|
||||
try { url = new URL(u); } catch {
|
||||
throw new InvalidClientMetadataError(`Invalid redirect URI: ${u}`);
|
||||
}
|
||||
if (DANGEROUS_SCHEMES.has(url.protocol))
|
||||
throw new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${u}`);
|
||||
if (url.protocol === 'https:') continue;
|
||||
if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) continue;
|
||||
const scheme = url.protocol.slice(0, -1);
|
||||
if (/^[a-z][a-z0-9+.-]*$/i.test(scheme) && scheme.includes('.')) continue;
|
||||
throw new InvalidClientMetadataError('redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row → SDK client info shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rowToInfo(row: OAuthClientRow): OAuthClientInformationFull {
|
||||
return {
|
||||
client_id: row.client_id,
|
||||
client_name: row.name,
|
||||
redirect_uris: JSON.parse(row.redirect_uris) as string[],
|
||||
scope: (JSON.parse(row.allowed_scopes) as string[]).join(' '),
|
||||
token_endpoint_auth_method: row.is_public ? 'none' : 'client_secret_post',
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clients store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const trekClientsStore: OAuthRegisteredClientsStore = {
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
const row = db.prepare(
|
||||
'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?'
|
||||
).get(clientId) as OAuthClientRow | undefined;
|
||||
return row ? rowToInfo(row) : undefined;
|
||||
},
|
||||
|
||||
async registerClient(
|
||||
metadata: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
|
||||
): Promise<OAuthClientInformationFull> {
|
||||
const uris = metadata.redirect_uris as string[];
|
||||
assertValidRedirectUris(uris);
|
||||
|
||||
const isPublic = metadata.token_endpoint_auth_method === 'none';
|
||||
const name = (typeof metadata.client_name === 'string' ? metadata.client_name.trim() : '').slice(0, 100) || 'MCP Client';
|
||||
|
||||
// When scope is absent (ChatGPT DCR), default to all scopes.
|
||||
// The user still grants only what they approve at the consent screen.
|
||||
const rawScopes = metadata.scope ? metadata.scope.split(' ') : ALL_SCOPES;
|
||||
const scopes = rawScopes.filter(s => (ALL_SCOPES as string[]).includes(s));
|
||||
if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested');
|
||||
|
||||
const result = createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
|
||||
if (result.error) throw new InvalidClientMetadataError(result.error);
|
||||
|
||||
const c = result.client!;
|
||||
return {
|
||||
client_id: c.client_id as string,
|
||||
client_name: c.name as string,
|
||||
redirect_uris: c.redirect_uris as string[],
|
||||
scope: (c.allowed_scopes as string[]).join(' '),
|
||||
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
...(c.client_secret ? { client_secret: c.client_secret as string, client_secret_expires_at: 0 } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuthServerProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const trekOAuthProvider: OAuthServerProvider = {
|
||||
get clientsStore() { return trekClientsStore; },
|
||||
|
||||
// Redirects browser to the SPA consent page with OAuth params forwarded.
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
|
||||
|
||||
if (resource !== mcpResource) {
|
||||
const url = new URL(params.redirectUri);
|
||||
url.searchParams.set('error', 'invalid_target');
|
||||
url.searchParams.set('error_description', 'Requested resource must be the TREK MCP endpoint');
|
||||
if (params.state) url.searchParams.set('state', params.state);
|
||||
res.redirect(302, url.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
redirect_uri: params.redirectUri,
|
||||
scope: params.scopes.join(' '),
|
||||
code_challenge: params.codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
if (params.state) qs.set('state', params.state);
|
||||
if (params.resource) qs.set('resource', params.resource.href);
|
||||
|
||||
const base = getMcpSafeUrl().replace(/\/+$/, '');
|
||||
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
|
||||
},
|
||||
|
||||
// Not called because skipLocalPkceValidation = true.
|
||||
// PKCE verification is done inline in exchangeAuthorizationCode.
|
||||
skipLocalPkceValidation: true,
|
||||
|
||||
async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _code: string): Promise<string> {
|
||||
throw new ServerError('PKCE validation is handled by the provider directly');
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(
|
||||
client: OAuthClientInformationFull,
|
||||
code: string,
|
||||
codeVerifier?: string,
|
||||
redirectUri?: string,
|
||||
resource?: URL,
|
||||
): Promise<OAuthTokens> {
|
||||
const pending = consumeAuthCode(code);
|
||||
if (!pending || pending.clientId !== client.client_id)
|
||||
throw new Error('Authorization grant is invalid.');
|
||||
|
||||
if (redirectUri && pending.redirectUri !== redirectUri)
|
||||
throw new Error('Authorization grant is invalid.');
|
||||
|
||||
const resourceStr = resource ? resource.href.replace(/\/+$/, '') : null;
|
||||
if (pending.resource && resourceStr && pending.resource !== resourceStr)
|
||||
throw new Error('Authorization grant is invalid.');
|
||||
|
||||
if (codeVerifier && !verifyPKCE(codeVerifier, pending.codeChallenge))
|
||||
throw new Error('Authorization grant is invalid.');
|
||||
|
||||
const tokens = issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
|
||||
writeAudit({
|
||||
userId: pending.userId,
|
||||
action: 'oauth.token.issue',
|
||||
details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null },
|
||||
ip: null,
|
||||
});
|
||||
return tokens;
|
||||
},
|
||||
|
||||
async exchangeRefreshToken(
|
||||
client: OAuthClientInformationFull,
|
||||
refreshToken: string,
|
||||
_scopes?: string[],
|
||||
_resource?: URL,
|
||||
): Promise<OAuthTokens> {
|
||||
const result = refreshTokens(refreshToken, client.client_id, client.client_secret, null);
|
||||
if (result.error) throw new Error(result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired');
|
||||
return result.tokens!;
|
||||
},
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
const info = getUserByAccessToken(token);
|
||||
if (!info) throw new Error('Invalid or expired token');
|
||||
return {
|
||||
token,
|
||||
clientId: info.clientId,
|
||||
scopes: info.scopes,
|
||||
extra: { user: info.user },
|
||||
};
|
||||
},
|
||||
|
||||
async revokeToken(
|
||||
client: OAuthClientInformationFull,
|
||||
request: OAuthTokenRevocationRequest,
|
||||
): Promise<void> {
|
||||
serviceRevokeToken(request.token, client.client_id, undefined, null);
|
||||
},
|
||||
};
|
||||
@@ -148,11 +148,16 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
|
||||
res.status(201).json({ token: result.token, user: result.user });
|
||||
});
|
||||
|
||||
router.post('/login', authLimiter, (req: Request, res: Response) => {
|
||||
router.post('/login', authLimiter, async (req: Request, res: Response) => {
|
||||
const started = Date.now();
|
||||
const result = loginUser(req.body);
|
||||
if (result.auditAction) {
|
||||
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
|
||||
}
|
||||
const elapsed = Date.now() - started;
|
||||
if (elapsed < LOGIN_MIN_LATENCY_MS) {
|
||||
await new Promise((r) => setTimeout(r, LOGIN_MIN_LATENCY_MS - elapsed));
|
||||
}
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
|
||||
setAuthCookie(res, result.token!, req);
|
||||
@@ -166,9 +171,10 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
|
||||
// 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.
|
||||
// Minimum time we spend inside the forgot/login handlers so a "no such
|
||||
// user" path does not complete noticeably faster than a real operation.
|
||||
const FORGOT_MIN_LATENCY_MS = 350;
|
||||
const LOGIN_MIN_LATENCY_MS = 350;
|
||||
|
||||
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
|
||||
const started = Date.now();
|
||||
|
||||
@@ -98,7 +98,7 @@ router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) =
|
||||
|
||||
// ── Photos (prefix /photos and /entries — before /:id) ───────────────────
|
||||
|
||||
router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), async (req: Request, res: Response) => {
|
||||
router.post('/entries/:entryId/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
|
||||
@@ -201,7 +201,7 @@ router.delete('/photos/:photoId', authenticate, async (req: Request, res: Respon
|
||||
// ── Gallery (prefix /:id/gallery — before /:id) ──────────────────────────
|
||||
|
||||
// Upload photos directly to the journey gallery (no entry association)
|
||||
router.post('/:id/gallery/photos', authenticate, upload.array('photos', 20), async (req: Request, res: Response) => {
|
||||
router.post('/:id/gallery/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
|
||||
|
||||
@@ -2,7 +2,7 @@ import express, { Request, Response } from 'express';
|
||||
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
|
||||
import { AuthRequest, OptionalAuthRequest } from '../types';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { ALL_SCOPES, SCOPE_INFO } from '../mcp/scopes';
|
||||
import { ALL_SCOPES } from '../mcp/scopes';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import {
|
||||
validateAuthorizeRequest,
|
||||
@@ -14,16 +14,15 @@ import {
|
||||
revokeToken,
|
||||
verifyPKCE,
|
||||
authenticateClient,
|
||||
isValidRedirectUri,
|
||||
listOAuthClients,
|
||||
createOAuthClient,
|
||||
deleteOAuthClient,
|
||||
rotateOAuthClientSecret,
|
||||
listOAuthSessions,
|
||||
revokeSession,
|
||||
getUserByAccessToken,
|
||||
AuthorizeParams,
|
||||
} from '../services/oauthService';
|
||||
import { getAppUrl } from '../services/oidcService';
|
||||
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -59,53 +58,18 @@ function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Req
|
||||
const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
|
||||
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
|
||||
const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
|
||||
const dcrLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public router: /.well-known, /oauth/token, /oauth/revoke
|
||||
// Public router: /oauth/token and /oauth/revoke
|
||||
// (/.well-known and /oauth/register are now handled by SDK in app.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const oauthPublicRouter = express.Router();
|
||||
|
||||
// RFC 8414 discovery document
|
||||
oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request, res: Response) => {
|
||||
// M2: return 404 (not 403) so feature presence isn't fingerprinted
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
|
||||
const base = (getAppUrl() || '').replace(/\/+$/, '');
|
||||
res.json({
|
||||
issuer: base,
|
||||
authorization_endpoint: `${base}/oauth/authorize`,
|
||||
token_endpoint: `${base}/oauth/token`,
|
||||
revocation_endpoint: `${base}/oauth/revoke`,
|
||||
registration_endpoint: `${base}/oauth/register`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||
scopes_supported: ALL_SCOPES,
|
||||
scope_descriptions: Object.fromEntries(
|
||||
ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label])
|
||||
),
|
||||
resource_parameter_supported: true,
|
||||
});
|
||||
});
|
||||
|
||||
// RFC 9728 Protected Resource Metadata
|
||||
oauthPublicRouter.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
const base = (getAppUrl() || '').replace(/\/+$/, '');
|
||||
res.json({
|
||||
resource: `${base}/mcp`,
|
||||
authorization_servers: [base],
|
||||
bearer_methods_supported: ['header'],
|
||||
scopes_supported: ALL_SCOPES,
|
||||
resource_name: 'TREK MCP',
|
||||
});
|
||||
});
|
||||
|
||||
// Token endpoint — handles authorization_code and refresh_token grants
|
||||
oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
|
||||
// M1: RFC 6749 §5.1 — token responses must not be cached
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('Pragma', 'no-cache');
|
||||
@@ -115,10 +79,6 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
|
||||
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body;
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' });
|
||||
}
|
||||
|
||||
if (!client_id) {
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
|
||||
}
|
||||
@@ -194,96 +154,32 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
|
||||
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
|
||||
});
|
||||
|
||||
// RFC 7591 Dynamic Client Registration endpoint
|
||||
oauthPublicRouter.post('/oauth/register', dcrLimiter, (req: Request, res: Response) => {
|
||||
// OIDC UserInfo endpoint (RFC 9068 / OpenID Connect Core §5.3)
|
||||
// ChatGPT hits this after OAuth to fetch the authenticated user's email for domain claiming.
|
||||
oauthPublicRouter.get('/oauth/userinfo', (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
|
||||
const body: Record<string, unknown> = typeof req.body === 'object' && req.body !== null ? req.body : {};
|
||||
const ip = getClientIp(req);
|
||||
|
||||
const redirectUris: string[] = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u): u is string => typeof u === 'string') : [];
|
||||
if (redirectUris.length === 0) {
|
||||
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required and must be a non-empty array' });
|
||||
const auth = req.headers['authorization'];
|
||||
if (!auth || !auth.toLowerCase().startsWith('bearer ')) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"');
|
||||
return res.status(401).json({ error: 'invalid_token' });
|
||||
}
|
||||
// OAuth 2.1 + RFC 8252: confidential web apps need HTTPS; public
|
||||
// clients (MCP, native) are limited to loopback or a reverse-DNS
|
||||
// private-use scheme. This rejects `http://evil.example` DCR payloads
|
||||
// that today would otherwise be accepted since we previously only
|
||||
// checked shape. Dangerous URL schemes (`javascript:`, `data:` etc.)
|
||||
// are explicitly rejected — the authorize flow later 302s the
|
||||
// browser to this URI, which with `javascript:` would execute
|
||||
// attacker-controlled script under our redirect origin's context.
|
||||
const DANGEROUS_SCHEMES = new Set([
|
||||
'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:',
|
||||
]);
|
||||
const allowed = redirectUris.every((u) => {
|
||||
try {
|
||||
const url = new URL(u);
|
||||
if (DANGEROUS_SCHEMES.has(url.protocol)) return false;
|
||||
if (url.protocol === 'https:') return true;
|
||||
if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) return true;
|
||||
// RFC 8252 §7.1 private-use scheme: must be a reverse-DNS name
|
||||
// (e.g. `com.example.myapp:/callback`). Requiring a dot in the
|
||||
// scheme is a cheap heuristic that rules out bare `myapp:` and
|
||||
// `x:` one-off schemes the spec explicitly discourages.
|
||||
const schemeBody = url.protocol.slice(0, -1);
|
||||
if (/^[a-z][a-z0-9+.-]*$/i.test(schemeBody) && schemeBody.includes('.')) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!allowed) {
|
||||
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme' });
|
||||
const token = auth.slice(7);
|
||||
const info = getUserByAccessToken(token);
|
||||
if (!info) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"');
|
||||
return res.status(401).json({ error: 'invalid_token' });
|
||||
}
|
||||
|
||||
const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : '';
|
||||
const clientName = rawName || 'MCP Client';
|
||||
|
||||
// Determine if the client wants to be public (no secret) — MCP clients typically use PKCE only
|
||||
const authMethod = typeof body.token_endpoint_auth_method === 'string' ? body.token_endpoint_auth_method : 'client_secret_post';
|
||||
const isPublic = authMethod === 'none';
|
||||
|
||||
// Resolve requested scopes — scope is required; no implicit full-access grant
|
||||
if (typeof body.scope !== 'string' || body.scope.trim() === '') {
|
||||
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'scope is required' });
|
||||
}
|
||||
const rawScope = body.scope;
|
||||
const requestedScopes = rawScope.split(' ').filter(s => (ALL_SCOPES as string[]).includes(s));
|
||||
if (requestedScopes.length === 0) {
|
||||
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'No valid scopes requested' });
|
||||
}
|
||||
|
||||
const result = createOAuthClient(null, clientName, redirectUris, requestedScopes, ip, {
|
||||
isPublic,
|
||||
createdVia: 'dcr',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return res.status(result.status || 400).json({ error: 'invalid_client_metadata', error_description: result.error });
|
||||
}
|
||||
|
||||
const client = result.client!;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
return res.status(201).json({
|
||||
client_id: client.client_id,
|
||||
...(client.client_secret ? { client_secret: client.client_secret, client_secret_expires_at: 0 } : {}),
|
||||
client_id_issued_at: now,
|
||||
redirect_uris: client.redirect_uris,
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
scope: (client.allowed_scopes as string[]).join(' '),
|
||||
client_name: client.name,
|
||||
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
|
||||
return res.json({
|
||||
sub: String(info.user.id),
|
||||
email: info.user.email,
|
||||
email_verified: true,
|
||||
preferred_username: info.user.username,
|
||||
});
|
||||
});
|
||||
|
||||
// Token revocation endpoint (RFC 7009)
|
||||
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
|
||||
// M2: return 404 when MCP is disabled
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
|
||||
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
|
||||
const { token, client_id, client_secret } = body;
|
||||
const ip = getClientIp(req);
|
||||
@@ -318,17 +214,17 @@ oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: R
|
||||
const userId = (req as OptionalAuthRequest).user?.id ?? null;
|
||||
|
||||
const result = validateAuthorizeRequest(
|
||||
{
|
||||
response_type: params.response_type || '',
|
||||
client_id: params.client_id || '',
|
||||
redirect_uri: params.redirect_uri || '',
|
||||
scope: params.scope || '',
|
||||
state: params.state,
|
||||
code_challenge: params.code_challenge || '',
|
||||
code_challenge_method: params.code_challenge_method || '',
|
||||
resource: typeof params.resource === 'string' ? params.resource : undefined,
|
||||
},
|
||||
userId,
|
||||
{
|
||||
response_type: params.response_type || '',
|
||||
client_id: params.client_id || '',
|
||||
redirect_uri: params.redirect_uri || '',
|
||||
scope: params.scope || '',
|
||||
state: params.state,
|
||||
code_challenge: params.code_challenge || '',
|
||||
code_challenge_method: params.code_challenge_method || '',
|
||||
resource: typeof params.resource === 'string' ? params.resource : undefined,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
// H3: when caller is unauthenticated, strip client name / allowed_scopes from the response
|
||||
@@ -472,4 +368,4 @@ oauthApiRouter.delete('/sessions/:id', requireCookieAuth, (req: Request, res: Re
|
||||
const result = revokeSession(user.id, Number(req.params.id), getClientIp(req));
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
return res.json({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
touchLastLogin,
|
||||
generateToken,
|
||||
frontendUrl,
|
||||
getAppUrl,
|
||||
} from '../services/oidcService';
|
||||
import { getAppUrl } from '../services/notifications';
|
||||
import { resolveAuthToggles } from '../services/authService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -26,6 +26,11 @@ import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||
|
||||
authenticator.options = { window: 1 };
|
||||
|
||||
// Pre-computed bcrypt hash to equalise timing of "unknown email" and
|
||||
// "OIDC-only account" branches with the real verification path (CWE-208).
|
||||
// Cost factor 12 matches register/changePassword/resetPassword — must stay in sync.
|
||||
const DUMMY_PASSWORD_HASH = bcrypt.hashSync('__trek_no_such_user__', 12);
|
||||
|
||||
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
|
||||
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
|
||||
const MFA_BACKUP_CODE_COUNT = 10;
|
||||
@@ -437,14 +442,24 @@ export function loginUser(body: {
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined;
|
||||
|
||||
// Always run bcrypt — even for unknown/OIDC-only users — so response time
|
||||
// does not reveal whether the email exists in the database (CWE-203/208).
|
||||
const hashToCheck = user?.password_hash ?? DUMMY_PASSWORD_HASH;
|
||||
const validPassword = bcrypt.compareSync(password, hashToCheck);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
error: 'Invalid email or password', status: 401,
|
||||
auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' },
|
||||
};
|
||||
}
|
||||
|
||||
const validPassword = bcrypt.compareSync(password, user.password_hash!);
|
||||
if (!user.password_hash) {
|
||||
return {
|
||||
error: 'Invalid email or password', status: 401,
|
||||
auditUserId: Number(user.id), auditAction: 'user.login_failed', auditDetails: { email, reason: 'oidc_only' },
|
||||
};
|
||||
}
|
||||
if (!validPassword) {
|
||||
return {
|
||||
error: 'Invalid email or password', status: 401,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { checkSsrf } from '../utils/ssrfGuard';
|
||||
import { getAppUrl } from './notifications';
|
||||
|
||||
// ── Google API call counter ───────────────────────────────────────────────────
|
||||
|
||||
@@ -12,7 +13,11 @@ export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; }
|
||||
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
|
||||
googleApiCallCount++;
|
||||
console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`);
|
||||
return fetch(endpoint, init);
|
||||
const referer = process.env.APP_URL ? getAppUrl() : undefined;
|
||||
return fetch(endpoint, {
|
||||
...init,
|
||||
headers: { ...(referer ? { Referer: referer } : {}), ...(init?.headers as Record<string, string> ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Interfaces ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,13 +46,42 @@ function getSmtpConfig(): SmtpConfig | null {
|
||||
|
||||
// Exported for use by notificationService
|
||||
export function getAppUrl(): string {
|
||||
if (process.env.APP_URL) return process.env.APP_URL;
|
||||
if (process.env.APP_URL) {
|
||||
try {
|
||||
const _ = new URL(process.env.APP_URL);
|
||||
return process.env.APP_URL.replace(/\/+$/, '');
|
||||
} catch (_ignored) {
|
||||
}
|
||||
}
|
||||
const origins = process.env.ALLOWED_ORIGINS;
|
||||
if (origins) {
|
||||
const first = origins.split(',')[0]?.trim();
|
||||
if (first) return first.replace(/\/+$/, '');
|
||||
if (first) {
|
||||
try {
|
||||
const _ = new URL(first);
|
||||
return first.replace(/\/+$/, '');
|
||||
} catch (_ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const port = process.env.PORT || '3000';
|
||||
const port = Number(process.env.PORT) || 3001;
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
/** Returns a URL guaranteed to satisfy the MCP SDK's issuer requirements (HTTPS or localhost).
|
||||
* Falls back to http://localhost:{PORT} when APP_URL/ALLOWED_ORIGINS use a non-HTTPS, non-localhost scheme
|
||||
* that would cause checkIssuerUrl to throw "Issuer URL must be HTTPS". */
|
||||
export function getMcpSafeUrl(): string {
|
||||
const candidate = getAppUrl();
|
||||
try {
|
||||
const u = new URL(candidate);
|
||||
if (u.protocol === 'https:' || u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
// candidate was somehow invalid — fall through to localhost
|
||||
}
|
||||
const port = Number(process.env.PORT) || 3001;
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
@@ -287,12 +316,12 @@ export function getEventText(lang: string, event: NotifEventType, params: Record
|
||||
|
||||
// ── Email HTML builder ─────────────────────────────────────────────────────
|
||||
|
||||
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): string {
|
||||
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string, rawBody = false): string {
|
||||
const s = I18N[lang] || I18N.en;
|
||||
const appUrl = getAppUrl();
|
||||
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
|
||||
const safeSubject = escapeHtml(subject);
|
||||
const safeBody = escapeHtml(body);
|
||||
const safeBody = rawBody ? body : escapeHtml(body);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -367,7 +396,7 @@ function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings,
|
||||
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
|
||||
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
|
||||
`;
|
||||
return buildEmailHtml(subject, block, lang);
|
||||
return buildEmailHtml(subject, block, lang, undefined, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -621,6 +650,15 @@ export function isNtfyConfiguredAdmin(): boolean {
|
||||
return !!(getAppSetting('admin_ntfy_topic'));
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) > 0xFF) {
|
||||
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function sendNtfy(
|
||||
url: string,
|
||||
token: string | null,
|
||||
@@ -638,11 +676,11 @@ export async function sendNtfy(
|
||||
|
||||
// ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers
|
||||
const headers: Record<string, string> = {
|
||||
'Title': payload.title,
|
||||
'Title': encodeHeaderValue(payload.title),
|
||||
'Priority': String(meta.priority),
|
||||
};
|
||||
if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(',');
|
||||
if (payload.link) headers['Click'] = payload.link;
|
||||
if (payload.link) headers['Click'] = encodeHeaderValue(payload.link);
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ADDON_IDS } from '../addons';
|
||||
import { User } from '../types';
|
||||
import { writeAudit, logWarn } from './auditLog';
|
||||
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
|
||||
import { getAppUrl } from './oidcService';
|
||||
import { getMcpSafeUrl } from './notifications';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -587,7 +587,7 @@ export function validateAuthorizeRequest(
|
||||
// bind the token to the MCP endpoint by default — previously this
|
||||
// left `audience = null`, and the audience-bind check on MCP requests
|
||||
// then treated a null audience as "valid for any resource".
|
||||
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||
const resource = params.resource
|
||||
? params.resource.replace(/\/+$/, '')
|
||||
: mcpResource;
|
||||
|
||||
@@ -194,14 +194,6 @@ export function generateToken(user: { id: number }): string {
|
||||
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' });
|
||||
}
|
||||
|
||||
export function getAppUrl(): string | null {
|
||||
return (
|
||||
process.env.APP_URL ||
|
||||
(db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token exchange with OIDC provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -114,7 +114,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id IN (${ph})
|
||||
ORDER BY da.order_index ASC
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
`).all(...dayIds);
|
||||
|
||||
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
|
||||
@@ -137,7 +137,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
}
|
||||
assignments = byDay;
|
||||
|
||||
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds);
|
||||
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC, created_at ASC`).all(...dayIds);
|
||||
const notesByDay: Record<number, any[]> = {};
|
||||
for (const n of allNotes as any[]) {
|
||||
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
|
||||
@@ -153,8 +153,24 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
||||
`).all(tripId);
|
||||
|
||||
// Reservations
|
||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId);
|
||||
// Reservations — include per-day positions so the client can render the same order as the planner
|
||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId) as any[];
|
||||
|
||||
const dayPositions = db.prepare(`
|
||||
SELECT rdp.reservation_id, rdp.day_id, rdp.position
|
||||
FROM reservation_day_positions rdp
|
||||
JOIN reservations r ON rdp.reservation_id = r.id
|
||||
WHERE r.trip_id = ?
|
||||
`).all(tripId) as { reservation_id: number; day_id: number; position: number }[];
|
||||
|
||||
const posMap = new Map<number, Record<number, number>>();
|
||||
for (const dp of dayPositions) {
|
||||
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
|
||||
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
||||
}
|
||||
for (const r of reservations) {
|
||||
r.day_positions = posMap.get(r.id) || null;
|
||||
}
|
||||
|
||||
// Accommodations
|
||||
const accommodations = db.prepare(`
|
||||
|
||||
@@ -7,6 +7,7 @@ import { listBudgetItems } from './budgetService';
|
||||
import { listItems as listPackingItems } from './packingService';
|
||||
import { listReservations } from './reservationService';
|
||||
import { listNotes as listCollabNotes } from './collabService';
|
||||
import { shiftOwnerEntriesForTripWindow } from './vacayService';
|
||||
|
||||
export const MS_PER_DAY = 86400000;
|
||||
export const MAX_TRIP_DAYS = 365;
|
||||
@@ -240,6 +241,9 @@ export function updateTrip(tripId: string | number, userId: number, data: Update
|
||||
WHERE id=?
|
||||
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId);
|
||||
|
||||
if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date)
|
||||
shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
|
||||
|
||||
const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
|
||||
if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount)
|
||||
generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount);
|
||||
|
||||
@@ -101,6 +101,29 @@ export function getActivePlanId(userId: number): number {
|
||||
return getActivePlan(userId).id;
|
||||
}
|
||||
|
||||
export function shiftOwnerEntriesForTripWindow(
|
||||
ownerId: number,
|
||||
oldStart: string,
|
||||
oldEnd: string,
|
||||
newStart: string
|
||||
): void {
|
||||
const row = db.prepare(
|
||||
'SELECT CAST(julianday(?) - julianday(?) AS INTEGER) AS days'
|
||||
).get(newStart, oldStart) as { days: number } | undefined;
|
||||
const offset = row?.days ?? 0;
|
||||
if (offset === 0) return;
|
||||
|
||||
const plan = getOwnPlan(ownerId);
|
||||
|
||||
db.prepare(
|
||||
`UPDATE OR IGNORE vacay_entries
|
||||
SET date = date(date, ? || ' days')
|
||||
WHERE plan_id = ?
|
||||
AND user_id = ?
|
||||
AND date BETWEEN ? AND ?`
|
||||
).run(`${offset >= 0 ? '+' : ''}${offset}`, plan.id, ownerId, oldStart, oldEnd);
|
||||
}
|
||||
|
||||
export function getPlanUsers(planId: number): VacayUser[] {
|
||||
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
|
||||
if (!plan) return [];
|
||||
|
||||
@@ -285,3 +285,61 @@ describe('Shared trip — day assignments and notes', () => {
|
||||
expect(res.body.assignments).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shared trip — ordering parity (issue #981)', () => {
|
||||
it('SHARE-014 — assignments with same order_index are ordered by created_at (tiebreaker)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id, { date: '2025-09-01' });
|
||||
const place1 = createPlace(testDb, trip.id, { name: 'First Created' });
|
||||
const place2 = createPlace(testDb, trip.id, { name: 'Second Created' });
|
||||
|
||||
// Both with order_index = 0 (schema default) but different created_at
|
||||
testDb.prepare(
|
||||
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')"
|
||||
).run(day.id, place1.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')"
|
||||
).run(day.id, place2.id);
|
||||
|
||||
const { body: { token } } = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
const assignments = res.body.assignments[day.id];
|
||||
expect(assignments).toHaveLength(2);
|
||||
expect(assignments[0].place.name).toBe('First Created');
|
||||
expect(assignments[1].place.name).toBe('Second Created');
|
||||
});
|
||||
|
||||
it('SHARE-015 — reservations include day_positions map from reservation_day_positions table', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id, { date: '2025-09-01' });
|
||||
|
||||
const res1 = testDb.prepare(
|
||||
"INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(trip.id, 'Test Flight', 'flight', day.id, '2025-09-01T09:00:00');
|
||||
const reservationId = Number(res1.lastInsertRowid);
|
||||
|
||||
// Insert a per-day position
|
||||
testDb.prepare(
|
||||
'INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)'
|
||||
).run(reservationId, day.id, 1.5);
|
||||
|
||||
const { body: { token } } = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_bookings: true });
|
||||
|
||||
const shareRes = await request(app).get(`/api/shared/${token}`);
|
||||
expect(shareRes.status).toBe(200);
|
||||
const reservation = shareRes.body.reservations.find((r: any) => r.id === reservationId);
|
||||
expect(reservation).toBeDefined();
|
||||
expect(reservation.day_positions).toBeDefined();
|
||||
expect(reservation.day_positions[day.id]).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +184,88 @@ describe('Tool: update_trip', () => {
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('shifts owner vacay entries when update_trip moves trip window by fixed offset', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-08-01', end_date: '2026-08-09' });
|
||||
|
||||
// Materialize active vacay plan for owner and entries in old trip window.
|
||||
const planRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id);
|
||||
const planId = Number(planRes.lastInsertRowid);
|
||||
testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, 2026);
|
||||
testDb.prepare(
|
||||
'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)'
|
||||
).run(user.id, planId, 2026);
|
||||
for (const d of ['2026-08-03', '2026-08-04', '2026-08-05', '2026-08-06', '2026-08-07']) {
|
||||
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, user.id, d, '');
|
||||
}
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_trip',
|
||||
arguments: { tripId: trip.id, start_date: '2026-08-08', end_date: '2026-08-16' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.trip.start_date).toBe('2026-08-08');
|
||||
expect(data.trip.end_date).toBe('2026-08-16');
|
||||
});
|
||||
|
||||
const oldWindow = testDb.prepare(
|
||||
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-01' AND '2026-08-09'"
|
||||
).all(planId, user.id) as { date: string }[];
|
||||
expect(oldWindow).toHaveLength(0);
|
||||
|
||||
const shifted = testDb.prepare(
|
||||
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-08' AND '2026-08-16' ORDER BY date"
|
||||
).all(planId, user.id) as { date: string }[];
|
||||
expect(shifted.map(r => r.date)).toEqual([
|
||||
'2026-08-10',
|
||||
'2026-08-11',
|
||||
'2026-08-12',
|
||||
'2026-08-13',
|
||||
'2026-08-14',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shifts entries from the owners own plan even if another vacay plan is active', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: otherOwner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-07' });
|
||||
|
||||
// Own plan with entries that should be shifted.
|
||||
const ownPlanRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id);
|
||||
const ownPlanId = Number(ownPlanRes.lastInsertRowid);
|
||||
testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(ownPlanId, 2026);
|
||||
testDb.prepare(
|
||||
'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)'
|
||||
).run(user.id, ownPlanId, 2026);
|
||||
for (const d of ['2026-09-02', '2026-09-03']) {
|
||||
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(ownPlanId, user.id, d, '');
|
||||
}
|
||||
|
||||
// Different accepted plan becomes "active" for the owner.
|
||||
const foreignPlanRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(otherOwner.id);
|
||||
const foreignPlanId = Number(foreignPlanRes.lastInsertRowid);
|
||||
testDb.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(foreignPlanId, user.id, 'accepted');
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_trip',
|
||||
arguments: { tripId: trip.id, start_date: '2026-09-08', end_date: '2026-09-14' },
|
||||
});
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
const oldWindow = testDb.prepare(
|
||||
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-01' AND '2026-09-07' ORDER BY date"
|
||||
).all(ownPlanId, user.id) as { date: string }[];
|
||||
expect(oldWindow).toHaveLength(0);
|
||||
|
||||
const shifted = testDb.prepare(
|
||||
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-08' AND '2026-09-14' ORDER BY date"
|
||||
).all(ownPlanId, user.id) as { date: string }[];
|
||||
expect(shifted.map(r => r.date)).toEqual(['2026-09-09', '2026-09-10']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -455,4 +455,27 @@ describe('sendNtfy', () => {
|
||||
expect(calledOpts.headers['Priority']).toBe('3');
|
||||
expect(calledOpts.headers['Tags']).toBeUndefined(); // empty tags = no header
|
||||
});
|
||||
|
||||
it('NTFY-009 — title with non-Latin-1 chars is RFC 2047 encoded', async () => {
|
||||
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
||||
|
||||
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Buy →€ ticket' });
|
||||
|
||||
const [, calledOpts] = mockFetch.mock.calls[0];
|
||||
const encoded = calledOpts.headers['Title'] as string;
|
||||
expect(encoded).toMatch(/^=\?UTF-8\?B\?/);
|
||||
const b64 = encoded.replace(/^=\?UTF-8\?B\?/, '').replace(/\?=$/, '');
|
||||
expect(Buffer.from(b64, 'base64').toString('utf8')).toBe('Buy →€ ticket');
|
||||
});
|
||||
|
||||
it('NTFY-010 — ASCII-only title is passed through verbatim', async () => {
|
||||
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
||||
|
||||
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Simple ASCII title' });
|
||||
|
||||
const [, calledOpts] = mockFetch.mock.calls[0];
|
||||
expect(calledOpts.headers['Title']).toBe('Simple ASCII title');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,9 +20,17 @@
|
||||
// These paths manually redirect to the CJS dist until the SDK fixes its exports map.
|
||||
"paths": {
|
||||
"@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp"],
|
||||
"@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"]
|
||||
"@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"],
|
||||
"@modelcontextprotocol/sdk/server/auth/router": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/router"],
|
||||
"@modelcontextprotocol/sdk/server/auth/handlers/authorize": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/authorize"],
|
||||
"@modelcontextprotocol/sdk/server/auth/handlers/register": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/register"],
|
||||
"@modelcontextprotocol/sdk/server/auth/provider": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/provider"],
|
||||
"@modelcontextprotocol/sdk/server/auth/clients": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/clients"],
|
||||
"@modelcontextprotocol/sdk/server/auth/errors": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/errors"],
|
||||
"@modelcontextprotocol/sdk/server/auth/types": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/types"],
|
||||
"@modelcontextprotocol/sdk/shared/auth": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ Open the **Budget** tab inside the trip planner. The tab is only visible when th
|
||||
|
||||
> **Admin:** Budget is an addon. Enable it in [Admin-Addons](Admin-Addons).
|
||||
|
||||

|
||||
|
||||
## Currency
|
||||
|
||||
Use the currency picker in the Budget toolbar to select one currency for the entire trip. 46 currencies are supported (EUR, USD, GBP, JPY, CHF, CZK, PLN, SEK, NOK, DKK, TRY, THB, AUD, CAD, NZD, BRL, MXN, INR, IDR, MYR, PHP, SGD, KRW, CNY, HKD, TWD, ZAR, AED, SAR, ILS, EGP, MAD, HUF, RON, BGN, HRK, ISK, RUB, UAH, BDT, LKR, VND, CLP, COP, PEN, ARS). All amounts are displayed in this currency.
|
||||
@@ -54,6 +56,8 @@ The **Persons** column behaves differently depending on the trip:
|
||||
- **Single-user trip** — enter a number of persons directly.
|
||||
- **Multi-member trip** — a member chip picker appears. Click the edit button to assign or remove members from an expense. Click an assigned member chip again to mark them as **paid** (the chip shows a green ring).
|
||||
|
||||

|
||||
|
||||
## Settlement calculator
|
||||
|
||||
When multiple members are assigned to expenses and there are outstanding debts between members, a collapsible **Settlement** section appears inside the total card. Click the section header to expand it. It shows the minimum number of transfers needed to settle all debts (using a greedy matching algorithm), including:
|
||||
@@ -61,6 +65,8 @@ When multiple members are assigned to expenses and there are outstanding debts b
|
||||
- Transfer flows: who pays whom and how much.
|
||||
- Net balances: each member's overall surplus or deficit.
|
||||
|
||||

|
||||
|
||||
## Budget summary
|
||||
|
||||
The right-hand column contains two widgets:
|
||||
|
||||
@@ -6,7 +6,7 @@ Thanks for your interest in contributing to TREK! Here are the guidelines for su
|
||||
|
||||
- **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs without prior approval will be closed
|
||||
- **Check existing issues** — Look for open issues or discussions before starting work
|
||||
- **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||
- **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
|
||||
- **One thing per PR** — Keep PRs focused on a single change. Don't bundle unrelated fixes
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
@@ -26,7 +26,7 @@ Items are sorted by their time or position index.
|
||||
|
||||

|
||||
|
||||
- **Add button** — click the **+** button inside an expanded day section to open an inline search panel; find the place and tap it to assign.
|
||||
- **Add button** — Click on the day and then click the **+** button inside an expanded day section to open an inline search panel; find the place and tap it to assign.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ git push origin fix/my-changes:dev
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
Then open a Pull Request from your fork to `mauriceboe/TREK` targeting the `dev` branch.
|
||||
Then open a Pull Request from your fork to `mauriceboe/TREK` targeting the `dev` branch. If your PR only modifies files under `wiki/`, it is exempt from branch enforcement and may target any branch.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ Complete reference for all environment variables TREK reads.
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `HOST` | Bind address for the HTTP server (e.g. `127.0.0.1`, `10.0.0.72`). **Source / Proxmox installs only** — do not set this in Docker or any containerized deployment. See note below. | all interfaces |
|
||||
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key — see resolution order below | auto |
|
||||
| `TZ` | Timezone for logs, reminders, and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
@@ -25,6 +26,22 @@ Complete reference for all environment variables TREK reads.
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` |
|
||||
| `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links. | — |
|
||||
|
||||
### `HOST` — Source and Proxmox installs only
|
||||
|
||||
By default TREK binds to all network interfaces (`0.0.0.0`), which is the correct behaviour inside a container because Docker handles port exposure at the host level. Setting `HOST` overrides the bind address at the Node.js level.
|
||||
|
||||
**When to use it:** only when running TREK directly on a host (git sources or the [Proxmox community script](Install-Proxmox)) and you need to restrict which interface the server listens on — for example, to expose TREK only on a LAN interface while keeping it off the public-facing one.
|
||||
|
||||
**Never set `HOST` in Docker, Docker Compose, Helm, or Unraid deployments.** Use Docker's `-p <host-ip>:<host-port>:<container-port>` syntax or your orchestrator's port binding instead.
|
||||
|
||||
```
|
||||
# .env — source / Proxmox installs only
|
||||
HOST=10.0.0.72 # bind only on this LAN interface
|
||||
PORT=3001
|
||||
```
|
||||
|
||||
When `HOST` is set, the startup banner includes a `Host:` line confirming the bound address.
|
||||
|
||||
### `ENCRYPTION_KEY` — Resolution Order
|
||||
|
||||
`server/src/config.ts` resolves the encryption key in this order:
|
||||
|
||||