Compare commits

..

11 Commits

Author SHA1 Message Date
github-actions[bot] 7e49f3467c chore: bump version to 3.0.20 [skip ci] 2026-05-13 08:35:23 +00:00
jubnl 93b51a0bf5 fix(csp): allow unsafe-eval for HEIC image conversion 2026-05-13 10:34:57 +02:00
github-actions[bot] 5b710a429a chore: bump version to 3.0.19 [skip ci] 2026-05-13 08:13:30 +00:00
Julien G. da3cba2de3 v3.0.19 Bug Fixes (#992)
* fix(mcp): replace relative oauth constent redirect by absolute redirect derived from APP_URL (#987)

* feat(journey): convert HEIC/HEIF uploads to JPEG for cross-platform compatibility

HEIC is an Apple-only format not recognised as an image by many browsers
and platforms. heic-to (lazy-loaded) now converts HEIC/HEIF files to JPEG
before upload in both the gallery and entry editor photo pickers.
Embedded metadata (EXIF, GPS) may be lost during conversion — documented
in the Journey Journal wiki page.

* fix(journey): skip heic-to import for non-HEIC files to avoid test env failures

* fix(notifications): prevent double-escaping HTML in password reset emails

buildPasswordResetHtml passed a pre-built HTML block to buildEmailHtml,
which then escaped it again — rendering raw tags as plain text in the email.
2026-05-13 10:13:17 +02:00
github-actions[bot] 7f87dc1ce1 chore: bump version to 3.0.18 [skip ci] 2026-05-10 14:03:27 +00:00
Julien G. e7b419d397 security: login timing enumeration fix + dep CVE patches (v3.0.18) (#984)
* fix(security): equalise login response timing to prevent user enumeration (CWE-208)

Always run bcrypt.compareSync regardless of whether the email exists, using a
module-scope DUMMY_PASSWORD_HASH for unknown/OIDC-only accounts. Also wraps the
login handler in a 350ms minimum-latency pad (matching /forgot-password) as
defence-in-depth against CPU jitter and future code-path drift.

Fixes: CWE-203, CWE-208 — Observable Timing Discrepancy (CVSS 5.3 Medium)

* chore(deps): patch hono/picomatch/ip-address/brace-expansion CVEs, bump to node:24-alpine

Extends server/package.json overrides to pin hono >=4.12.16, picomatch >=4.0.4,
brace-expansion >=2.0.3, ip-address >=10.1.1. Adds matching overrides to client/.
Lockfiles regenerated to resolve: hono 4.12.18, ip-address 10.2.0, picomatch 4.0.4.

Also bumps base image node:22-alpine -> node:24-alpine (reduces base image CVEs)
and adds .github/workflows/security.yml to gate PRs on critical/high CVEs via
Docker Scout.

Addresses: CVE-2026-44456, CVE-2026-44455 (hono), CVE-2026-42338 (ip-address),
           CVE-2026-33671, CVE-2026-33672 (picomatch), CVE-2026-33750 (brace-expansion)

* chore: update emails in security.md

* ci(security): use docker/login-action for Scout auth instead of env vars

* chore: regenerate lock files

* chore: correct secret names

* chore: pr perms write

* fix(docker): remove package-lock.json from production image after npm ci

Docker Scout reads package-lock.json as an SBOM source and reports all
lockfile entries including devDependencies (e.g. picomatch via vitest/vite)
even when they are not physically installed. The lockfile has no runtime
purpose after npm ci completes, so delete it to ensure Scout only reports
packages actually present in node_modules.

* fix(docker): remove npm CLI from production image to eliminate bundled CVEs

picomatch@4.0.3, brace-expansion@5.0.4, and ip-address@10.1.0 were all
coming from /usr/local/lib/node_modules/npm — npm's own bundled packages
shipped with node:24-alpine. The production container only needs the node
binary to run the server; npm is unused at runtime.

Removing npm + npx after npm ci drops the package count from 500 to 365
and eliminates all npm-ecosystem CVEs (0H 0M remaining from npm packages).
Only busybox CVE-2025-60876 remains, which has no fix in Alpine 3.23.

* fix(deps): remove client overrides and brace-expansion server override; audit fix

brace-expansion ^2.0.3 in the client forced all installations to v2, breaking
minimatch in CI (test:coverage path via @vitest/coverage-v8 -> test-exclude)
which expects the named-export API of brace-expansion v5. The CVE it targeted
(>=4.0.0,<5.0.5) was only in npm's own bundled packages, already eliminated
by removing npm from the Docker image.

Also removes picomatch and ip-address client overrides for the same reason:
all three CVEs sourced from /usr/local/lib/node_modules/npm/, not app deps.
Drops brace-expansion from server overrides (server uses v2.1.0, outside the
affected range >=4.0.0).

* fix(#981): align public share itinerary order with daily planner (#985)

The public share page rendered daily items in a different order than the
authenticated planner because it used a simplified, divergent merge
algorithm. Five specific bugs:

1. shareService never loaded reservation_day_positions, so per-day
   transport positions were lost on the share page (fell back to
   day_plan_position ?? 999, pushing transports to the bottom).
2. Multi-day transports (overnight trains/flights) only appeared on their
   start day due to date-string filtering instead of day_id span logic.
3. Assignment-linked transports appeared twice (once as place, once as
   transport card) because the assignment_id exclusion was missing.
4. Time-based transport insertion was absent; missing positions used 999
   instead of a computed fractional position from the place timeline.
5. created_at tiebreaker was missing for assignments and notes with equal
   order_index/sort_order, making order non-deterministic on the share page.

Fix: extract the authoritative merge logic (parseTimeToMinutes,
getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems)
from DayPlanSidebar into client/src/utils/dayMerge.ts and use it in both
the planner and SharedTripPage. Enrich the shareService payload with
day_positions from reservation_day_positions and add created_at tiebreakers
to the assignment and day_notes ORDER BY clauses.

* fix(#983): shift owner vacay entries when update_trip moves trip window

updateTrip() now calls shiftOwnerEntriesForTripWindow() which looks up
the owner's own vacay plan (not the active plan) and shifts all entries
in the old date window by the same offset as the trip start date.
2026-05-10 16:03:15 +02:00
github-actions[bot] de3152ee57 chore: bump version to 3.0.17 [skip ci] 2026-05-07 11:49:53 +00:00
Julien G. de6c0fb781 fix: prevent Invalid URL crash when APP_URL lacks a protocol (#972)
* fix: prevent Invalid URL crash when APP_URL lacks a protocol (issue #970)

- Add getMcpSafeUrl() to notifications.ts: wraps getAppUrl() and
  guarantees a result that satisfies the MCP SDK's checkIssuerUrl
  requirement (https:// or http://localhost). Non-HTTPS, non-localhost
  URLs fall back to http://localhost:{PORT} instead of propagating an
  "Issuer URL must be HTTPS" error.
- Switch app.ts, mcp/index.ts, mcp/oauthProvider.ts, and oauthService.ts
  to import getMcpSafeUrl instead of getAppUrl for all MCP resource URL
  construction, so a misconfigured APP_URL never crashes the metadata
  router initialisation.
- Restrict the SDK metadata router middleware to /.well-known/* paths
  only. Previously it was invoked on every request; in production the
  lazy getMetaRouter() init ran on GET / and threw "Invalid URL" when
  APP_URL had no scheme, returning 500 for every page load.
- Log a startup warning when APP_URL is set but not usable, and include
  the resolved App URL in the startup banner so operators can confirm
  the correct value at a glance.
- Update oauth.test.ts mock to target notifications.getMcpSafeUrl.

* fix: show getAppUrl in banner and add two separate APP_URL startup checks

- Banner now displays getAppUrl() (the resolved app URL) rather than
  getMcpSafeUrl() so operators see the actual configured value
- Two independent startup warnings after the banner when APP_URL is set:
  1. whether APP_URL is a valid URL (parseable by new URL())
  2. whether APP_URL is MCP-safe (https:// or http://localhost)
- Fix getMcpSafeUrl() fallback port to use Number(PORT) || 3001,
  consistent with how index.ts parses PORT

* fix: update oidc.ts to import getAppUrl from notifications
2026-05-07 13:49:39 +02:00
github-actions[bot] 9f1d05e886 chore: bump version to 3.0.16 [skip ci] 2026-05-06 19:38:58 +00:00
Julien G. 25f326a659 v3.0.16 — bug fixes (#964)
* fix(mcp): MCP RFC compliant for more strict clients

* fix(mcp): serve flat /.well-known/oauth-protected-resource for ChatGPT reconnect

Clients such as ChatGPT probe the flat well-known URL on every fresh discovery
cycle (i.e. after a full disconnect/reconnect where cached OAuth state is cleared).
The SDK's mcpAuthMetadataRouter only serves the path-based form
/.well-known/oauth-protected-resource/mcp, so the flat probe returned 404.

Without the resource metadata, ChatGPT fell back to the issuer URL as the
resource parameter (https://…/ instead of https://…/mcp). The authorize handler
then rejected it with invalid_target and redirected back to ChatGPT's callback
with an error — showing the user the TREK home page instead of the consent form.

Add an explicit GET handler for the flat URL that returns the same protected
resource metadata, so the resource URI is discovered correctly on the first probe.

* fix(mcp): fix OAuth popup blank page — SW denylist and COOP header

Service worker was intercepting /oauth/authorize navigate requests
(not in denylist), serving index.html, and React Router's catch-all
redirected to / instead of the SDK authorize handler.

Helmet's default COOP: same-origin isolated the /oauth/consent popup
from its cross-origin opener, making window.opener null and breaking
the popup-based OAuth completion signal for ChatGPT and similar clients.

* fix(ntfy): encode non-Latin-1 header values with RFC 2047 to prevent ByteString crash

Todo/trip names containing chars like → or € (and non-Latin-1 locale templates
for Czech, Chinese, Russian, etc.) caused the Fetch API to throw when setting
the ntfy Title header. Apply RFC 2047 base64 encoded-word encoding for any
header value containing chars above U+00FF; ntfy decodes this automatically.

* docs(mcp): document Cloudflare bot detection blocking ChatGPT MCP requests

Add Cloudflare WAF note to MCP-Setup and a full troubleshooting entry covering
root cause (IP reputation + UA heuristics), free-plan limitation (disable Bot
Fight Mode entirely, with explicit warning), and paid-plan WAF skip rule with
the full expression syntax and path table for all MCP/OAuth/.well-known routes.

* fix(pwa): detect upstream proxy auth challenges and recover gracefully

Behind Cloudflare Zero Trust or Pangolin, cross-origin auth redirects on
/api/* calls surface as CORS errors (error.response === undefined) that
the existing 401 interceptor never catches, leaving the PWA stuck with
network-error toasts instead of re-authenticating.

New connectivity module probes /api/health every 30s using fetch with
cache:no-store and inspects Content-Type to reliably detect whether the
server is reachable vs intercepted by an upstream proxy.

axios interceptor changes:
- On !error.response + navigator.onLine: run probeNow(); if the health
  probe also fails (proxy is intercepting all requests), trigger a guarded
  window.location.reload() so the edge proxy can intercept the top-level
  navigation and run its auth flow (covers CF Access and Pangolin 302 mode)
- On error.response status 401 with text/html body: same reload path,
  covering Pangolin header-auth extended compatibility mode which returns
  401+HTML instead of a 302 redirect. TREK own 401s are always JSON so
  there is no collision with the existing AUTH_REQUIRED branch.
- sessionStorage flag prevents reload loops; cleared on any successful
  response so the guard resets after re-auth.

/api/health excluded from SW NetworkFirst cache (vite.config.js regex)
and Cache-Control: no-store added server-side so probes always hit the
network and cannot be served stale from the 24h api-data cache.

LoginPage caches last-known appConfig in localStorage so the SSO button
renders in OIDC+UN/PW dual mode even when the config fetch is intercepted
by the proxy. Auto-redirect to IdP skipped when config comes from cache
to avoid redirect loops while the proxy is challenging.

Fixes discussion #836.

* fix(files): add bottom-nav padding to files tab wrapper on mobile

* fix(budget): expose toolbar on mobile so users can add budget categories

* fix(pwa): unregister SW before proxy-reauth reload so Pangolin can challenge

WorkBox's NavigationRoute served the cached SPA shell on window.location.reload(),
meaning Pangolin/CF Access never saw the navigation and the app was left stuck
showing stale offline data. Unregistering the SW first lets the navigation reach
the network so the upstream proxy can run its auth flow.

Also rebuilds server/public with corrected sw.js (health excluded from
NetworkFirst, /oauth/ and /.well-known/ added to NavigationRoute denylist).

* chore: remove committed build artifacts from server/public

Dockerfile and Proxmox community script both rebuild client/dist and copy
it into server/public at build time — committed artifacts were never used.
Replace with .gitkeep and add server/public/* to .gitignore.

* chore: add build-from-sources script
2026-05-06 21:38:40 +02:00
jubnl 418f3e0bb2 docs: add Portainer install guide and tag strategy to wiki
- Add wiki/Install-Portainer.md with stack setup, image tag strategy, update instructions, named volumes, and 7 annotated screenshots
- Add tag strategy sections (latest / major / pinned) to Install-Docker.md, Install-Docker-Compose.md, and Updating.md
- Add named volumes examples with Docker Compose volumes reference link to Install-Docker.md, Install-Docker-Compose.md, and Install-Portainer.md
- Add Portainer update section with screenshots to Updating.md
- Add Install-Portainer entry to _Sidebar.md
2026-05-06 16:54:05 +02:00
125 changed files with 3858 additions and 7794 deletions
+37
View File
@@ -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
+2
View File
@@ -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 -4
View File
@@ -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
+1 -1
View File
@@ -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.
+25
View File
@@ -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"
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.0.15
version: 3.0.20
description: Minimal Helm chart for TREK app
appVersion: "3.0.15"
appVersion: "3.0.20"
+380 -1753
View File
File diff suppressed because it is too large Load Diff
+2 -7
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "3.0.15",
"version": "3.0.20",
"private": true,
"type": "module",
"scripts": {
@@ -18,12 +18,7 @@
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"workbox-cacheable-response": "^7.0.0",
"workbox-core": "^7.0.0",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0",
"heic-to": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
+1 -1
View File
@@ -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={
+112 -61
View File
@@ -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'
@@ -43,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 {
@@ -69,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 = {
@@ -143,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) */
@@ -154,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),
},
@@ -216,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 = {
@@ -314,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),
@@ -323,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),
@@ -388,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),
@@ -444,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 = {
@@ -530,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
@@ -10,11 +10,8 @@ import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
import BudgetPanel from './BudgetPanel';
import { offlineDb } from '../../db/offlineDb';
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
beforeEach(() => {
resetAllStores();
// Settlement and per-person APIs needed by BudgetPanel
server.use(
+4 -4
View File
@@ -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>
@@ -35,7 +35,6 @@ vi.mock('../../api/client', async (importOriginal) => {
});
import { filesApi } from '../../api/client';
import { offlineDb } from '../../db/offlineDb';
const buildFile = (overrides = {}) => ({
id: 1,
@@ -67,9 +66,7 @@ const defaultProps = {
allowedFileTypes: null,
};
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
// Seed auth as admin so useCanDo() returns true for all permissions
@@ -133,21 +130,15 @@ describe('FileManager', () => {
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-005: star button calls star endpoint', async () => {
let starCalled = false;
server.use(
http.patch('/api/trips/1/files/1/star', () => {
starCalled = true;
return HttpResponse.json({ success: true });
}),
);
it('FE-COMP-FILEMANAGER-005: star button calls filesApi.toggleStar', async () => {
render(<FileManager {...defaultProps} files={[buildFile()]} />);
const user = userEvent.setup();
// Find the star button by its title
const starBtn = screen.getByTitle(/star/i);
await user.click(starBtn);
await waitFor(() => expect(starCalled).toBe(true));
expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1);
});
it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
@@ -407,47 +398,39 @@ describe('FileManager', () => {
await screen.findByText('Hotel Paris');
});
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls file update endpoint', async () => {
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls filesApi.update', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Louvre Museum' });
const file = buildFile({ id: 1 });
const onUpdate = vi.fn().mockResolvedValue(undefined);
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/files/1', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ file: { ...file, place_id: 10 } });
}),
);
render(<FileManager {...defaultProps} files={[file]} places={[place]} onUpdate={onUpdate} />);
const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Louvre Museum');
// Click on the place button to link it
await user.click(screen.getByText('Louvre Museum'));
await waitFor(() => expect(capturedBody).toMatchObject({ place_id: 10 }));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: 10 });
});
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls file update endpoint', async () => {
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
const file = buildFile({ id: 1 });
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/files/1', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ file: { ...file, reservation_id: 20 } });
}),
);
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Train Ticket');
// Click on the reservation button to link it
await user.click(screen.getByText('Train Ticket'));
await waitFor(() => expect(capturedBody).toMatchObject({ reservation_id: 20 }));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: 20 });
});
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
@@ -524,46 +507,39 @@ describe('FileManager', () => {
await screen.findByText(/Colosseum/);
});
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls file update endpoint', async () => {
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls filesApi.update', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Venice Beach' });
// File already has place_id set to 10 (linked)
const file = buildFile({ id: 1, place_id: 10 });
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/files/1', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ file: { ...file, place_id: null } });
}),
);
render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Venice Beach');
await user.click(screen.getByText('Venice Beach'));
await waitFor(() => expect(capturedBody).toMatchObject({ place_id: null }));
// Clicking the linked place should unlink it
await user.click(screen.getByText('Venice Beach'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: null });
});
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls file update endpoint', async () => {
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
// File already has reservation_id set to 20
const file = buildFile({ id: 1, reservation_id: 20 });
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/files/1', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ file: { ...file, reservation_id: null } });
}),
);
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
const user = userEvent.setup();
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Museum Pass');
await user.click(screen.getByText('Museum Pass'));
await waitFor(() => expect(capturedBody).toMatchObject({ reservation_id: null }));
// Clicking the linked reservation should unlink it
await user.click(screen.getByText('Museum Pass'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: null });
});
it('FE-COMP-FILEMANAGER-033: opening PDF preview and closing via backdrop', async () => {
+2 -3
View File
@@ -5,7 +5,6 @@ import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, M
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client'
import { fileRepo } from '../../repo/fileRepo'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
@@ -291,7 +290,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const handleStar = async (fileId: number) => {
try {
await fileRepo.toggleStar(tripId, fileId)
await filesApi.toggleStar(tripId, fileId)
refreshFiles()
} catch { /* */ }
}
@@ -410,7 +409,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
try {
await fileRepo.update(tripId, fileId, data as Record<string, unknown>)
await filesApi.update(tripId, fileId, data)
refreshFiles()
} catch {
toast.error(t('files.toast.assignError'))
@@ -9,11 +9,8 @@ import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
import PackingListPanel from './PackingListPanel';
import { offlineDb } from '../../db/offlineDb';
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
beforeEach(() => {
resetAllStores();
// Side-effect APIs PackingListPanel calls on mount
server.use(
@@ -11,7 +11,6 @@ import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import DayDetailPanel from './DayDetailPanel';
import { offlineDb } from '../../db/offlineDb';
const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
@@ -29,9 +28,7 @@ const defaultProps = {
onAccommodationChange: vi.fn(),
};
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
server.use(
@@ -5,7 +5,6 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
import { weatherApi, accommodationsApi } from '../../api/client'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import CustomSelect from '../shared/CustomSelect'
@@ -118,10 +117,8 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const handleSaveAccommodation = async () => {
if (!hotelForm.place_id) return
try {
const selectedPlace = places.find(p => p.id === hotelForm.place_id)
const data = await accommodationRepo.create(tripId, {
const data = await accommodationsApi.create(tripId, {
place_id: hotelForm.place_id,
place_name: selectedPlace?.name,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null,
@@ -145,7 +142,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const updateAccommodationField = async (field, value) => {
if (!accommodation) return
try {
const data = await accommodationRepo.update(tripId, accommodation.id, { [field]: value || null })
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
setAccommodation(data.accommodation)
onAccommodationChange?.()
} catch {}
@@ -154,7 +151,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const handleRemoveAccommodation = async () => {
if (!accommodation) return
try {
await accommodationRepo.delete(tripId, accommodation.id)
await accommodationsApi.delete(tripId, accommodation.id)
const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated)
setDayAccommodations(updated.filter(a =>
@@ -586,7 +583,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<button onClick={async () => {
if (showHotelPicker === 'edit' && accommodation) {
// Update existing
await accommodationRepo.update(tripId, accommodation.id, {
await accommodationsApi.update(tripId, accommodation.id, {
place_id: hotelForm.place_id,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
+15 -113
View File
@@ -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
+15 -199
View File
@@ -1,21 +1,13 @@
/**
* Offline settings tab shows cached trips, storage info, and controls
* to re-sync or clear the offline cache. Also exposes runtime SW cache config.
* to re-sync or clear the offline cache.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Wifi, RefreshCw, Trash2, Database, Settings2, RotateCcw, CheckCircle } from 'lucide-react'
import React, { useState, useEffect, useCallback } from 'react'
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
import Section from './Section'
import { offlineDb, clearAll } from '../../db/offlineDb'
import { tripSyncManager } from '../../sync/tripSyncManager'
import { mutationQueue } from '../../sync/mutationQueue'
import {
DEFAULT_SW_CONFIG,
loadSwConfig,
saveSwConfig,
validateSwConfig,
SW_CONFIG_BOUNDS,
type SwCacheConfig,
} from '../../sync/swConfig'
import type { SyncMeta } from '../../db/offlineDb'
import type { Trip } from '../../types'
@@ -33,12 +25,6 @@ export default function OfflineTab(): React.ReactElement {
const [clearing, setClearing] = useState(false)
const [loading, setLoading] = useState(true)
// Cache config state
const [cacheConfig, setCacheConfig] = useState<SwCacheConfig>({ ...DEFAULT_SW_CONFIG })
const [configSaving, setConfigSaving] = useState(false)
const [configApplied, setConfigApplied] = useState<Date | null>(null)
const appliedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
@@ -67,59 +53,6 @@ export default function OfflineTab(): React.ReactElement {
useEffect(() => { load() }, [load])
// Load persisted cache config on mount
useEffect(() => {
loadSwConfig().then(setCacheConfig).catch(() => {})
}, [])
// Listen for SW acknowledgement
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.data?.type === 'CACHE_CONFIG_APPLIED') {
setConfigApplied(new Date())
setConfigSaving(false)
if (appliedTimerRef.current) clearTimeout(appliedTimerRef.current)
appliedTimerRef.current = setTimeout(() => setConfigApplied(null), 5000)
}
}
navigator.serviceWorker?.addEventListener('message', handler)
return () => {
navigator.serviceWorker?.removeEventListener('message', handler)
if (appliedTimerRef.current) clearTimeout(appliedTimerRef.current)
}
}, [])
async function handleSaveConfig() {
const validated = validateSwConfig(cacheConfig)
setCacheConfig(validated)
setConfigSaving(true)
try {
await saveSwConfig(validated)
const controller = navigator.serviceWorker?.controller
if (controller) {
controller.postMessage({ type: 'UPDATE_CACHE_CONFIG', config: validated })
// configSaving cleared by the SW message handler
} else {
// No active SW yet (e.g. first install) — config saved to IDB, applied on next SW activation
setConfigApplied(new Date())
setConfigSaving(false)
}
} catch {
setConfigSaving(false)
}
}
function handleResetConfig() {
setCacheConfig({ ...DEFAULT_SW_CONFIG })
}
function updateField(field: keyof SwCacheConfig) {
return (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseInt(e.target.value, 10)
if (!isNaN(v)) setCacheConfig(prev => ({ ...prev, [field]: v }))
}
}
async function handleResync() {
setSyncing(true)
try {
@@ -187,86 +120,6 @@ export default function OfflineTab(): React.ReactElement {
</button>
</div>
{/* Cache configuration */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Settings2 size={14} style={{ color: 'var(--text-muted)' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Cache configuration</span>
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
Changes apply immediately to the service worker and persist across reloads.
Existing cached entries follow their original TTL; new entries use the updated settings.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<CacheField
label="API cache TTL (days)"
value={cacheConfig.apiTtlDays}
min={SW_CONFIG_BOUNDS.ttlMin}
max={SW_CONFIG_BOUNDS.ttlMax}
onChange={updateField('apiTtlDays')}
/>
<CacheField
label="API max entries"
value={cacheConfig.apiMaxEntries}
min={SW_CONFIG_BOUNDS.entriesMin}
max={SW_CONFIG_BOUNDS.entriesMax}
onChange={updateField('apiMaxEntries')}
/>
<CacheField
label="Map tiles TTL (days)"
value={cacheConfig.tilesTtlDays}
min={SW_CONFIG_BOUNDS.ttlMin}
max={SW_CONFIG_BOUNDS.ttlMax}
onChange={updateField('tilesTtlDays')}
/>
<CacheField
label="Map tiles max entries"
value={cacheConfig.tilesMaxEntries}
min={SW_CONFIG_BOUNDS.entriesMin}
max={SW_CONFIG_BOUNDS.entriesMax}
onChange={updateField('tilesMaxEntries')}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<button
onClick={handleSaveConfig}
disabled={configSaving}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
cursor: configSaving ? 'not-allowed' : 'pointer',
fontSize: 13, fontWeight: 500, opacity: configSaving ? 0.6 : 1,
}}
>
<RefreshCw size={14} style={configSaving ? { animation: 'spin 1s linear infinite' } : {}} />
{configSaving ? 'Applying…' : 'Save'}
</button>
<button
onClick={handleResetConfig}
disabled={configSaving}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-secondary)', color: 'var(--text-muted)',
cursor: configSaving ? 'not-allowed' : 'pointer',
fontSize: 13, fontWeight: 500,
}}
>
<RotateCcw size={14} />
Reset to defaults
</button>
{configApplied && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: '#22c55e' }}>
<CheckCircle size={12} />
Applied at {configApplied.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
</div>
{/* Cached trip list */}
{loading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading</p>
@@ -286,32 +139,24 @@ export default function OfflineTab(): React.ReactElement {
display: 'flex', flexDirection: 'column', gap: 2,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span style={{ fontWeight: 600, fontSize: 14, color: trip.title ? 'var(--text-primary)' : 'var(--text-muted)', fontStyle: trip.title ? 'normal' : 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trip.title || 'Unnamed trip'}
</span>
{trip.description ? (
<span style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trip.description.length > 72 ? trip.description.slice(0, 72) + '…' : trip.description}
</span>
) : null}
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{trip.start_date
? `${formatDate(trip.start_date)} ${formatDate(trip.end_date)}`
: 'No dates set'}
{' · '}
{placeCount} place{placeCount !== 1 ? 's' : ''}
{fileCount > 0 ? ` · ${fileCount} file${fileCount !== 1 ? 's' : ''}` : null}
</span>
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
{trip.name}
</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
<Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
{meta.lastSyncedAt
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: '—'}
</span>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{formatDate(trip.start_date)} {formatDate(trip.end_date)}
{' · '}
{placeCount} place{placeCount !== 1 ? 's' : ''}
{' · '}
{fileCount} file{fileCount !== 1 ? 's' : ''}
</span>
</div>
))}
</div>
@@ -333,32 +178,3 @@ function Stat({ label, value }: { label: string; value: number }) {
</div>
)
}
function CacheField({
label, value, min, max, onChange,
}: {
label: string
value: number
min: number
max: number
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}) {
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 500 }}>{label}</span>
<input
type="number"
value={value}
min={min}
max={max}
onChange={onChange}
style={{
padding: '6px 10px', borderRadius: 6,
border: '1px solid var(--border-primary)',
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
fontSize: 13, width: '100%', boxSizing: 'border-box',
}}
/>
</label>
)
}
+3
View File
@@ -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>
+4 -9
View File
@@ -744,17 +744,12 @@ export default function DashboardPage(): React.ReactElement {
const loadTrips = async () => {
setIsLoading(true)
try {
const { trips, archivedTrips, refresh } = await tripRepo.list()
const { trips, archivedTrips } = await tripRepo.list()
setTrips(sortTrips(trips))
setArchivedTrips(sortTrips(archivedTrips))
setIsLoading(false)
refresh.then(fresh => {
if (!fresh) return
setTrips(sortTrips(fresh.trips))
setArchivedTrips(sortTrips(fresh.archivedTrips))
}).catch(() => {})
} catch {
toast.error(t('dashboard.toast.loadError'))
} finally {
setIsLoading(false)
}
}
@@ -796,7 +791,7 @@ export default function DashboardPage(): React.ReactElement {
const handleArchive = async (id) => {
try {
const data = await tripRepo.update(id, { is_archived: true })
const data = await tripsApi.archive(id)
setTrips(prev => prev.filter(t => t.id !== id))
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.archived'))
@@ -807,7 +802,7 @@ export default function DashboardPage(): React.ReactElement {
const handleUnarchive = async (id) => {
try {
const data = await tripRepo.update(id, { is_archived: false })
const data = await tripsApi.unarchive(id)
setArchivedTrips(prev => prev.filter(t => t.id !== id))
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.restored'))
+1 -4
View File
@@ -9,7 +9,6 @@ import { buildUser, buildTrip, buildTripFile } from '../../tests/helpers/factori
import { useAuthStore } from '../store/authStore';
import { useTripStore } from '../store/tripStore';
import FilesPage from './FilesPage';
import { offlineDb } from '../db/offlineDb';
vi.mock('../components/Files/FileManager', () => ({
default: ({ files }: { files: unknown[]; onUpload: unknown; onDelete: unknown }) =>
@@ -30,9 +29,7 @@ function renderFilesPage(tripId: number | string = 1) {
);
}
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
beforeEach(() => {
vi.clearAllMocks();
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
+5 -2
View File
@@ -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'
@@ -1027,8 +1028,9 @@ 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()
@@ -2265,7 +2267,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', () => {
});
});
});
});
});
+22 -8
View File
@@ -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):
+1 -1
View File
@@ -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 = {
+178 -175
View File
@@ -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>
)
}
}
+10 -8
View File
@@ -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
+1 -1
View File
@@ -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)}
+9 -82
View File
@@ -1,89 +1,16 @@
import { accommodationsApi } from '../api/client'
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Accommodation } from '../types'
export const accommodationRepo = {
async list(tripId: number | string): Promise<{ accommodations: Accommodation[]; refresh: Promise<{ accommodations: Accommodation[] } | null> }> {
const cached = await offlineDb.accommodations
.where('trip_id').equals(Number(tripId)).toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await accommodationsApi.list(tripId)
upsertAccommodations(result.accommodations || []).catch(() => {})
return result
} catch {
return null
}
})()
if (cached.length > 0) return { accommodations: cached, refresh }
const fresh = await refresh
if (!fresh) return { accommodations: [], refresh: Promise.resolve(null) }
return { accommodations: fresh.accommodations, refresh: Promise.resolve(fresh) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ accommodation: Accommodation }> {
const tempId = -(Date.now())
const tempAccommodation: Accommodation = {
...(data as Partial<Accommodation>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New accommodation',
address: null,
check_in: null,
check_in_end: null,
check_out: null,
confirmation_number: null,
notes: null,
url: null,
created_at: new Date().toISOString(),
} as Accommodation
await offlineDb.accommodations.put(tempAccommodation)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/accommodations`,
body: data,
resource: 'accommodations',
tempId,
})
mutationQueue.flush().catch(() => {})
return { accommodation: tempAccommodation }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ accommodation: Accommodation }> {
const existing = await offlineDb.accommodations.get(id)
const optimistic: Accommodation = { ...(existing ?? {} as Accommodation), ...(data as Partial<Accommodation>), id }
await offlineDb.accommodations.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/accommodations/${id}`,
body: data,
resource: 'accommodations',
})
mutationQueue.flush().catch(() => {})
return { accommodation: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.accommodations.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/accommodations/${id}`,
body: undefined,
resource: 'accommodations',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
if (!navigator.onLine) {
const accommodations = await offlineDb.accommodations
.where('trip_id').equals(Number(tripId)).toArray()
return { accommodations }
}
const result = await accommodationsApi.list(tripId)
upsertAccommodations(result.accommodations || []).catch(() => {})
return result
},
}
+11 -79
View File
@@ -1,86 +1,18 @@
import { budgetApi } from '../api/client'
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { BudgetItem } from '../types'
export const budgetRepo = {
async list(tripId: number | string): Promise<{ items: BudgetItem[]; refresh: Promise<{ items: BudgetItem[] } | null> }> {
const cached = await offlineDb.budgetItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await budgetApi.list(tripId)
upsertBudgetItems(result.items)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { items: cached, refresh }
const fresh = await refresh
if (!fresh) return { items: [], refresh: Promise.resolve(null) }
return { items: fresh.items, refresh: Promise.resolve(fresh) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: BudgetItem }> {
const tempId = -(Date.now())
const tempItem: BudgetItem = {
...(data as Partial<BudgetItem>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New expense',
amount: (data.amount as number) ?? 0,
currency: (data.currency as string) ?? 'USD',
members: [],
} as BudgetItem
await offlineDb.budgetItems.put(tempItem)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/budget`,
body: data,
resource: 'budgetItems',
tempId,
})
mutationQueue.flush().catch(() => {})
return { item: tempItem }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: BudgetItem }> {
const existing = await offlineDb.budgetItems.get(id)
const optimistic: BudgetItem = { ...(existing ?? {} as BudgetItem), ...(data as Partial<BudgetItem>), id }
await offlineDb.budgetItems.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/budget/${id}`,
body: data,
resource: 'budgetItems',
})
mutationQueue.flush().catch(() => {})
return { item: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.budgetItems.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/budget/${id}`,
body: undefined,
resource: 'budgetItems',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.budgetItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { items: cached }
}
const result = await budgetApi.list(tripId)
upsertBudgetItems(result.items)
return result
},
}
+11 -39
View File
@@ -1,46 +1,18 @@
import { daysApi } from '../api/client'
import { offlineDb, upsertDays } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Day } from '../types'
export const dayRepo = {
async list(tripId: number | string): Promise<{ days: Day[]; refresh: Promise<{ days: Day[] } | null> }> {
const cached = (await offlineDb.days
.where('trip_id')
.equals(Number(tripId))
.sortBy('day_number' as keyof Day)) as Day[]
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await daysApi.list(tripId)
upsertDays(result.days)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { days: cached, refresh }
const fresh = await refresh
if (!fresh) return { days: [], refresh: Promise.resolve(null) }
return { days: fresh.days, refresh: Promise.resolve(fresh) }
},
async update(tripId: number | string, dayId: number | string, data: Record<string, unknown>): Promise<{ day: Day }> {
const existing = await offlineDb.days.get(Number(dayId))
const optimistic: Day = { ...(existing ?? {} as Day), ...data, id: Number(dayId) }
await offlineDb.days.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/days/${dayId}`,
body: data,
resource: 'days',
})
mutationQueue.flush().catch(() => {})
return { day: optimistic }
async list(tripId: number | string): Promise<{ days: Day[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.days
.where('trip_id')
.equals(Number(tripId))
.sortBy('day_number' as keyof Day)
return { days: cached as Day[] }
}
const result = await daysApi.list(tripId)
upsertDays(result.days)
return result
},
}
+10 -69
View File
@@ -1,77 +1,18 @@
import { filesApi } from '../api/client'
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { TripFile } from '../types'
export const fileRepo = {
async list(tripId: number | string): Promise<{ files: TripFile[]; refresh: Promise<{ files: TripFile[] } | null> }> {
const cached = await offlineDb.tripFiles
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await filesApi.list(tripId)
upsertTripFiles(result.files)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { files: cached, refresh }
const fresh = await refresh
if (!fresh) return { files: [], refresh: Promise.resolve(null) }
return { files: fresh.files, refresh: Promise.resolve(fresh) }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ file: TripFile }> {
const existing = await offlineDb.tripFiles.get(id)
const optimistic: TripFile = { ...(existing ?? {} as TripFile), ...(data as Partial<TripFile>), id: Number(id) }
await offlineDb.tripFiles.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/files/${id}`,
body: data,
resource: 'tripFiles',
})
mutationQueue.flush().catch(() => {})
return { file: optimistic }
},
async toggleStar(tripId: number | string, id: number): Promise<unknown> {
const existing = await offlineDb.tripFiles.get(id)
if (existing) {
await offlineDb.tripFiles.put({ ...existing, starred: existing.starred ? 0 : 1 })
async list(tripId: number | string): Promise<{ files: TripFile[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.tripFiles
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { files: cached }
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PATCH',
url: `/trips/${tripId}/files/${id}/star`,
body: undefined,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.tripFiles.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/files/${id}`,
body: undefined,
resource: 'tripFiles',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
const result = await filesApi.list(tripId)
upsertTripFiles(result.files)
return result
},
}
+71 -67
View File
@@ -4,81 +4,85 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { PackingItem } from '../types'
export const packingRepo = {
async list(tripId: number | string): Promise<{ items: PackingItem[]; refresh: Promise<{ items: PackingItem[] } | null> }> {
const cached = await offlineDb.packingItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await packingApi.list(tripId)
upsertPackingItems(result.items)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { items: cached, refresh }
const fresh = await refresh
if (!fresh) return { items: [], refresh: Promise.resolve(null) }
return { items: fresh.items, refresh: Promise.resolve(fresh) }
async list(tripId: number | string): Promise<{ items: PackingItem[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.packingItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { items: cached }
}
const result = await packingApi.list(tripId)
upsertPackingItems(result.items)
return result
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
const tempId = -(Date.now())
const tempItem: PackingItem = {
...(data as Partial<PackingItem>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New item',
checked: 0,
} as PackingItem
await offlineDb.packingItems.put(tempItem)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/packing`,
body: data,
resource: 'packingItems',
tempId,
})
mutationQueue.flush().catch(() => {})
return { item: tempItem }
if (!navigator.onLine) {
const tempId = -(Date.now())
const tempItem: PackingItem = {
...(data as Partial<PackingItem>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New item',
checked: 0,
} as PackingItem
await offlineDb.packingItems.put(tempItem)
const id = generateUUID()
await mutationQueue.enqueue({
id,
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/packing`,
body: data,
resource: 'packingItems',
tempId,
})
return { item: tempItem }
}
const result = await packingApi.create(tripId, data)
offlineDb.packingItems.put(result.item)
return result
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
const existing = await offlineDb.packingItems.get(id)
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
await offlineDb.packingItems.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/packing/${id}`,
body: data,
resource: 'packingItems',
})
mutationQueue.flush().catch(() => {})
return { item: optimistic }
if (!navigator.onLine) {
const existing = await offlineDb.packingItems.get(id)
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
await offlineDb.packingItems.put(optimistic)
const mutId = generateUUID()
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/packing/${id}`,
body: data,
resource: 'packingItems',
})
return { item: optimistic }
}
const result = await packingApi.update(tripId, id, data)
offlineDb.packingItems.put(result.item)
return result
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.packingItems.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/packing/${id}`,
body: undefined,
resource: 'packingItems',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
if (!navigator.onLine) {
await offlineDb.packingItems.delete(id)
const mutId = generateUUID()
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/packing/${id}`,
body: undefined,
resource: 'packingItems',
entityId: id,
})
return { success: true }
}
const result = await packingApi.delete(tripId, id)
offlineDb.packingItems.delete(id)
return result
},
}
+84 -75
View File
@@ -4,97 +4,106 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Place } from '../types'
export const placeRepo = {
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[]; refresh: Promise<{ places: Place[] } | null> }> {
const cached = await offlineDb.places
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await placesApi.list(tripId, params)
upsertPlaces(result.places)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { places: cached, refresh }
const fresh = await refresh
if (!fresh) return { places: [], refresh: Promise.resolve(null) }
return { places: fresh.places, refresh: Promise.resolve(fresh) }
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.places
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { places: cached }
}
const result = await placesApi.list(tripId, params)
upsertPlaces(result.places)
return result
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
const tempId = -(Date.now())
const tempPlace: Place = {
...(data as Partial<Place>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New place',
} as Place
await offlineDb.places.put(tempPlace)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/places`,
body: data,
resource: 'places',
tempId,
})
mutationQueue.flush().catch(() => {})
return { place: tempPlace }
if (!navigator.onLine) {
const tempId = -(Date.now())
const tempPlace: Place = {
...(data as Partial<Place>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New place',
} as Place
await offlineDb.places.put(tempPlace)
const id = generateUUID()
await mutationQueue.enqueue({
id,
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/places`,
body: data,
resource: 'places',
tempId,
})
return { place: tempPlace }
}
const result = await placesApi.create(tripId, data)
offlineDb.places.put(result.place)
return result
},
async update(tripId: number | string, id: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
const existing = await offlineDb.places.get(Number(id))
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
await offlineDb.places.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/places/${id}`,
body: data,
resource: 'places',
})
mutationQueue.flush().catch(() => {})
return { place: optimistic }
if (!navigator.onLine) {
const existing = await offlineDb.places.get(Number(id))
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
await offlineDb.places.put(optimistic)
const mutId = generateUUID()
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/places/${id}`,
body: data,
resource: 'places',
})
return { place: optimistic }
}
const result = await placesApi.update(tripId, id, data)
offlineDb.places.put(result.place)
return result
},
async delete(tripId: number | string, id: number | string): Promise<unknown> {
await offlineDb.places.delete(Number(id))
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: Number(id),
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
await offlineDb.places.bulkDelete(ids)
for (const id of ids) {
if (!navigator.onLine) {
await offlineDb.places.delete(Number(id))
const mutId = generateUUID()
await mutationQueue.enqueue({
id: generateUUID(),
id: mutId,
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: id,
entityId: Number(id),
})
return { success: true }
}
mutationQueue.flush().catch(() => {})
return { deleted: ids, count: ids.length }
const result = await placesApi.delete(tripId, id)
offlineDb.places.delete(Number(id))
return result
},
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
if (!navigator.onLine) {
await offlineDb.places.bulkDelete(ids)
for (const id of ids) {
const mutId = generateUUID()
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: id,
})
}
return { deleted: ids, count: ids.length }
}
const result = await placesApi.bulkDelete(tripId, ids)
await offlineDb.places.bulkDelete(ids)
return result
},
}
+11 -84
View File
@@ -1,91 +1,18 @@
import { reservationsApi } from '../api/client'
import { offlineDb, upsertReservations } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Reservation } from '../types'
export const reservationRepo = {
async list(tripId: number | string): Promise<{ reservations: Reservation[]; refresh: Promise<{ reservations: Reservation[] } | null> }> {
const cached = await offlineDb.reservations
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await reservationsApi.list(tripId)
upsertReservations(result.reservations)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { reservations: cached, refresh }
const fresh = await refresh
if (!fresh) return { reservations: [], refresh: Promise.resolve(null) }
return { reservations: fresh.reservations, refresh: Promise.resolve(fresh) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ reservation: Reservation }> {
const tempId = -(Date.now())
const tempReservation: Reservation = {
...(data as Partial<Reservation>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New reservation',
type: (data.type as string) ?? 'other',
status: 'pending',
date: (data.date as string) ?? null,
time: null,
confirmation_number: null,
notes: null,
url: null,
created_at: new Date().toISOString(),
} as Reservation
await offlineDb.reservations.put(tempReservation)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/reservations`,
body: data,
resource: 'reservations',
tempId,
})
mutationQueue.flush().catch(() => {})
return { reservation: tempReservation }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ reservation: Reservation }> {
const existing = await offlineDb.reservations.get(id)
const optimistic: Reservation = { ...(existing ?? {} as Reservation), ...(data as Partial<Reservation>), id }
await offlineDb.reservations.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/reservations/${id}`,
body: data,
resource: 'reservations',
})
mutationQueue.flush().catch(() => {})
return { reservation: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.reservations.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/reservations/${id}`,
body: undefined,
resource: 'reservations',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
async list(tripId: number | string): Promise<{ reservations: Reservation[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.reservations
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { reservations: cached }
}
const result = await reservationsApi.list(tripId)
upsertReservations(result.reservations)
return result
},
}
+11 -82
View File
@@ -1,89 +1,18 @@
import { todoApi } from '../api/client'
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { TodoItem } from '../types'
export const todoRepo = {
async list(tripId: number | string): Promise<{ items: TodoItem[]; refresh: Promise<{ items: TodoItem[] } | null> }> {
const cached = await offlineDb.todoItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await todoApi.list(tripId)
upsertTodoItems(result.items)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { items: cached, refresh }
const fresh = await refresh
if (!fresh) return { items: [], refresh: Promise.resolve(null) }
return { items: fresh.items, refresh: Promise.resolve(fresh) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: TodoItem }> {
const tempId = -(Date.now())
const tempItem: TodoItem = {
...(data as Partial<TodoItem>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New todo',
checked: 0,
sort_order: 0,
due_date: null,
description: null,
assigned_user_id: null,
priority: 0,
} as TodoItem
await offlineDb.todoItems.put(tempItem)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/todo`,
body: data,
resource: 'todoItems',
tempId,
})
mutationQueue.flush().catch(() => {})
return { item: tempItem }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: TodoItem }> {
const existing = await offlineDb.todoItems.get(id)
const optimistic: TodoItem = { ...(existing ?? {} as TodoItem), ...(data as Partial<TodoItem>), id }
await offlineDb.todoItems.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/todo/${id}`,
body: data,
resource: 'todoItems',
})
mutationQueue.flush().catch(() => {})
return { item: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.todoItems.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/todo/${id}`,
body: undefined,
resource: 'todoItems',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
async list(tripId: number | string): Promise<{ items: TodoItem[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.todoItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { items: cached }
}
const result = await todoApi.list(tripId)
upsertTodoItems(result.items)
return result
},
}
+19 -63
View File
@@ -1,77 +1,33 @@
import { tripsApi } from '../api/client'
import { offlineDb, upsertTrip } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Trip } from '../types'
type TripsRefresh = Promise<{ trips: Trip[]; archivedTrips: Trip[] } | null>
type TripRefresh = Promise<{ trip: Trip } | null>
export const tripRepo = {
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[]; refresh: TripsRefresh }> {
const all = await offlineDb.trips.toArray()
const refresh: TripsRefresh = (async () => {
if (!navigator.onLine) return null
try {
const [active, archived] = await Promise.all([
tripsApi.list(),
tripsApi.list({ archived: 1 }),
])
active.trips.forEach(t => upsertTrip(t))
archived.trips.forEach(t => upsertTrip(t))
return { trips: active.trips, archivedTrips: archived.trips }
} catch {
return null
}
})()
if (all.length > 0) {
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> {
if (!navigator.onLine) {
const all = await offlineDb.trips.toArray()
return {
trips: all.filter(t => !t.is_archived),
archivedTrips: all.filter(t => t.is_archived),
refresh,
}
}
const fresh = await refresh
if (!fresh) return { trips: [], archivedTrips: [], refresh: Promise.resolve(null) }
return { ...fresh, refresh: Promise.resolve(fresh) }
const [active, archived] = await Promise.all([
tripsApi.list(),
tripsApi.list({ archived: 1 }),
])
active.trips.forEach(t => upsertTrip(t))
archived.trips.forEach(t => upsertTrip(t))
return { trips: active.trips, archivedTrips: archived.trips }
},
async get(tripId: number | string): Promise<{ trip: Trip; refresh: TripRefresh }> {
const cached = await offlineDb.trips.get(Number(tripId))
const refresh: TripRefresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await tripsApi.get(tripId)
upsertTrip(result.trip)
return result
} catch {
return null
}
})()
if (cached) return { trip: cached, refresh }
const fresh = await refresh
if (!fresh) throw new Error('No cached trip data available offline')
return { trip: fresh.trip, refresh: Promise.resolve(fresh) }
},
async update(tripId: number | string, data: Partial<Trip>): Promise<{ trip: Trip }> {
const existing = await offlineDb.trips.get(Number(tripId))
const optimistic: Trip = { ...(existing ?? {} as Trip), ...(data as Partial<Trip>), id: Number(tripId) }
await offlineDb.trips.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}`,
body: data as Record<string, unknown>,
resource: 'trips',
})
mutationQueue.flush().catch(() => {})
return { trip: optimistic }
async get(tripId: number | string): Promise<{ trip: Trip }> {
if (!navigator.onLine) {
const cached = await offlineDb.trips.get(Number(tripId))
if (cached) return { trip: cached }
throw new Error('No cached trip data available offline')
}
const result = await tripsApi.get(tripId)
upsertTrip(result.trip)
return result
},
}
@@ -1,6 +1,4 @@
import { assignmentsApi } from '../../api/client'
import { offlineDb } from '../../db/offlineDb'
import { mutationQueue, generateUUID } from '../../sync/mutationQueue'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Assignment, AssignmentsMap } from '../../types'
@@ -42,23 +40,6 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
}
}))
if (!navigator.onLine) {
const day = await offlineDb.days.get(parseInt(String(dayId)))
if (day) {
const updated = [...(day.assignments || [])]
updated.splice(insertIdx, 0, tempAssignment)
await offlineDb.days.put({ ...day, assignments: updated })
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/days/${dayId}/assignments`,
body: { place_id: placeId },
})
return tempAssignment
}
try {
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
const newAssignment: Assignment = {
@@ -118,24 +99,6 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
}
}))
if (!navigator.onLine) {
const day = await offlineDb.days.get(parseInt(String(dayId)))
if (day) {
await offlineDb.days.put({
...day,
assignments: (day.assignments || []).filter(a => a.id !== assignmentId),
})
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/days/${dayId}/assignments/${assignmentId}`,
body: undefined,
})
return
}
try {
await assignmentsApi.delete(tripId, dayId, assignmentId)
} catch (err: unknown) {
+23 -24
View File
@@ -4,11 +4,8 @@ import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildBudgetItem } from '../../../tests/helpers/factories';
import { useTripStore } from '../tripStore';
import { offlineDb } from '../../db/offlineDb';
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
beforeEach(() => {
resetAllStores();
server.resetHandlers();
});
@@ -37,28 +34,25 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems).toEqual([]);
});
it('FE-STORE-BUDGET-003: addBudgetItem appends to store optimistically', async () => {
it('FE-STORE-BUDGET-003: addBudgetItem appends to store and returns item', async () => {
const newItem = buildBudgetItem({ name: 'Hotel', trip_id: 1 });
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ item: buildBudgetItem({ name: 'Hotel', trip_id: 1 }) })
HttpResponse.json({ item: newItem })
)
);
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel' });
expect(result.name).toBe('Hotel');
const items = useTripStore.getState().budgetItems;
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Hotel');
expect(result.id).toBe(newItem.id);
expect(useTripStore.getState().budgetItems).toContainEqual(newItem);
});
it('FE-STORE-BUDGET-004: addBudgetItem adds item optimistically even on API error', async () => {
it('FE-STORE-BUDGET-004: addBudgetItem throws on API error', async () => {
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
)
);
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Item' });
expect(result.name).toBe('Item');
expect(useTripStore.getState().budgetItems).toHaveLength(1);
await expect(useTripStore.getState().addBudgetItem(1, {})).rejects.toThrow();
});
it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
@@ -77,21 +71,24 @@ describe('budgetSlice', () => {
expect(items[0].name).toBe('New');
});
it('FE-STORE-BUDGET-006: updateBudgetItem resolves and updates store optimistically', async () => {
const existing = buildBudgetItem({ id: 20, trip_id: 1, amount: 100 });
it('FE-STORE-BUDGET-006: updateBudgetItem calls loadReservations when reservation_id + total_price provided', async () => {
const existing = buildBudgetItem({ id: 20, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [existing] });
const loadReservations = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { loadReservations });
const itemWithReservation = { ...existing, reservation_id: 99 };
server.use(
http.put('/api/trips/1/budget/20', () =>
HttpResponse.json({ item: { ...existing, amount: 50 } })
HttpResponse.json({ item: itemWithReservation })
)
);
const result = await useTripStore.getState().updateBudgetItem(1, 20, { amount: 50 });
expect(result.amount).toBe(50);
expect(useTripStore.getState().budgetItems[0].amount).toBe(50);
await useTripStore.getState().updateBudgetItem(1, 20, { total_price: 50 });
expect(loadReservations).toHaveBeenCalledWith(1);
});
it('FE-STORE-BUDGET-007: deleteBudgetItem removes item permanently even on API error', async () => {
it('FE-STORE-BUDGET-007: deleteBudgetItem optimistically removes and rolls back on error', async () => {
const item = buildBudgetItem({ id: 5, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [item] });
@@ -100,9 +97,11 @@ describe('budgetSlice', () => {
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
);
await useTripStore.getState().deleteBudgetItem(1, 5);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().budgetItems).toHaveLength(0);
// The item is removed immediately (optimistic), then restored on error
const deletePromise = useTripStore.getState().deleteBudgetItem(1, 5);
await expect(deletePromise).rejects.toThrow();
// After rollback, item is back
expect(useTripStore.getState().budgetItems).toContainEqual(item);
});
it('FE-STORE-BUDGET-008: setBudgetItemMembers updates members on matching item', async () => {
+3 -6
View File
@@ -24,9 +24,6 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
try {
const data = await budgetRepo.list(tripId)
set({ budgetItems: data.items })
data.refresh.then(fresh => {
if (fresh) set({ budgetItems: fresh.items })
}).catch(() => {})
} catch (err: unknown) {
console.error('Failed to load budget items:', err)
}
@@ -34,7 +31,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
addBudgetItem: async (tripId, data) => {
try {
const result = await budgetRepo.create(tripId, data as Record<string, unknown>)
const result = await budgetApi.create(tripId, data)
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item
} catch (err: unknown) {
@@ -44,7 +41,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
updateBudgetItem: async (tripId, id, data) => {
try {
const result = await budgetRepo.update(tripId, id, data as Record<string, unknown>)
const result = await budgetApi.update(tripId, id, data)
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
@@ -61,7 +58,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
const prev = get().budgetItems
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
try {
await budgetRepo.delete(tripId, id)
await budgetApi.delete(tripId, id)
} catch (err: unknown) {
set({ budgetItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting budget item'))
+3 -67
View File
@@ -1,7 +1,4 @@
import { dayNotesApi } from '../../api/client'
import { offlineDb } from '../../db/offlineDb'
import { dayRepo } from '../../repo/dayRepo'
import { mutationQueue, generateUUID } from '../../sync/mutationQueue'
import { daysApi, dayNotesApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { DayNote } from '../../types'
@@ -22,7 +19,7 @@ export interface DayNotesSlice {
export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({
updateDayNotes: async (tripId, dayId, notes) => {
try {
await dayRepo.update(tripId, dayId, { notes })
await daysApi.update(tripId, dayId, { notes })
set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, notes } : d)
}))
@@ -33,7 +30,7 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
updateDayTitle: async (tripId, dayId, title) => {
try {
await dayRepo.update(tripId, dayId, { title })
await daysApi.update(tripId, dayId, { title })
set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, title } : d)
}))
@@ -51,22 +48,6 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote],
}
}))
if (!navigator.onLine) {
const day = await offlineDb.days.get(Number(dayId))
if (day) {
await offlineDb.days.put({ ...day, notes_items: [...(day.notes_items || []), tempNote] })
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/days/${dayId}/notes`,
body: data as Record<string, unknown>,
})
return tempNote
}
try {
const result = await dayNotesApi.create(tripId, dayId, data)
set(state => ({
@@ -88,32 +69,6 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
},
updateDayNote: async (tripId, dayId, id, data) => {
if (!navigator.onLine) {
const existing = get().dayNotes[String(dayId)]?.find(n => n.id === id)
const optimistic: DayNote = { ...(existing ?? {} as DayNote), ...(data as Partial<DayNote>), id }
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? optimistic : n),
}
}))
const day = await offlineDb.days.get(Number(dayId))
if (day) {
await offlineDb.days.put({
...day,
notes_items: (day.notes_items || []).map(n => n.id === id ? optimistic : n),
})
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/days/${dayId}/notes/${id}`,
body: data as Record<string, unknown>,
})
return optimistic
}
try {
const result = await dayNotesApi.update(tripId, dayId, id, data)
set(state => ({
@@ -136,25 +91,6 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
}
}))
if (!navigator.onLine) {
const day = await offlineDb.days.get(Number(dayId))
if (day) {
await offlineDb.days.put({
...day,
notes_items: (day.notes_items || []).filter(n => n.id !== id),
})
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/days/${dayId}/notes/${id}`,
body: undefined,
})
return
}
try {
await dayNotesApi.delete(tripId, dayId, id)
} catch (err: unknown) {
+2 -4
View File
@@ -35,12 +35,10 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({
},
deleteFile: async (tripId, id) => {
const prev = get().files
set(state => ({ files: state.files.filter(f => f.id !== id) }))
try {
await fileRepo.delete(tripId, id)
await filesApi.delete(tripId, id)
set(state => ({ files: state.files.filter(f => f.id !== id) }))
} catch (err: unknown) {
set({ files: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting file'))
}
},
-3
View File
@@ -20,9 +20,6 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
try {
const data = await placeRepo.list(tripId)
set({ places: data.places })
data.refresh.then(fresh => {
if (fresh) set({ places: fresh.places })
}).catch(() => {})
} catch (err: unknown) {
console.error('Failed to refresh places:', err)
}
+6 -7
View File
@@ -1,3 +1,4 @@
import { reservationsApi } from '../../api/client'
import { reservationRepo } from '../../repo/reservationRepo'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
@@ -27,7 +28,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
addReservation: async (tripId, data) => {
try {
const result = await reservationRepo.create(tripId, data as Record<string, unknown>)
const result = await reservationsApi.create(tripId, data)
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
return result.reservation
} catch (err: unknown) {
@@ -37,7 +38,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
updateReservation: async (tripId, id, data) => {
try {
const result = await reservationRepo.update(tripId, id, data as Record<string, unknown>)
const result = await reservationsApi.update(tripId, id, data)
set(state => ({
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
}))
@@ -56,19 +57,17 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
}))
try {
await reservationRepo.update(tripId, id, { status: newStatus })
await reservationsApi.update(tripId, id, { status: newStatus })
} catch {
set({ reservations: prev })
}
},
deleteReservation: async (tripId, id) => {
const prev = get().reservations
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
try {
await reservationRepo.delete(tripId, id)
await reservationsApi.delete(tripId, id)
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
} catch (err: unknown) {
set({ reservations: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
}
},
+5 -5
View File
@@ -1,4 +1,4 @@
import { todoRepo } from '../../repo/todoRepo'
import { todoApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { TodoItem } from '../../types'
@@ -17,7 +17,7 @@ export interface TodoSlice {
export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
addTodoItem: async (tripId, data) => {
try {
const result = await todoRepo.create(tripId, data as Record<string, unknown>)
const result = await todoApi.create(tripId, data)
set(state => ({ todoItems: [...state.todoItems, result.item] }))
return result.item
} catch (err: unknown) {
@@ -27,7 +27,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
updateTodoItem: async (tripId, id, data) => {
try {
const result = await todoRepo.update(tripId, id, data as Record<string, unknown>)
const result = await todoApi.update(tripId, id, data)
set(state => ({
todoItems: state.todoItems.map(item => item.id === id ? result.item : item)
}))
@@ -41,7 +41,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
const prev = get().todoItems
set(state => ({ todoItems: state.todoItems.filter(item => item.id !== id) }))
try {
await todoRepo.delete(tripId, id)
await todoApi.delete(tripId, id)
} catch (err: unknown) {
set({ todoItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting todo'))
@@ -55,7 +55,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
)
}))
try {
await todoRepo.update(tripId, id, { checked })
await todoApi.update(tripId, id, { checked })
} catch {
set(state => ({
todoItems: state.todoItems.map(item =>
+24 -63
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import type { StoreApi } from 'zustand'
import { tagsApi, categoriesApi } from '../api/client'
import { offlineDb, upsertTags, upsertCategories } from '../db/offlineDb'
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
import { offlineDb } from '../db/offlineDb'
import { tripRepo } from '../repo/tripRepo'
import { dayRepo } from '../repo/dayRepo'
import { placeRepo } from '../repo/placeRepo'
@@ -89,38 +89,27 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
loadTrip: async (tripId: number | string) => {
set({ isLoading: true, error: null })
try {
// Fire tags/categories network refresh immediately — they're global (not trip-specific)
// and must be in-flight before the await below so MSW resolves them during the wait
const tagsRefresh = tagsApi.list()
.then(fresh => { upsertTags(fresh.tags).catch(() => {}); return fresh })
.catch(() => null)
const categoriesRefresh = categoriesApi.list()
.then(fresh => { upsertCategories(fresh.categories).catch(() => {}); return fresh })
.catch(() => null)
// All reads from IndexedDB — instant, no network wait
const [tripData, daysData, placesData, packingData, todoData, cachedTags, cachedCategories] = await Promise.all([
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
tripRepo.get(tripId),
dayRepo.list(tripId),
placeRepo.list(tripId),
packingRepo.list(tripId),
todoRepo.list(tripId),
offlineDb.tags.toArray(),
offlineDb.categories.toArray(),
navigator.onLine
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags })))
: offlineDb.tags.toArray().then(tags => ({ tags })),
navigator.onLine
? categoriesApi.list().catch(() => offlineDb.categories.toArray().then(categories => ({ categories })))
: offlineDb.categories.toArray().then(categories => ({ categories })),
])
const buildMaps = (days: Day[]) => {
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
return { assignmentsMap, dayNotesMap }
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
const { assignmentsMap, dayNotesMap } = buildMaps(daysData.days)
set({
trip: tripData.trip,
days: daysData.days,
@@ -129,36 +118,10 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
dayNotes: dayNotesMap,
packingItems: packingData.items,
todoItems: todoData.items,
tags: cachedTags,
categories: cachedCategories,
tags: tagsData.tags,
categories: categoriesData.categories,
isLoading: false,
})
// Apply background refreshes — update state when fresh data arrives
Promise.all([
tripData.refresh,
daysData.refresh,
placesData.refresh,
packingData.refresh,
todoData.refresh,
tagsRefresh,
categoriesRefresh,
]).then(([freshTrip, freshDays, freshPlaces, freshPacking, freshTodo, freshTags, freshCategories]) => {
const updates: Partial<TripStoreState> = {}
if (freshTrip) updates.trip = freshTrip.trip
if (freshDays) {
const { assignmentsMap: am, dayNotesMap: dm } = buildMaps(freshDays.days)
updates.days = freshDays.days
updates.assignments = am
updates.dayNotes = dm
}
if (freshPlaces) updates.places = freshPlaces.places
if (freshPacking) updates.packingItems = freshPacking.items
if (freshTodo) updates.todoItems = freshTodo.items
if (freshTags) updates.tags = freshTags.tags
if (freshCategories) updates.categories = freshCategories.categories
if (Object.keys(updates).length > 0) set(updates)
}).catch(() => {})
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error'
set({ isLoading: false, error: message })
@@ -183,18 +146,16 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
updateTrip: async (tripId: number | string, data: Partial<Trip>) => {
try {
const result = await tripRepo.update(tripId, data)
const result = await tripsApi.update(tripId, data)
set({ trip: result.trip })
if (navigator.onLine) {
const daysData = await dayRepo.list(tripId)
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
const daysData = await dayRepo.list(tripId)
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
return result.trip
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating trip'))
-161
View File
@@ -1,161 +0,0 @@
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core';
import {
precacheAndRoute,
cleanupOutdatedCaches,
matchPrecache,
} from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import {
DEFAULT_SW_CONFIG,
readSwConfigFromIDB,
validateSwConfig,
type SwCacheConfig,
} from './sync/swConfig';
declare const self: ServiceWorkerGlobalScope;
self.skipWaiting();
clientsClaim();
// Inject precache manifest (replaced by vite-plugin-pwa at build time)
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
// ── Static routes (not user-configurable) ─────────────────────────────────────
// Network-first navigations so reverse-proxy auth redirects (Cloudflare Zero
// Trust, Pangolin, etc.) reach the browser instead of being swallowed by the
// precached app shell. `redirect: 'manual'` produces an opaqueredirect Response
// which, per Fetch spec, the browser follows for navigation requests returned
// from FetchEvent.respondWith. Falls back to precached app shell offline.
registerRoute(
new NavigationRoute(
async ({ request }) => {
try {
return await fetch(request, { redirect: 'manual' });
} catch {
const cached = await matchPrecache('index.html');
return cached ?? Response.error();
}
},
{ denylist: [/^\/api/, /^\/uploads/, /^\/mcp/] },
),
);
registerRoute(
/^https:\/\/unpkg\.com\/.*/i,
new CacheFirst({
cacheName: 'cdn-libs',
plugins: [
new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 }),
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
}),
'GET',
);
registerRoute(
/\/uploads\/(?:covers|avatars)\/.*/i,
new CacheFirst({
cacheName: 'user-uploads',
plugins: [
new ExpirationPlugin({ maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 }),
new CacheableResponsePlugin({ statuses: [200] }),
],
}),
'GET',
);
// ── Configurable routes ────────────────────────────────────────────────────────
// Routes are registered once. Strategy instances are replaced on config change
// so the stable handler wrapper always delegates to the current instance.
const DAY = 24 * 60 * 60;
// Detects when an upstream reverse-proxy auth gate (Cloudflare Zero Trust,
// Pangolin, etc.) redirects a mid-session API call to an external SSO login
// page. Uses redirect:'manual' so the response stays as opaqueredirect instead
// of being silently followed; converts it to a 401 that the Axios interceptor
// in api/client.ts already handles (→ window.location.href = '/login').
const authRedirectPlugin = {
async requestWillFetch({ request }: { request: Request }): Promise<Request> {
return new Request(request, { redirect: 'manual' });
},
async fetchDidSucceed({ response }: { response: Response }): Promise<Response> {
if (response.type === 'opaqueredirect') {
return new Response(JSON.stringify({ code: 'AUTH_REQUIRED' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
return response;
},
};
function buildApiStrategy(cfg: SwCacheConfig): NetworkFirst {
return new NetworkFirst({
cacheName: 'api-data',
networkTimeoutSeconds: 2,
plugins: [
authRedirectPlugin,
new ExpirationPlugin({
maxEntries: cfg.apiMaxEntries,
maxAgeSeconds: cfg.apiTtlDays * DAY,
}),
new CacheableResponsePlugin({ statuses: [200] }),
],
});
}
function buildTilesStrategy(cfg: SwCacheConfig): CacheFirst {
return new CacheFirst({
cacheName: 'map-tiles',
plugins: [
new ExpirationPlugin({
maxEntries: cfg.tilesMaxEntries,
maxAgeSeconds: cfg.tilesTtlDays * DAY,
}),
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
});
}
let apiStrategy = buildApiStrategy(DEFAULT_SW_CONFIG);
let cartoStrategy = buildTilesStrategy(DEFAULT_SW_CONFIG);
let osmStrategy = buildTilesStrategy(DEFAULT_SW_CONFIG);
function applyConfig(cfg: SwCacheConfig): void {
apiStrategy = buildApiStrategy(cfg);
cartoStrategy = buildTilesStrategy(cfg);
osmStrategy = buildTilesStrategy(cfg);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerRoute(/\/api\/(?!auth|admin|backup|settings).*/i, { handle: (o: any) => apiStrategy.handle(o) }, 'GET');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerRoute(/^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i, { handle: (o: any) => cartoStrategy.handle(o) }, 'GET');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerRoute(/^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i, { handle: (o: any) => osmStrategy.handle(o) }, 'GET');
// Load persisted config asynchronously; replaces defaults if user has saved settings
readSwConfigFromIDB()
.then(cfg => { if (cfg) applyConfig(cfg); })
.catch(() => {});
// ── Message handler ────────────────────────────────────────────────────────────
self.addEventListener('message', (event: ExtendableMessageEvent) => {
const data = event.data as { type?: string; config?: unknown };
if (data?.type !== 'UPDATE_CACHE_CONFIG' || !data.config) return;
const validated = validateSwConfig(data.config as Partial<SwCacheConfig>);
applyConfig(validated);
// Acknowledge back to the sending client
(event.source as WindowClient | null)?.postMessage({ type: 'CACHE_CONFIG_APPLIED' });
});
+47
View File
@@ -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)
}
+11 -16
View File
@@ -13,15 +13,12 @@ import type { Table } from 'dexie'
// Map Dexie table names used in `resource` field → actual Dexie tables.
function getTable(resource: string): Table | undefined {
const map: Record<string, Table> = {
trips: offlineDb.trips,
days: offlineDb.days,
places: offlineDb.places,
packingItems: offlineDb.packingItems,
todoItems: offlineDb.todoItems,
budgetItems: offlineDb.budgetItems,
reservations: offlineDb.reservations,
accommodations: offlineDb.accommodations,
tripFiles: offlineDb.tripFiles,
places: offlineDb.places,
packingItems: offlineDb.packingItems,
todoItems: offlineDb.todoItems,
budgetItems: offlineDb.budgetItems,
reservations: offlineDb.reservations,
tripFiles: offlineDb.tripFiles,
}
return map[resource]
}
@@ -73,14 +70,12 @@ export const mutationQueue = {
if (_flushing || !navigator.onLine) return
_flushing = true
try {
while (true) {
const pending = await offlineDb.mutationQueue
.where('status')
.equals('pending')
.sortBy('createdAt')
const mutation = pending[0]
if (!mutation) break
const pending = await offlineDb.mutationQueue
.where('status')
.equals('pending')
.sortBy('createdAt')
for (const mutation of pending) {
// Mark as syncing so UI can show progress
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
-80
View File
@@ -1,80 +0,0 @@
/**
* SW cache configuration shared between the service worker and the main thread.
* Uses a dedicated 'trek-sw-config' IndexedDB database (separate from trek-offline)
* so the SW can read it without needing to know the full trek-offline schema versions.
*/
import Dexie, { type Table } from 'dexie';
export interface SwCacheConfig {
apiTtlDays: number;
apiMaxEntries: number;
tilesTtlDays: number;
tilesMaxEntries: number;
}
export const DEFAULT_SW_CONFIG: SwCacheConfig = {
apiTtlDays: 7,
apiMaxEntries: 500,
tilesTtlDays: 30,
tilesMaxEntries: 1000,
};
export const SW_CONFIG_BOUNDS = {
ttlMin: 1,
ttlMax: 365,
entriesMin: 10,
entriesMax: 5000,
};
export function validateSwConfig(raw: Partial<SwCacheConfig>): SwCacheConfig {
const clamp = (v: unknown, min: number, max: number, def: number): number => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.max(min, Math.min(max, Math.round(n))) : def;
};
return {
apiTtlDays: clamp(raw.apiTtlDays, SW_CONFIG_BOUNDS.ttlMin, SW_CONFIG_BOUNDS.ttlMax, DEFAULT_SW_CONFIG.apiTtlDays),
apiMaxEntries: clamp(raw.apiMaxEntries, SW_CONFIG_BOUNDS.entriesMin, SW_CONFIG_BOUNDS.entriesMax, DEFAULT_SW_CONFIG.apiMaxEntries),
tilesTtlDays: clamp(raw.tilesTtlDays, SW_CONFIG_BOUNDS.ttlMin, SW_CONFIG_BOUNDS.ttlMax, DEFAULT_SW_CONFIG.tilesTtlDays),
tilesMaxEntries:clamp(raw.tilesMaxEntries, SW_CONFIG_BOUNDS.entriesMin, SW_CONFIG_BOUNDS.entriesMax, DEFAULT_SW_CONFIG.tilesMaxEntries),
};
}
// ── Dedicated IDB for SW config ───────────────────────────────────────────────
interface SwConfigRow extends SwCacheConfig {
id: 'singleton';
updatedAt: number;
}
class SwConfigDb extends Dexie {
config!: Table<SwConfigRow, 'singleton'>;
constructor() {
super('trek-sw-config');
this.version(1).stores({ config: 'id' });
}
}
let _db: SwConfigDb | null = null;
function getDb(): SwConfigDb {
if (!_db) _db = new SwConfigDb();
return _db;
}
export async function readSwConfigFromIDB(): Promise<SwCacheConfig | null> {
try {
const row = await getDb().config.get('singleton');
return row ? validateSwConfig(row) : null;
} catch {
return null;
}
}
export async function saveSwConfig(cfg: SwCacheConfig): Promise<void> {
const validated = validateSwConfig(cfg);
await getDb().config.put({ id: 'singleton', ...validated, updatedAt: Date.now() });
}
export async function loadSwConfig(): Promise<SwCacheConfig> {
return (await readSwConfigFromIDB()) ?? { ...DEFAULT_SW_CONFIG };
}
+2 -1
View File
@@ -16,7 +16,7 @@ export interface User {
export interface Trip {
id: number
title: string
name: string
description: string | null
start_date: string
end_date: string
@@ -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[]
+17
View File
@@ -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))
}
+127
View File
@@ -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'])
})
})
+136
View File
@@ -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)
}
+20 -10
View File
@@ -66,28 +66,38 @@ describe('packingRepo.list', () => {
});
describe('packingRepo.create', () => {
it('writes item optimistically to Dexie immediately', async () => {
it('calls REST and caches created item in Dexie', async () => {
const item = buildPackingItem({ trip_id: 1, name: 'Sunscreen' });
server.use(
http.post('/api/trips/1/packing', () => HttpResponse.json({ item })),
);
const result = await packingRepo.create(1, { name: 'Sunscreen' });
expect(result.item.name).toBe('Sunscreen');
// tempId is negative (-(Date.now()))
expect(result.item.id).toBeLessThan(0);
const cached = await offlineDb.packingItems.where('trip_id').equals(1).toArray();
expect(cached).toHaveLength(1);
expect(cached[0].name).toBe('Sunscreen');
await new Promise(r => setTimeout(r, 0));
const cached = await offlineDb.packingItems.get(item.id);
expect(cached).toBeDefined();
expect(cached!.name).toBe('Sunscreen');
});
});
describe('packingRepo.update', () => {
it('writes optimistic update to Dexie immediately', async () => {
it('calls REST and updates Dexie cache', async () => {
const original = buildPackingItem({ trip_id: 1, name: 'Jacket', checked: 0 });
await offlineDb.packingItems.put(original);
const result = await packingRepo.update(1, original.id, { checked: true });
expect(result.item.checked).toBeTruthy();
const updated = { ...original, checked: 1 };
server.use(
http.put(`/api/trips/1/packing/${original.id}`, () => HttpResponse.json({ item: updated })),
);
const result = await packingRepo.update(1, original.id, { checked: true });
expect(result.item.checked).toBe(1);
await new Promise(r => setTimeout(r, 0));
const cached = await offlineDb.packingItems.get(original.id);
expect(cached!.checked).toBeTruthy();
expect(cached!.checked).toBe(1);
});
});
+10 -6
View File
@@ -67,15 +67,19 @@ describe('placeRepo.list', () => {
});
describe('placeRepo.create', () => {
it('writes place optimistically to Dexie immediately', async () => {
it('calls REST and caches created place in Dexie', async () => {
const place = buildPlace({ trip_id: 1, name: 'Eiffel Tower' });
server.use(
http.post('/api/trips/1/places', () => HttpResponse.json({ place })),
);
const result = await placeRepo.create(1, { name: 'Eiffel Tower' });
expect(result.place.name).toBe('Eiffel Tower');
// tempId is negative (-(Date.now()))
expect(result.place.id).toBeLessThan(0);
const cached = await offlineDb.places.where('trip_id').equals(1).toArray();
expect(cached).toHaveLength(1);
expect(cached[0].name).toBe('Eiffel Tower');
await new Promise(r => setTimeout(r, 0));
const cached = await offlineDb.places.get(place.id);
expect(cached).toBeDefined();
expect(cached!.name).toBe('Eiffel Tower');
});
});
+28 -21
View File
@@ -2,9 +2,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildBudgetItem } from '../../helpers/factories';
import { buildBudgetItem, buildReservation } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -18,9 +17,7 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
beforeEach(() => {
resetAllStores();
});
@@ -52,18 +49,16 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems).toHaveLength(2);
});
it('FE-BUDGET-003: addBudgetItem always adds item optimistically (no throw on API error)', async () => {
it('FE-BUDGET-003: addBudgetItem on failure throws', async () => {
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Fail' });
expect(result.name).toBe('Fail');
expect(useTripStore.getState().budgetItems).toHaveLength(1);
expect(useTripStore.getState().budgetItems[0].name).toBe('Fail');
await expect(
useTripStore.getState().addBudgetItem(1, { name: 'Fail' })
).rejects.toThrow();
});
});
@@ -85,26 +80,38 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems[0].name).toBe('Updated');
});
it('FE-BUDGET-005: updateBudgetItem resolves and updates store optimistically', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', amount: 100 });
seedStore(useTripStore, { budgetItems: [item] });
it('FE-BUDGET-005: updateBudgetItem with total_price triggers loadReservations when reservation_id present', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1, amount: 100 });
const initialReservation = buildReservation({ trip_id: 1 });
const newReservation = buildReservation({ trip_id: 1, name: 'Refreshed Reservation' });
seedStore(useTripStore, {
budgetItems: [item],
reservations: [initialReservation],
});
server.use(
http.put('/api/trips/1/budget/10', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
// Return item with reservation_id to trigger loadReservations
return HttpResponse.json({ item: { ...item, ...body, reservation_id: 42 } });
}),
http.get('/api/trips/1/reservations', () =>
HttpResponse.json({ reservations: [newReservation] })
),
);
const result = await useTripStore.getState().updateBudgetItem(1, 10, { amount: 200 } as Record<string, unknown>);
await useTripStore.getState().updateBudgetItem(1, 10, { total_price: 200 } as Record<string, unknown>);
expect(result.amount).toBe(200);
expect(useTripStore.getState().budgetItems[0].amount).toBe(200);
// Wait for the async loadReservations to complete
await new Promise(resolve => setTimeout(resolve, 50));
expect(useTripStore.getState().reservations).toHaveLength(1);
expect(useTripStore.getState().reservations[0].name).toBe('Refreshed Reservation');
});
});
describe('deleteBudgetItem', () => {
it('FE-BUDGET-006: deleteBudgetItem removes item permanently even on API error', async () => {
it('FE-BUDGET-006: deleteBudgetItem optimistically removes item, rolls back on failure', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [item] });
@@ -114,10 +121,10 @@ describe('budgetSlice', () => {
),
);
await useTripStore.getState().deleteBudgetItem(1, 10);
await expect(useTripStore.getState().deleteBudgetItem(1, 10)).rejects.toThrow();
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().budgetItems).toHaveLength(0);
expect(useTripStore.getState().budgetItems).toHaveLength(1);
expect(useTripStore.getState().budgetItems[0].id).toBe(10);
});
it('FE-BUDGET-006b: deleteBudgetItem success removes item', async () => {
+4 -4
View File
@@ -100,7 +100,7 @@ describe('filesSlice', () => {
expect(files[0].id).toBe(20);
});
it('FE-FILES-006: deleteFile removes file permanently even on API error', async () => {
it('FE-FILES-006: deleteFile on failure throws', async () => {
const file = buildTripFile({ id: 10, trip_id: 1 });
seedStore(useTripStore, { files: [file] });
@@ -110,10 +110,10 @@ describe('filesSlice', () => {
),
);
await useTripStore.getState().deleteFile(1, 10);
await expect(useTripStore.getState().deleteFile(1, 10)).rejects.toThrow();
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().files).toHaveLength(0);
// File remains since server-first (only removes after success)
expect(useTripStore.getState().files).toHaveLength(1);
});
});
});
+13 -16
View File
@@ -4,7 +4,6 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildPackingItem } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -18,9 +17,7 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
beforeEach(() => {
resetAllStores();
});
@@ -39,18 +36,16 @@ describe('packingSlice', () => {
expect(items[items.length - 1].name).toBe('Toothbrush');
});
it('FE-PACKING-002: addPackingItem always adds item optimistically (no throw on API error)', async () => {
it('FE-PACKING-002: addPackingItem on failure throws', async () => {
server.use(
http.post('/api/trips/1/packing', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
const result = await useTripStore.getState().addPackingItem(1, { name: 'Fail item' });
expect(result.name).toBe('Fail item');
expect(useTripStore.getState().packingItems).toHaveLength(1);
expect(useTripStore.getState().packingItems[0].name).toBe('Fail item');
await expect(
useTripStore.getState().addPackingItem(1, { name: 'Fail item' })
).rejects.toThrow();
});
});
@@ -74,7 +69,7 @@ describe('packingSlice', () => {
});
describe('deletePackingItem', () => {
it('FE-PACKING-004: deletePackingItem removes item permanently even on API error', async () => {
it('FE-PACKING-004: deletePackingItem optimistically removes item, rollback on failure', async () => {
const item = buildPackingItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { packingItems: [item] });
@@ -84,9 +79,10 @@ describe('packingSlice', () => {
),
);
await useTripStore.getState().deletePackingItem(1, 10);
await expect(useTripStore.getState().deletePackingItem(1, 10)).rejects.toThrow();
expect(useTripStore.getState().packingItems).toHaveLength(0);
expect(useTripStore.getState().packingItems).toHaveLength(1);
expect(useTripStore.getState().packingItems[0].id).toBe(10);
});
it('FE-PACKING-004b: deletePackingItem success removes item', async () => {
@@ -119,7 +115,7 @@ describe('packingSlice', () => {
expect(useTripStore.getState().packingItems[0].checked).toBe(1);
});
it('FE-PACKING-006: togglePackingItem preserves optimistic checked state even on API failure', async () => {
it('FE-PACKING-006: togglePackingItem rolls back checked on API failure', async () => {
const item = buildPackingItem({ id: 10, trip_id: 1, checked: 0 });
seedStore(useTripStore, { packingItems: [item] });
@@ -129,10 +125,11 @@ describe('packingSlice', () => {
),
);
// toggle does NOT throw on error (silent rollback)
await useTripStore.getState().togglePackingItem(1, 10, true);
// Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().packingItems[0].checked).toBe(1);
// Should be rolled back to original value
expect(useTripStore.getState().packingItems[0].checked).toBe(0);
});
});
});
+4 -10
View File
@@ -4,7 +4,6 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildPlace, buildAssignment } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -18,9 +17,7 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
beforeEach(() => {
resetAllStores();
});
@@ -38,7 +35,7 @@ describe('placesSlice', () => {
expect(places[0].name).toBe('New Place'); // prepended
});
it('FE-PLACES-002: addPlace always adds place optimistically (no throw on API error)', async () => {
it('FE-PLACES-002: addPlace on failure throws and places remain unchanged', async () => {
const existing = buildPlace({ trip_id: 1 });
seedStore(useTripStore, { places: [existing] });
@@ -48,11 +45,8 @@ describe('placesSlice', () => {
),
);
const result = await useTripStore.getState().addPlace(1, { name: 'Fail' });
expect(result.name).toBe('Fail');
expect(useTripStore.getState().places).toHaveLength(2);
expect(useTripStore.getState().places[0].name).toBe('Fail');
await expect(useTripStore.getState().addPlace(1, { name: 'Fail' })).rejects.toThrow();
expect(useTripStore.getState().places).toEqual([existing]);
});
});
@@ -4,7 +4,6 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildReservation } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -18,9 +17,7 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
beforeEach(() => {
resetAllStores();
});
@@ -61,18 +58,16 @@ describe('reservationsSlice', () => {
expect(reservations[0].name).toBe('New Hotel');
});
it('FE-RESERV-003: addReservation always adds optimistically (no throw on API error)', async () => {
it('FE-RESERV-003: addReservation on failure throws', async () => {
server.use(
http.post('/api/trips/1/reservations', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
const result = await useTripStore.getState().addReservation(1, { name: 'Fail' });
expect(result.name).toBe('Fail');
expect(useTripStore.getState().reservations).toHaveLength(1);
expect(useTripStore.getState().reservations[0].name).toBe('Fail');
await expect(
useTripStore.getState().addReservation(1, { name: 'Fail' })
).rejects.toThrow();
});
});
@@ -128,7 +123,7 @@ describe('reservationsSlice', () => {
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
});
it('FE-RESERV-007: toggleReservationStatus preserves optimistic status even on API failure', async () => {
it('FE-RESERV-007: toggleReservationStatus rolls back on API failure (silent)', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'confirmed' });
seedStore(useTripStore, { reservations: [reservation] });
@@ -138,10 +133,10 @@ describe('reservationsSlice', () => {
),
);
// Does NOT throw (silent rollback)
await useTripStore.getState().toggleReservationStatus(1, 10);
// Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().reservations[0].status).toBe('pending');
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
});
it('FE-RESERV-008: toggleReservationStatus does nothing if reservation not found', async () => {
@@ -167,7 +162,7 @@ describe('reservationsSlice', () => {
expect(reservations[0].id).toBe(20);
});
it('FE-RESERV-010: deleteReservation removes permanently even on API error', async () => {
it('FE-RESERV-010: deleteReservation on failure throws (no optimistic, server-first)', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1 });
seedStore(useTripStore, { reservations: [reservation] });
@@ -177,10 +172,10 @@ describe('reservationsSlice', () => {
),
);
await useTripStore.getState().deleteReservation(1, 10);
await expect(useTripStore.getState().deleteReservation(1, 10)).rejects.toThrow();
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().reservations).toHaveLength(0);
// Still in state since server-first (only removes after success)
expect(useTripStore.getState().reservations).toHaveLength(1);
});
});
});
+12 -16
View File
@@ -4,7 +4,6 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildTodoItem } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -18,9 +17,7 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
beforeEach(() => {
resetAllStores();
});
@@ -37,18 +34,16 @@ describe('todoSlice', () => {
expect(items).toHaveLength(2);
});
it('FE-TODO-002: addTodoItem always adds item optimistically (no throw on API error)', async () => {
it('FE-TODO-002: addTodoItem on failure throws', async () => {
server.use(
http.post('/api/trips/1/todo', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
const result = await useTripStore.getState().addTodoItem(1, { name: 'Fail' });
expect(result.name).toBe('Fail');
expect(useTripStore.getState().todoItems).toHaveLength(1);
expect(useTripStore.getState().todoItems[0].name).toBe('Fail');
await expect(
useTripStore.getState().addTodoItem(1, { name: 'Fail' })
).rejects.toThrow();
});
});
@@ -74,7 +69,7 @@ describe('todoSlice', () => {
});
describe('deleteTodoItem', () => {
it('FE-TODO-004: deleteTodoItem removes item permanently even on API error', async () => {
it('FE-TODO-004: deleteTodoItem optimistically removes item, rollback on failure', async () => {
const item = buildTodoItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { todoItems: [item] });
@@ -84,9 +79,10 @@ describe('todoSlice', () => {
),
);
await useTripStore.getState().deleteTodoItem(1, 10);
await expect(useTripStore.getState().deleteTodoItem(1, 10)).rejects.toThrow();
expect(useTripStore.getState().todoItems).toHaveLength(0);
expect(useTripStore.getState().todoItems).toHaveLength(1);
expect(useTripStore.getState().todoItems[0].id).toBe(10);
});
it('FE-TODO-004b: deleteTodoItem success removes item from array', async () => {
@@ -119,7 +115,7 @@ describe('todoSlice', () => {
expect(useTripStore.getState().todoItems[0].checked).toBe(1);
});
it('FE-TODO-006: toggleTodoItem preserves optimistic checked state even on API failure', async () => {
it('FE-TODO-006: toggleTodoItem rolls back checked on API failure (silent)', async () => {
const item = buildTodoItem({ id: 10, trip_id: 1, checked: 0 });
seedStore(useTripStore, { todoItems: [item] });
@@ -129,10 +125,10 @@ describe('todoSlice', () => {
),
);
// Does NOT throw
await useTripStore.getState().toggleTodoItem(1, 10, true);
// Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().todoItems[0].checked).toBe(1);
expect(useTripStore.getState().todoItems[0].checked).toBe(0);
});
it('FE-TODO-007: toggleTodoItem preserves sort_order field', async () => {
+3 -12
View File
@@ -4,7 +4,6 @@ import { useTripStore } from '../../src/store/tripStore';
import { resetAllStores } from '../helpers/store';
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories';
import { server } from '../helpers/msw/server';
import { offlineDb } from '../../src/db/offlineDb';
vi.mock('../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -18,11 +17,7 @@ vi.mock('../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(async () => {
// Flush pending macro tasks so any in-flight repo IIFEs from the previous test
// finish writing to IDB before we wipe it (prevents stale IDB data in next test).
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
beforeEach(() => {
resetAllStores();
});
@@ -80,10 +75,6 @@ describe('tripStore', () => {
const tag = buildTag();
const category = buildCategory();
// Seed IDB so tags/categories are available for the immediate IDB read in loadTrip
await offlineDb.tags.put(tag);
await offlineDb.categories.put(category);
server.use(
http.get('/api/trips/1', () => HttpResponse.json({ trip })),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
@@ -219,8 +210,8 @@ describe('tripStore', () => {
const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' });
expect(result.name).toBe('Updated Trip');
expect(useTripStore.getState().trip?.name).toBe('Updated Trip');
expect(result).toEqual(updatedTrip);
expect(useTripStore.getState().trip).toEqual(updatedTrip);
});
});
+82 -6
View File
@@ -7,12 +7,65 @@ export default defineConfig({
react(),
VitePWA({
registerType: 'autoUpdate',
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectManifest: {
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
workbox: {
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/, /^\/oauth\//, /^\/.well-known\//],
runtimeCaching: [
{
// Carto map tiles (default provider)
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// OpenStreetMap tiles (fallback / alternative)
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// Leaflet CSS/JS from unpkg CDN
urlPattern: /^https:\/\/unpkg\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'cdn-libs',
expiration: { maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// API calls — prefer network, fall back to cache
// Exclude sensitive endpoints (auth, admin, backup, settings)
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-data',
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
networkTimeoutSeconds: 5,
cacheableResponse: { statuses: [200] },
},
},
{
// Uploaded files (photos, covers — public assets only)
urlPattern: /\/uploads\/(?:covers|avatars)\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'user-uploads',
expiration: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 },
cacheableResponse: { statuses: [200] },
},
},
],
},
manifest: {
name: 'TREK \u2014 Travel Planner',
@@ -57,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,
},
}
}
})
+78 -1107
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "3.0.15",
"version": "3.0.20",
"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",
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-1
View File
@@ -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
View File
@@ -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

-15
View File
@@ -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

-33
View File
@@ -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>
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

-1
View File
@@ -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
View File
@@ -1 +0,0 @@
if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('/sw.js', { scope: '/' })})}
-1
View File
@@ -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} didnt 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
View File
@@ -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
View File
@@ -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

File diff suppressed because one or more lines are too long
+145 -11
View File
@@ -43,11 +43,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 +65,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 +95,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 +249,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 +276,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 +367,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');
@@ -380,4 +514,4 @@ export function createApp(): express.Application {
});
return app;
}
}
+31 -10
View File
@@ -19,6 +19,7 @@ const tmpDir = path.join(__dirname, '../data/tmp');
const app = createApp();
import * as scheduler from './scheduler';
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
const PORT = Number(process.env.PORT) || 3001;
const HOST = process.env.HOST;
@@ -29,22 +30,42 @@ const onListen = () => {
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 ${APP_VERSION}`,
...(HOST ? [` Host: ${HOST}`] : []),
` 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!');
+21 -20
View File
@@ -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();
}
}
+221
View File
@@ -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);
},
};
+9 -3
View File
@@ -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();
+35 -139
View File
@@ -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 });
});
});
+1 -1
View File
@@ -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();
+17 -2
View File
@@ -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,
+46 -8
View File
@@ -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 {
+2 -2
View File
@@ -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;

Some files were not shown because too many files have changed in this diff Show More