Compare commits

..

18 Commits

Author SHA1 Message Date
Julien G. 0629b375dd Merge 81a59edf03 into 640e5616e9 2026-05-05 14:28:09 +00:00
jubnl 81a59edf03 fix: surface app-config load failure so SSO button is never silently hidden
When /api/auth/app-config fails (ZT redirect, network blip) the login page
now shows a warning banner with a Refresh button instead of silently omitting
the SSO sign-in button. The SW also now applies authRedirectPlugin to this
endpoint so ZT opaque redirects are converted to 401 AUTH_REQUIRED rather
than causing a JSON parse failure that went undetected.

Translations added for all 15 supported languages.
2026-05-05 16:27:53 +02:00
jubnl a1f4643b90 docs: document ChatGPT MCP + Cloudflare Bot Fight Mode issue 2026-05-05 15:37:59 +02:00
jubnl 55ef0f3ca9 feat: add OIDC userinfo endpoint for ChatGPT domain claiming
ChatGPT enables OIDC when it finds /.well-known/openid-configuration
and uses the userinfo endpoint to fetch the authenticated user's email
for authorization domain claiming.

- Add GET /oauth/userinfo: validates Bearer token, returns sub/email/
  email_verified/preferred_username from the OAuth access token
- Add userinfo_endpoint to /.well-known/openid-configuration response
- Add /oauth/userinfo to open-CORS pre-middleware
2026-05-05 14:41:15 +02:00
jubnl 895f34deba refactor: extract getOAuthMetadata() shared by both discovery endpoints
Both /.well-known/oauth-authorization-server (via SDK router) and
/.well-known/openid-configuration now serve the same OAuthMetadata
object built once from a shared lazy getter.

The MCP spec explicitly states clients try OIDC Discovery or RFC 8414
depending on server support — ChatGPT uses OIDC Discovery first.
Serving the OAuth AS metadata at the OIDC URL is the correct approach;
clients only read the OAuth fields (authorization_endpoint,
token_endpoint, registration_endpoint) from it.
2026-05-05 14:32:59 +02:00
jubnl f9db7e1104 fix: serve OAuth AS metadata at /.well-known/openid-configuration
ChatGPT uses OIDC discovery to bootstrap the OAuth flow: it fetches
/.well-known/openid-configuration to find the registration_endpoint,
authorization_endpoint, and token_endpoint before attempting DCR.
Without this endpoint responding, it cannot proceed and reports
'does not implement OAuth'.

Serve the same AS metadata at the OIDC discovery URL so OIDC-first
clients can bootstrap the full OAuth 2.1 + DCR flow.
2026-05-05 14:25:05 +02:00
jubnl 001cc6431b fix: 404 JSON + open CORS for all /.well-known/* paths
ChatGPT probes /.well-known/openid-configuration and the RFC 8414
path-suffixed form /.well-known/oauth-authorization-server/mcp before
(or instead of) following the RFC 9728 WWW-Authenticate chain.
Both returned 200 HTML from the SPA catch-all, which clients can't
parse as JSON — ChatGPT reported 'does not implement OAuth'.

Two fixes:
- Extend open-CORS pre-middleware from /.well-known/oauth-* to all
  /.well-known/* so browser-based probes aren't CORS-blocked
- Add a 404 JSON catch-all for /.well-known/* paths the SDK metadata
  router doesn't handle, placed before the SPA catch-all
2026-05-05 14:16:28 +02:00
jubnl af10ab1c93 fix: add /mcp to open-CORS pre-middleware
External MCP clients (ChatGPT, Claude.ai, MCP Inspector) call /mcp
cross-origin with Bearer tokens. The OPTIONS preflight was hitting the
SPA catch-all because the global cors({ origin: false }) didn't add
Access-Control-Allow-Origin. Without a valid CORS response the browser
blocked the subsequent POST, preventing the 401 WWW-Authenticate header
from being read — ChatGPT reported 'does not implement OAuth'.
2026-05-05 13:57:52 +02:00
jubnl 8e14434a1b fix: thread resource indicator through OAuth consent flow
The consent page extracted client_id, redirect_uri, scope, state,
code_challenge from URL params but silently dropped `resource`. Without
it the auth code had no resource binding, tokens were issued with
audience=null, and the MCP handler's RFC 8707 audience check rejected
every token — "There was a problem connecting TREK."

Fix: extract `resource` from URLSearchParams and forward it through
oauthApi.validate() and oauthApi.authorize(). Add the field to both
API type signatures.
2026-05-05 13:48:43 +02:00
jubnl fb6eaaf06d fix: open CORS for OAuth register/authorize + correct WWW-Authenticate PRM path
Two follow-up fixes after the SDK auth migration:

1. CORS for browser-based OAuth clients (ChatGPT DCR 403)
   The global cors({ origin: false }) intercepts OPTIONS preflight for
   /oauth/register and /oauth/authorize before the SDK's own cors()
   middleware inside clientRegistrationHandler/authorizationHandler
   runs, causing the browser to reject the response with no
   Access-Control-Allow-Origin header. ChatGPT's connector makes DCR
   from the browser, so this manifested as a 403.
   Fix: extend the open-CORS pre-middleware to also cover
   /oauth/register and /oauth/authorize (same pattern as /.well-known).

2. WWW-Authenticate resource_metadata URL (RFC 9728 §5)
   The MCP handler was advertising the base PRM path
   (/.well-known/oauth-protected-resource) instead of the path-aware
   variant (/.well-known/oauth-protected-resource/mcp). RFC 9728
   requires the resource path to be appended when the resource URI has
   a path component. The SDK registers the path-aware URL; the
   WWW-Authenticate header now points to the same location.
2026-05-05 13:29:40 +02:00
jubnl 86129bbfbc feat: migrate OAuth public endpoints to MCP SDK auth handlers
Fixes issue #959 — two bugs causing ChatGPT's custom MCP connector to fail:

1. RFC 9728 path-based PRM: ChatGPT requests
   /.well-known/oauth-protected-resource/mcp (path-aware URL per RFC 9728
   §5). The old TREK handler only registered the base path; requests for
   the path variant fell through to the SPA catch-all and returned HTML.
   mcpAuthMetadataRouter registers the path-aware URL automatically.

2. DCR without scope: ChatGPT never sends scope during Dynamic Client
   Registration (RFC 7591 makes it optional). The old handler returned
   400 for missing scope. clientRegistrationHandler accepts it;
   trekClientsStore.registerClient defaults to ALL_SCOPES when absent,
   and the user still grants only what they approve at the consent UI
   (scopeSelectable=true for DCR clients is unchanged).

Hybrid approach: SDK handles /.well-known, /oauth/authorize (redirect to
consent SPA), and /oauth/register. TREK keeps its own /oauth/token and
/oauth/revoke because SDK clientAuth does plain-text secret comparison
while TREK uses SHA-256 hashing — incompatible without a full clientAuth
rewrite.

SPA consent page renamed /oauth/authorize → /oauth/consent to avoid
routing conflict with the SDK's backend authorize handler now mounted at
that path. Existing URL paths (/oauth/token etc.) are unchanged so
active Claude.ai connections are unaffected.

Other: lazy-init SDK metadata router so getAppUrl() (DB query) is not
called at createApp() time; path-aware mcpAddonGate so only /.well-known
returns 404 when MCP is disabled (previously a blanket middleware blocked
all routes including static files); /api/oauth mounted before the SDK
middleware chain so SPA-facing routes with their own 403 gates are
reached correctly.
2026-05-05 13:01:32 +02:00
jubnl 69620e7276 feat: always-optimistic write pattern across all repos
All create/update/delete repo methods now write to IndexedDB optimistically
and fire mutationQueue.flush() as fire-and-forget, returning immediately
without waiting for the network. This eliminates the 8-second UX freeze
previously seen when the API was unreachable but navigator.onLine was true.

- Repos rewritten: trip, day, place, packing, todo, budget, accommodation,
  reservation, file — write methods never throw, always return optimistic data
- mutationQueue.flush() changed to iterative (one item per loop iteration)
  so mutations enqueued mid-flush (e.g. bulk check-all) are picked up
- fileRepo.toggleStar skips the IDB put when the file is not cached locally
- DayDetailPanel passes place_name into accommodationRepo.create so the
  optimistic accommodation renders the correct hotel label immediately
- Test suite updated throughout to reflect optimistic-first semantics:
  no more rollback assertions, IDB cleared in component test beforeEach hooks,
  FileManager tests switched from filesApi spy to MSW endpoint assertions
2026-05-05 02:14:39 +02:00
jubnl 3aa6b0952a fix: repair test suite after SWR offline-read changes
Add navigator.onLine guard to SWR refresh IIFEs so background
network calls don't fire in offline mode (prevents fake-IDB leakage
in tests via MSW default handlers).

Fix IDB isolation in affected test files by flushing pending macro
tasks then clearing IDB tables in beforeEach, so stale IDB writes
from previous tests' background IIFEs don't bleed into the next test.

Restore loadBudgetItems and refreshPlaces to apply background refresh
results to store state.

Move tags/categories API calls before the main Promise.all in
loadTrip so MSW handlers resolve during the await window.
2026-05-05 01:01:34 +02:00
jubnl a6a0521261 docs: update Offline-Mode-and-PWA wiki for SWR changes 2026-05-04 22:45:42 +02:00
jubnl a83b369cb7 fix: stale-while-revalidate for offline reads + axios timeout
navigator.onLine is unreliable on Android — returns true whenever any
network interface is up, regardless of actual reachability. This caused
all repo reads to take the API branch and either wait 5 s for the SW
NetworkFirst timeout (cache hit) or hang indefinitely (cache miss).

- All read repos (list/get) now return cached IndexedDB data instantly
  and carry a background refresh promise that resolves to fresh data or
  null on failure. Callers that opted in (loadTrip, loadTrips) apply
  fresh data silently when it arrives.
- tripStore.loadTrip: Promise.all now reads all 7 resources from
  IndexedDB (instant), fires network refreshes in background, sets
  isLoading: false immediately, then applies fresh data via a second
  Promise.all when ready. Tags/categories use upsertTags/upsertCategories.
- DashboardPage.loadTrips: same pattern — renders from cache instantly,
  silently updates trip list on refresh.
- axios timeout set to 8 s so requests can never hang indefinitely.
- SW networkTimeoutSeconds lowered from 5 to 2 as defence in depth.
2026-05-04 22:43:10 +02:00
jubnl 79d5fa7670 chore: update lock file 2026-05-04 22:01:01 +02:00
jubnl b94060aec1 fix: pass through reverse-proxy auth redirects in service worker
Navigation requests now use redirect:'manual' + network-first so upstream
auth gates (Cloudflare Zero Trust, Pangolin) can redirect the browser to
their SSO login page instead of being swallowed by the precached app shell.

An authRedirectPlugin on the API NetworkFirst strategy handles mid-session
expiry: detects opaqueredirect responses and converts them to a synthetic
401 { code: 'AUTH_REQUIRED' } that the existing Axios interceptor picks up,
triggering a full re-auth flow.

Offline fallback to the precached app shell is preserved.

Closes #836
2026-05-04 21:54:54 +02:00
jubnl 852f0085d1 feat: complete offline write support with mutation queue + runtime SW cache config
- Add offline CRUD to todoRepo, budgetRepo, reservationRepo, accommodationRepo,
  dayRepo, tripRepo, fileRepo with optimistic Dexie writes and mutation queue
- Wire all store slices (todo, budget, reservations, files, dayNotes, assignments,
  tripStore) through repos for offline-aware writes
- Cover archive/unarchive, file toggleStar/update/delete, assignment create/delete,
  day title/notes update offline paths
- Migrate service worker from generateSW to injectManifest (custom sw.ts) with
  runtime-configurable api-data (7d/500) and map-tiles (30d/1000) cache policies
- Add Settings → Offline cache configuration UI with save/reset and live SW postMessage
- Extend mutationQueue flush to cover all writable Dexie tables
2026-05-04 21:36:44 +02:00
121 changed files with 4953 additions and 2590 deletions
-2
View File
@@ -3,8 +3,6 @@ node_modules/
# Build output
client/dist/
server/public/*
!server/public/.gitkeep
# Generated PWA icons (built from SVG via prebuild)
client/public/icons/*.png
-25
View File
@@ -1,25 +0,0 @@
#!/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.17
version: 3.0.15
description: Minimal Helm chart for TREK app
appVersion: "3.0.17"
appVersion: "3.0.15"
+11 -12
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "3.0.17",
"version": "3.0.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "3.0.17",
"version": "3.0.15",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
@@ -27,6 +27,12 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"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",
"zustand": "^4.5.2"
},
"devDependencies": {
@@ -6471,7 +6477,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"dev": true,
"license": "ISC"
},
"node_modules/indent-string": {
@@ -7538,9 +7543,9 @@
}
},
"node_modules/marked": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
"version": "18.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
"integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
@@ -12032,7 +12037,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz",
"integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
@@ -12042,14 +12046,12 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
"integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-expiration": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz",
"integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"idb": "^7.0.1",
@@ -12083,7 +12085,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz",
"integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0",
@@ -12120,7 +12121,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz",
"integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
@@ -12130,7 +12130,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz",
"integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "3.0.17",
"version": "3.0.15",
"private": true,
"type": "module",
"scripts": {
@@ -18,6 +18,12 @@
"@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",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
+62 -111
View File
@@ -1,6 +1,5 @@
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'
@@ -44,24 +43,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 {
@@ -70,84 +69,36 @@ export function isAuthPublicPath(pathname: string): boolean {
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
}
// 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
// Response interceptor - handle 401, 403 MFA, 429 rate limit
apiClient.interceptors.response.use(
(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)
}
}
(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)
}
// 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)
}
}
}
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)
}
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)
}
)
export const authApi = {
@@ -210,7 +161,7 @@ export const oauthApi = {
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),
},
@@ -267,11 +218,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 = {
@@ -365,7 +316,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),
@@ -374,7 +325,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),
@@ -439,7 +390,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),
@@ -495,7 +446,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 = {
@@ -581,21 +532,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,8 +10,11 @@ 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(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
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="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 }}>
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
<div style={{ width: 150 }}>
<CustomSelect
value={currency}
onChange={setCurrency}
@@ -730,7 +730,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
/>
</div>
{canEdit && (
<div className="max-md:!w-full" style={{ display: 'flex', gap: 6, width: 260 }}>
<div 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} /> <span className="hidden sm:inline">CSV</span>
<Download size={14} strokeWidth={2.5} /> CSV
</button>
</div>
</div>
@@ -35,6 +35,7 @@ vi.mock('../../api/client', async (importOriginal) => {
});
import { filesApi } from '../../api/client';
import { offlineDb } from '../../db/offlineDb';
const buildFile = (overrides = {}) => ({
id: 1,
@@ -66,7 +67,9 @@ const defaultProps = {
allowedFileTypes: null,
};
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
vi.clearAllMocks();
// Seed auth as admin so useCanDo() returns true for all permissions
@@ -130,15 +133,21 @@ describe('FileManager', () => {
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-005: star button calls filesApi.toggleStar', async () => {
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 });
}),
);
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);
expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1);
await waitFor(() => expect(starCalled).toBe(true));
});
it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
@@ -398,39 +407,47 @@ describe('FileManager', () => {
await screen.findByText('Hotel Paris');
});
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls filesApi.update', async () => {
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls file update endpoint', 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'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: 10 });
await waitFor(() => expect(capturedBody).toMatchObject({ place_id: 10 }));
});
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls file update endpoint', 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'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: 20 });
await waitFor(() => expect(capturedBody).toMatchObject({ reservation_id: 20 }));
});
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
@@ -507,39 +524,46 @@ describe('FileManager', () => {
await screen.findByText(/Colosseum/);
});
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls filesApi.update', async () => {
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls file update endpoint', 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');
// Clicking the linked place should unlink it
await user.click(screen.getByText('Venice Beach'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: null });
await waitFor(() => expect(capturedBody).toMatchObject({ place_id: null }));
});
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls file update endpoint', 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');
// Clicking the linked reservation should unlink it
await user.click(screen.getByText('Museum Pass'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: null });
await waitFor(() => expect(capturedBody).toMatchObject({ reservation_id: null }));
});
it('FE-COMP-FILEMANAGER-033: opening PDF preview and closing via backdrop', async () => {
+3 -2
View File
@@ -5,6 +5,7 @@ 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'
@@ -290,7 +291,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const handleStar = async (fileId: number) => {
try {
await filesApi.toggleStar(tripId, fileId)
await fileRepo.toggleStar(tripId, fileId)
refreshFiles()
} catch { /* */ }
}
@@ -409,7 +410,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 filesApi.update(tripId, fileId, data)
await fileRepo.update(tripId, fileId, data as Record<string, unknown>)
refreshFiles()
} catch {
toast.error(t('files.toast.assignError'))
@@ -9,8 +9,11 @@ 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(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
// Side-effect APIs PackingListPanel calls on mount
server.use(
@@ -11,6 +11,7 @@ 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' });
@@ -28,7 +29,9 @@ const defaultProps = {
onAccommodationChange: vi.fn(),
};
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
vi.clearAllMocks();
server.use(
@@ -5,6 +5,7 @@ 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'
@@ -117,8 +118,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const handleSaveAccommodation = async () => {
if (!hotelForm.place_id) return
try {
const data = await accommodationsApi.create(tripId, {
const selectedPlace = places.find(p => p.id === hotelForm.place_id)
const data = await accommodationRepo.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,
@@ -142,7 +145,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const updateAccommodationField = async (field, value) => {
if (!accommodation) return
try {
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
const data = await accommodationRepo.update(tripId, accommodation.id, { [field]: value || null })
setAccommodation(data.accommodation)
onAccommodationChange?.()
} catch {}
@@ -151,7 +154,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const handleRemoveAccommodation = async () => {
if (!accommodation) return
try {
await accommodationsApi.delete(tripId, accommodation.id)
await accommodationRepo.delete(tripId, accommodation.id)
const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated)
setDayAccommodations(updated.filter(a =>
@@ -583,7 +586,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<button onClick={async () => {
if (showHotelPicker === 'edit' && accommodation) {
// Update existing
await accommodationsApi.update(tripId, accommodation.id, {
await accommodationRepo.update(tripId, accommodation.id, {
place_id: hotelForm.place_id,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
+199 -15
View File
@@ -1,13 +1,21 @@
/**
* Offline settings tab shows cached trips, storage info, and controls
* to re-sync or clear the offline cache.
* to re-sync or clear the offline cache. Also exposes runtime SW cache config.
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Wifi, RefreshCw, Trash2, Database, Settings2, RotateCcw, CheckCircle } 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'
@@ -25,6 +33,12 @@ 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 {
@@ -53,6 +67,59 @@ 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 {
@@ -120,6 +187,86 @@ 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>
@@ -139,24 +286,32 @@ export default function OfflineTab(): React.ReactElement {
display: 'flex', flexDirection: 'column', gap: 2,
}}
>
<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)' }}>
<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 }}>
<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>
@@ -178,3 +333,32 @@ 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>
)
}
+2
View File
@@ -464,6 +464,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'تحقق',
'login.invalidInviteLink': 'رابط الدعوة غير صالح أو منتهي الصلاحية',
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
'login.configLoadError': 'تعذّر تحميل خيارات تسجيل الدخول.',
'login.configLoadRetry': 'تحديث',
'login.usernameRequired': 'اسم المستخدم مطلوب',
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
'login.forgotPassword': 'نسيت كلمة المرور؟',
+2
View File
@@ -459,6 +459,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Verificar',
'login.invalidInviteLink': 'Link de convite inválido ou expirado',
'login.oidcFailed': 'Falha no login OIDC',
'login.configLoadError': 'Não foi possível carregar as opções de login.',
'login.configLoadRetry': 'Atualizar',
'login.usernameRequired': 'Nome de usuário é obrigatório',
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
'login.forgotPassword': 'Esqueceu a senha?',
+2
View File
@@ -459,6 +459,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Ověřit',
'login.invalidInviteLink': 'Neplatný nebo vypršelý odkaz s pozvánkou',
'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo',
'login.configLoadError': 'Nepodařilo se načíst možnosti přihlášení.',
'login.configLoadRetry': 'Obnovit',
'login.usernameRequired': 'Uživatelské jméno je povinné',
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
'login.forgotPassword': 'Zapomenuté heslo?',
+2
View File
@@ -464,6 +464,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Bestätigen',
'login.invalidInviteLink': 'Ungültiger oder abgelaufener Einladungslink',
'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen',
'login.configLoadError': 'Anmeldeoptionen konnten nicht geladen werden.',
'login.configLoadRetry': 'Aktualisieren',
'login.usernameRequired': 'Benutzername ist erforderlich',
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
'login.forgotPassword': 'Passwort vergessen?',
+2
View File
@@ -537,6 +537,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Verify',
'login.invalidInviteLink': 'Invalid or expired invite link',
'login.oidcFailed': 'OIDC login failed',
'login.configLoadError': 'Could not load login options.',
'login.configLoadRetry': 'Refresh',
'login.usernameRequired': 'Username is required',
'login.passwordMinLength': 'Password must be at least 8 characters',
'login.forgotPassword': 'Forgot password?',
+2
View File
@@ -451,6 +451,8 @@ const es: Record<string, string> = {
'login.mfaVerify': 'Verificar',
'login.invalidInviteLink': 'Enlace de invitación inválido o expirado',
'login.oidcFailed': 'Error de inicio de sesión OIDC',
'login.configLoadError': 'No se pudieron cargar las opciones de inicio de sesión.',
'login.configLoadRetry': 'Actualizar',
'login.usernameRequired': 'El nombre de usuario es obligatorio',
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
'login.forgotPassword': '¿Olvidaste tu contraseña?',
+2
View File
@@ -452,6 +452,8 @@ const fr: Record<string, string> = {
'login.mfaVerify': 'Vérifier',
'login.invalidInviteLink': 'Lien d\'invitation invalide ou expiré',
'login.oidcFailed': 'Échec de connexion OIDC',
'login.configLoadError': 'Impossible de charger les options de connexion.',
'login.configLoadRetry': 'Actualiser',
'login.usernameRequired': 'Le nom d\'utilisateur est obligatoire',
'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères',
'login.forgotPassword': 'Mot de passe oublié ?',
+2
View File
@@ -459,6 +459,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Ellenőrzés',
'login.invalidInviteLink': 'Érvénytelen vagy lejárt meghívólink',
'login.oidcFailed': 'OIDC bejelentkezés sikertelen',
'login.configLoadError': 'A bejelentkezési lehetőségek betöltése nem sikerült.',
'login.configLoadRetry': 'Frissítés',
'login.usernameRequired': 'A felhasználónév kötelező',
'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
'login.forgotPassword': 'Elfelejtetted a jelszavad?',
+2
View File
@@ -521,6 +521,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Verifikasi',
'login.invalidInviteLink': 'Tautan undangan tidak valid atau sudah kedaluwarsa',
'login.oidcFailed': 'Login OIDC gagal',
'login.configLoadError': 'Gagal memuat opsi login.',
'login.configLoadRetry': 'Segarkan',
'login.usernameRequired': 'Nama pengguna wajib diisi',
'login.passwordMinLength': 'Kata sandi minimal 8 karakter',
'login.forgotPassword': 'Lupa kata sandi?',
+2
View File
@@ -459,6 +459,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Verifica',
'login.invalidInviteLink': 'Link di invito non valido o scaduto',
'login.oidcFailed': 'Accesso OIDC non riuscito',
'login.configLoadError': 'Impossibile caricare le opzioni di accesso.',
'login.configLoadRetry': 'Aggiorna',
'login.usernameRequired': 'Il nome utente è obbligatorio',
'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
'login.forgotPassword': 'Password dimenticata?',
+2
View File
@@ -452,6 +452,8 @@ const nl: Record<string, string> = {
'login.mfaVerify': 'Verifiëren',
'login.invalidInviteLink': 'Ongeldige of verlopen uitnodigingslink',
'login.oidcFailed': 'OIDC-aanmelding mislukt',
'login.configLoadError': 'Kan aanmeldingsopties niet laden.',
'login.configLoadRetry': 'Vernieuwen',
'login.usernameRequired': 'Gebruikersnaam is vereist',
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
'login.forgotPassword': 'Wachtwoord vergeten?',
+2
View File
@@ -426,6 +426,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Weryfikuj',
'login.invalidInviteLink': 'Nieprawidłowy lub wygasły link zaproszenia',
'login.oidcFailed': 'Logowanie OIDC nie powiodło się',
'login.configLoadError': 'Nie można załadować opcji logowania.',
'login.configLoadRetry': 'Odśwież',
'login.usernameRequired': 'Nazwa użytkownika jest wymagana',
'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
'login.forgotPassword': 'Nie pamiętasz hasła?',
+2
View File
@@ -452,6 +452,8 @@ const ru: Record<string, string> = {
'login.mfaVerify': 'Подтвердить',
'login.invalidInviteLink': 'Недействительная или истёкшая ссылка-приглашение',
'login.oidcFailed': 'Ошибка входа через OIDC',
'login.configLoadError': 'Не удалось загрузить параметры входа.',
'login.configLoadRetry': 'Обновить',
'login.usernameRequired': 'Имя пользователя обязательно',
'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
'login.forgotPassword': 'Забыли пароль?',
+2
View File
@@ -452,6 +452,8 @@ const zh: Record<string, string> = {
'login.mfaVerify': '验证',
'login.invalidInviteLink': '邀请链接无效或已过期',
'login.oidcFailed': 'OIDC 登录失败',
'login.configLoadError': '无法加载登录选项。',
'login.configLoadRetry': '刷新',
'login.usernameRequired': '用户名为必填项',
'login.passwordMinLength': '密码至少需要8个字符',
'login.forgotPassword': '忘记密码?',
+2
View File
@@ -511,6 +511,8 @@ const zhTw: Record<string, string> = {
'login.mfaVerify': '驗證',
'login.invalidInviteLink': '邀請連結無效或已過期',
'login.oidcFailed': 'OIDC 登入失敗',
'login.configLoadError': '無法載入登入選項。',
'login.configLoadRetry': '重新整理',
'login.usernameRequired': '使用者名稱為必填',
'login.passwordMinLength': '密碼至少需要8個字元',
'login.forgotPassword': '忘記密碼?',
-3
View File
@@ -3,9 +3,6 @@ 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>
+9 -4
View File
@@ -744,12 +744,17 @@ export default function DashboardPage(): React.ReactElement {
const loadTrips = async () => {
setIsLoading(true)
try {
const { trips, archivedTrips } = await tripRepo.list()
const { trips, archivedTrips, refresh } = 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)
}
}
@@ -791,7 +796,7 @@ export default function DashboardPage(): React.ReactElement {
const handleArchive = async (id) => {
try {
const data = await tripsApi.archive(id)
const data = await tripRepo.update(id, { is_archived: true })
setTrips(prev => prev.filter(t => t.id !== id))
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.archived'))
@@ -802,7 +807,7 @@ export default function DashboardPage(): React.ReactElement {
const handleUnarchive = async (id) => {
try {
const data = await tripsApi.unarchive(id)
const data = await tripRepo.update(id, { is_archived: false })
setArchivedTrips(prev => prev.filter(t => t.id !== id))
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.restored'))
+4 -1
View File
@@ -9,6 +9,7 @@ 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 }) =>
@@ -29,7 +30,9 @@ function renderFilesPage(tripId: number | string = 1) {
);
}
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
vi.clearAllMocks();
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
@@ -60,9 +60,9 @@ 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' })
),
);
});
@@ -73,8 +73,8 @@ describe('LoginPage — OIDC redirect preservation', () => {
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
'/oauth/consent?client_id=foo&state=xyz',
{ replace: true },
'/oauth/consent?client_id=foo&state=xyz',
{ replace: true },
);
});
@@ -102,4 +102,4 @@ describe('LoginPage — OIDC redirect preservation', () => {
});
});
});
});
});
+20 -19
View File
@@ -33,6 +33,7 @@ export default function LoginPage(): React.ReactElement {
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const [configError, setConfigError] = useState<boolean>(false)
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
const exchangeInitiated = useRef(false)
@@ -117,29 +118,15 @@ export default function LoginPage(): React.ReactElement {
return
}
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'
}
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'
}
})
.catch(() => setConfigError(true))
}, [navigate, t, noRedirect])
// Language detection chain (runs once on mount, only if user has no saved preference):
@@ -874,6 +861,20 @@ export default function LoginPage(): React.ReactElement {
</>
)}
{/* Config load error shown when /api/auth/app-config fails (e.g. ZT redirect,
network blip). Hides the SSO button; prompt user to refresh. */}
{configError && !appConfig && (
<div style={{ marginTop: 16, padding: '10px 14px', background: '#fef3c7', border: '1px solid #fde68a', borderRadius: 12, fontSize: 13, color: '#92400e', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span>{t('login.configLoadError')}</span>
<button
onClick={() => window.location.reload()}
style={{ background: 'none', border: '1px solid #d97706', borderRadius: 8, padding: '4px 10px', fontSize: 12, fontWeight: 600, color: '#92400e', cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}
>
{t('login.configLoadRetry')}
</button>
</div>
)}
{/* Demo login button */}
{appConfig?.demo_mode && (
<button onClick={handleDemoLogin} disabled={isLoading}
+169 -169
View File
@@ -44,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() {
@@ -114,15 +114,15 @@ 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])]
)
}
@@ -148,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>
<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>
)
}
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>
<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>
)
}
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>
<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 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>
</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>
{/* 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 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.
<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>
<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>
<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>
{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)' }}>
{/* 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>
</label>
)
})}
<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>
)
})}
</label>
)
})}
</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)' }}>
)
})}
</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 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>
)}
{/* 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>
)}
{/* 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>
)
}
}
+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', paddingBottom: 'var(--bottom-nav-h)' }}>
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
<FileManager
files={files || []}
onUpload={(fd) => tripActions.addFile(tripId, fd)}
+82 -9
View File
@@ -1,16 +1,89 @@
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[] }> {
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
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 }
},
}
+79 -11
View File
@@ -1,18 +1,86 @@
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[] }> {
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
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 }
},
}
+39 -11
View File
@@ -1,18 +1,46 @@
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[] }> {
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
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 }
},
}
+69 -10
View File
@@ -1,18 +1,77 @@
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[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.tripFiles
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { files: cached }
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 })
}
const result = await filesApi.list(tripId)
upsertTripFiles(result.files)
return result
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 }
},
}
+67 -71
View File
@@ -4,85 +4,81 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { PackingItem } from '../types'
export const packingRepo = {
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 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 create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
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
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 }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
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
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 }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
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
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 }
},
}
+75 -84
View File
@@ -4,106 +4,97 @@ 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[] }> {
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 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 create(tripId: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
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
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 }
},
async update(tripId: number | string, id: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
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
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 }
},
async delete(tripId: number | string, id: number | string): Promise<unknown> {
if (!navigator.onLine) {
await offlineDb.places.delete(Number(id))
const mutId = generateUUID()
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) {
await mutationQueue.enqueue({
id: mutId,
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: Number(id),
entityId: id,
})
return { success: true }
}
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
mutationQueue.flush().catch(() => {})
return { deleted: ids, count: ids.length }
},
}
+84 -11
View File
@@ -1,18 +1,91 @@
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[] }> {
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
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 }
},
}
+82 -11
View File
@@ -1,18 +1,89 @@
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[] }> {
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
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 }
},
}
+63 -19
View File
@@ -1,33 +1,77 @@
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[] }> {
if (!navigator.onLine) {
const all = await offlineDb.trips.toArray()
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) {
return {
trips: all.filter(t => !t.is_archived),
archivedTrips: all.filter(t => t.is_archived),
refresh,
}
}
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 }
const fresh = await refresh
if (!fresh) return { trips: [], archivedTrips: [], refresh: Promise.resolve(null) }
return { ...fresh, refresh: Promise.resolve(fresh) }
},
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
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 }
},
}
@@ -1,4 +1,6 @@
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'
@@ -40,6 +42,23 @@ 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 = {
@@ -99,6 +118,24 @@ 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) {
+24 -23
View File
@@ -4,8 +4,11 @@ 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(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
server.resetHandlers();
});
@@ -34,25 +37,28 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems).toEqual([]);
});
it('FE-STORE-BUDGET-003: addBudgetItem appends to store and returns item', async () => {
const newItem = buildBudgetItem({ name: 'Hotel', trip_id: 1 });
it('FE-STORE-BUDGET-003: addBudgetItem appends to store optimistically', async () => {
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ item: newItem })
HttpResponse.json({ item: buildBudgetItem({ name: 'Hotel', trip_id: 1 }) })
)
);
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel' });
expect(result.id).toBe(newItem.id);
expect(useTripStore.getState().budgetItems).toContainEqual(newItem);
expect(result.name).toBe('Hotel');
const items = useTripStore.getState().budgetItems;
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Hotel');
});
it('FE-STORE-BUDGET-004: addBudgetItem throws on API error', async () => {
it('FE-STORE-BUDGET-004: addBudgetItem adds item optimistically even on API error', async () => {
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
)
);
await expect(useTripStore.getState().addBudgetItem(1, {})).rejects.toThrow();
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Item' });
expect(result.name).toBe('Item');
expect(useTripStore.getState().budgetItems).toHaveLength(1);
});
it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
@@ -71,24 +77,21 @@ describe('budgetSlice', () => {
expect(items[0].name).toBe('New');
});
it('FE-STORE-BUDGET-006: updateBudgetItem calls loadReservations when reservation_id + total_price provided', async () => {
const existing = buildBudgetItem({ id: 20, trip_id: 1 });
it('FE-STORE-BUDGET-006: updateBudgetItem resolves and updates store optimistically', async () => {
const existing = buildBudgetItem({ id: 20, trip_id: 1, amount: 100 });
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: itemWithReservation })
HttpResponse.json({ item: { ...existing, amount: 50 } })
)
);
await useTripStore.getState().updateBudgetItem(1, 20, { total_price: 50 });
expect(loadReservations).toHaveBeenCalledWith(1);
const result = await useTripStore.getState().updateBudgetItem(1, 20, { amount: 50 });
expect(result.amount).toBe(50);
expect(useTripStore.getState().budgetItems[0].amount).toBe(50);
});
it('FE-STORE-BUDGET-007: deleteBudgetItem optimistically removes and rolls back on error', async () => {
it('FE-STORE-BUDGET-007: deleteBudgetItem removes item permanently even on API error', async () => {
const item = buildBudgetItem({ id: 5, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [item] });
@@ -97,11 +100,9 @@ describe('budgetSlice', () => {
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
);
// 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);
await useTripStore.getState().deleteBudgetItem(1, 5);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().budgetItems).toHaveLength(0);
});
it('FE-STORE-BUDGET-008: setBudgetItemMembers updates members on matching item', async () => {
+6 -3
View File
@@ -24,6 +24,9 @@ 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)
}
@@ -31,7 +34,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
addBudgetItem: async (tripId, data) => {
try {
const result = await budgetApi.create(tripId, data)
const result = await budgetRepo.create(tripId, data as Record<string, unknown>)
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item
} catch (err: unknown) {
@@ -41,7 +44,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
updateBudgetItem: async (tripId, id, data) => {
try {
const result = await budgetApi.update(tripId, id, data)
const result = await budgetRepo.update(tripId, id, data as Record<string, unknown>)
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
@@ -58,7 +61,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 budgetApi.delete(tripId, id)
await budgetRepo.delete(tripId, id)
} catch (err: unknown) {
set({ budgetItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting budget item'))
+67 -3
View File
@@ -1,4 +1,7 @@
import { daysApi, dayNotesApi } from '../../api/client'
import { dayNotesApi } from '../../api/client'
import { offlineDb } from '../../db/offlineDb'
import { dayRepo } from '../../repo/dayRepo'
import { mutationQueue, generateUUID } from '../../sync/mutationQueue'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { DayNote } from '../../types'
@@ -19,7 +22,7 @@ export interface DayNotesSlice {
export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({
updateDayNotes: async (tripId, dayId, notes) => {
try {
await daysApi.update(tripId, dayId, { notes })
await dayRepo.update(tripId, dayId, { notes })
set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, notes } : d)
}))
@@ -30,7 +33,7 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
updateDayTitle: async (tripId, dayId, title) => {
try {
await daysApi.update(tripId, dayId, { title })
await dayRepo.update(tripId, dayId, { title })
set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, title } : d)
}))
@@ -48,6 +51,22 @@ 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 => ({
@@ -69,6 +88,32 @@ 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 => ({
@@ -91,6 +136,25 @@ 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) {
+4 -2
View File
@@ -35,10 +35,12 @@ 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 filesApi.delete(tripId, id)
set(state => ({ files: state.files.filter(f => f.id !== id) }))
await fileRepo.delete(tripId, id)
} catch (err: unknown) {
set({ files: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting file'))
}
},
+3
View File
@@ -20,6 +20,9 @@ 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)
}
+7 -6
View File
@@ -1,4 +1,3 @@
import { reservationsApi } from '../../api/client'
import { reservationRepo } from '../../repo/reservationRepo'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
@@ -28,7 +27,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
addReservation: async (tripId, data) => {
try {
const result = await reservationsApi.create(tripId, data)
const result = await reservationRepo.create(tripId, data as Record<string, unknown>)
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
return result.reservation
} catch (err: unknown) {
@@ -38,7 +37,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
updateReservation: async (tripId, id, data) => {
try {
const result = await reservationsApi.update(tripId, id, data)
const result = await reservationRepo.update(tripId, id, data as Record<string, unknown>)
set(state => ({
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
}))
@@ -57,17 +56,19 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
}))
try {
await reservationsApi.update(tripId, id, { status: newStatus })
await reservationRepo.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 reservationsApi.delete(tripId, id)
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
await reservationRepo.delete(tripId, id)
} catch (err: unknown) {
set({ reservations: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
}
},
+5 -5
View File
@@ -1,4 +1,4 @@
import { todoApi } from '../../api/client'
import { todoRepo } from '../../repo/todoRepo'
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 todoApi.create(tripId, data)
const result = await todoRepo.create(tripId, data as Record<string, unknown>)
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 todoApi.update(tripId, id, data)
const result = await todoRepo.update(tripId, id, data as Record<string, unknown>)
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 todoApi.delete(tripId, id)
await todoRepo.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 todoApi.update(tripId, id, { checked })
await todoRepo.update(tripId, id, { checked })
} catch {
set(state => ({
todoItems: state.todoItems.map(item =>
+63 -24
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import type { StoreApi } from 'zustand'
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
import { offlineDb } from '../db/offlineDb'
import { tagsApi, categoriesApi } from '../api/client'
import { offlineDb, upsertTags, upsertCategories } from '../db/offlineDb'
import { tripRepo } from '../repo/tripRepo'
import { dayRepo } from '../repo/dayRepo'
import { placeRepo } from '../repo/placeRepo'
@@ -89,27 +89,38 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
loadTrip: async (tripId: number | string) => {
set({ isLoading: true, error: null })
try {
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
// 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([
tripRepo.get(tripId),
dayRepo.list(tripId),
placeRepo.list(tripId),
packingRepo.list(tripId),
todoRepo.list(tripId),
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 })),
offlineDb.tags.toArray(),
offlineDb.categories.toArray(),
])
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 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, dayNotesMap } = buildMaps(daysData.days)
set({
trip: tripData.trip,
days: daysData.days,
@@ -118,10 +129,36 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
dayNotes: dayNotesMap,
packingItems: packingData.items,
todoItems: todoData.items,
tags: tagsData.tags,
categories: categoriesData.categories,
tags: cachedTags,
categories: cachedCategories,
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 })
@@ -146,16 +183,18 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
updateTrip: async (tripId: number | string, data: Partial<Trip>) => {
try {
const result = await tripsApi.update(tripId, data)
const result = await tripRepo.update(tripId, data)
set({ trip: result.trip })
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 || []
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 })
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
return result.trip
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating trip'))
+170
View File
@@ -0,0 +1,170 @@
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core';
import {
precacheAndRoute,
cleanupOutdatedCaches,
matchPrecache,
} from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst, NetworkOnly } 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);
}
// Apply authRedirectPlugin to the public app-config endpoint so a ZT redirect
// surfaces as AUTH_REQUIRED (401) instead of causing a silent JSON parse failure
// on the login page, which would hide the SSO button.
registerRoute(
/\/api\/auth\/app-config$/i,
new NetworkOnly({ plugins: [authRedirectPlugin] }),
'GET',
);
// 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
@@ -1,47 +0,0 @@
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)
}
+16 -11
View File
@@ -13,12 +13,15 @@ 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> = {
places: offlineDb.places,
packingItems: offlineDb.packingItems,
todoItems: offlineDb.todoItems,
budgetItems: offlineDb.budgetItems,
reservations: offlineDb.reservations,
tripFiles: offlineDb.tripFiles,
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,
}
return map[resource]
}
@@ -70,12 +73,14 @@ export const mutationQueue = {
if (_flushing || !navigator.onLine) return
_flushing = true
try {
const pending = await offlineDb.mutationQueue
.where('status')
.equals('pending')
.sortBy('createdAt')
while (true) {
const pending = await offlineDb.mutationQueue
.where('status')
.equals('pending')
.sortBy('createdAt')
const mutation = pending[0]
if (!mutation) break
for (const mutation of pending) {
// Mark as syncing so UI can show progress
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
+80
View File
@@ -0,0 +1,80 @@
/**
* 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 };
}
+1 -1
View File
@@ -16,7 +16,7 @@ export interface User {
export interface Trip {
id: number
name: string
title: string
description: string | null
start_date: string
end_date: string
+9 -19
View File
@@ -66,38 +66,28 @@ describe('packingRepo.list', () => {
});
describe('packingRepo.create', () => {
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 })),
);
it('writes item optimistically to Dexie immediately', async () => {
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);
await new Promise(r => setTimeout(r, 0));
const cached = await offlineDb.packingItems.get(item.id);
expect(cached).toBeDefined();
expect(cached!.name).toBe('Sunscreen');
const cached = await offlineDb.packingItems.where('trip_id').equals(1).toArray();
expect(cached).toHaveLength(1);
expect(cached[0].name).toBe('Sunscreen');
});
});
describe('packingRepo.update', () => {
it('calls REST and updates Dexie cache', async () => {
it('writes optimistic update to Dexie immediately', async () => {
const original = buildPackingItem({ trip_id: 1, name: 'Jacket', checked: 0 });
await offlineDb.packingItems.put(original);
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);
expect(result.item.checked).toBeTruthy();
await new Promise(r => setTimeout(r, 0));
const cached = await offlineDb.packingItems.get(original.id);
expect(cached!.checked).toBe(1);
expect(cached!.checked).toBeTruthy();
});
});
+6 -10
View File
@@ -67,19 +67,15 @@ describe('placeRepo.list', () => {
});
describe('placeRepo.create', () => {
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 })),
);
it('writes place optimistically to Dexie immediately', async () => {
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);
await new Promise(r => setTimeout(r, 0));
const cached = await offlineDb.places.get(place.id);
expect(cached).toBeDefined();
expect(cached!.name).toBe('Eiffel Tower');
const cached = await offlineDb.places.where('trip_id').equals(1).toArray();
expect(cached).toHaveLength(1);
expect(cached[0].name).toBe('Eiffel Tower');
});
});
+21 -28
View File
@@ -2,8 +2,9 @@ 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, buildReservation } from '../../helpers/factories';
import { buildBudgetItem } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -49,16 +52,18 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems).toHaveLength(2);
});
it('FE-BUDGET-003: addBudgetItem on failure throws', async () => {
it('FE-BUDGET-003: addBudgetItem always adds item optimistically (no throw on API error)', async () => {
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addBudgetItem(1, { name: 'Fail' })
).rejects.toThrow();
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');
});
});
@@ -80,38 +85,26 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems[0].name).toBe('Updated');
});
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],
});
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] });
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] })
),
);
await useTripStore.getState().updateBudgetItem(1, 10, { total_price: 200 } as Record<string, unknown>);
const result = await useTripStore.getState().updateBudgetItem(1, 10, { amount: 200 } as Record<string, unknown>);
// 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');
expect(result.amount).toBe(200);
expect(useTripStore.getState().budgetItems[0].amount).toBe(200);
});
});
describe('deleteBudgetItem', () => {
it('FE-BUDGET-006: deleteBudgetItem optimistically removes item, rolls back on failure', async () => {
it('FE-BUDGET-006: deleteBudgetItem removes item permanently even on API error', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [item] });
@@ -121,10 +114,10 @@ describe('budgetSlice', () => {
),
);
await expect(useTripStore.getState().deleteBudgetItem(1, 10)).rejects.toThrow();
await useTripStore.getState().deleteBudgetItem(1, 10);
expect(useTripStore.getState().budgetItems).toHaveLength(1);
expect(useTripStore.getState().budgetItems[0].id).toBe(10);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().budgetItems).toHaveLength(0);
});
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 on failure throws', async () => {
it('FE-FILES-006: deleteFile removes file permanently even on API error', async () => {
const file = buildTripFile({ id: 10, trip_id: 1 });
seedStore(useTripStore, { files: [file] });
@@ -110,10 +110,10 @@ describe('filesSlice', () => {
),
);
await expect(useTripStore.getState().deleteFile(1, 10)).rejects.toThrow();
await useTripStore.getState().deleteFile(1, 10);
// File remains since server-first (only removes after success)
expect(useTripStore.getState().files).toHaveLength(1);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().files).toHaveLength(0);
});
});
});
+16 -13
View File
@@ -4,6 +4,7 @@ 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(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -36,16 +39,18 @@ describe('packingSlice', () => {
expect(items[items.length - 1].name).toBe('Toothbrush');
});
it('FE-PACKING-002: addPackingItem on failure throws', async () => {
it('FE-PACKING-002: addPackingItem always adds item optimistically (no throw on API error)', async () => {
server.use(
http.post('/api/trips/1/packing', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addPackingItem(1, { name: 'Fail item' })
).rejects.toThrow();
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');
});
});
@@ -69,7 +74,7 @@ describe('packingSlice', () => {
});
describe('deletePackingItem', () => {
it('FE-PACKING-004: deletePackingItem optimistically removes item, rollback on failure', async () => {
it('FE-PACKING-004: deletePackingItem removes item permanently even on API error', async () => {
const item = buildPackingItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { packingItems: [item] });
@@ -79,10 +84,9 @@ describe('packingSlice', () => {
),
);
await expect(useTripStore.getState().deletePackingItem(1, 10)).rejects.toThrow();
await useTripStore.getState().deletePackingItem(1, 10);
expect(useTripStore.getState().packingItems).toHaveLength(1);
expect(useTripStore.getState().packingItems[0].id).toBe(10);
expect(useTripStore.getState().packingItems).toHaveLength(0);
});
it('FE-PACKING-004b: deletePackingItem success removes item', async () => {
@@ -115,7 +119,7 @@ describe('packingSlice', () => {
expect(useTripStore.getState().packingItems[0].checked).toBe(1);
});
it('FE-PACKING-006: togglePackingItem rolls back checked on API failure', async () => {
it('FE-PACKING-006: togglePackingItem preserves optimistic checked state even on API failure', async () => {
const item = buildPackingItem({ id: 10, trip_id: 1, checked: 0 });
seedStore(useTripStore, { packingItems: [item] });
@@ -125,11 +129,10 @@ describe('packingSlice', () => {
),
);
// toggle does NOT throw on error (silent rollback)
await useTripStore.getState().togglePackingItem(1, 10, true);
// Should be rolled back to original value
expect(useTripStore.getState().packingItems[0].checked).toBe(0);
// Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().packingItems[0].checked).toBe(1);
});
});
});
+10 -4
View File
@@ -4,6 +4,7 @@ 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(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -35,7 +38,7 @@ describe('placesSlice', () => {
expect(places[0].name).toBe('New Place'); // prepended
});
it('FE-PLACES-002: addPlace on failure throws and places remain unchanged', async () => {
it('FE-PLACES-002: addPlace always adds place optimistically (no throw on API error)', async () => {
const existing = buildPlace({ trip_id: 1 });
seedStore(useTripStore, { places: [existing] });
@@ -45,8 +48,11 @@ describe('placesSlice', () => {
),
);
await expect(useTripStore.getState().addPlace(1, { name: 'Fail' })).rejects.toThrow();
expect(useTripStore.getState().places).toEqual([existing]);
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');
});
});
@@ -4,6 +4,7 @@ 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(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -58,16 +61,18 @@ describe('reservationsSlice', () => {
expect(reservations[0].name).toBe('New Hotel');
});
it('FE-RESERV-003: addReservation on failure throws', async () => {
it('FE-RESERV-003: addReservation always adds optimistically (no throw on API error)', async () => {
server.use(
http.post('/api/trips/1/reservations', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addReservation(1, { name: 'Fail' })
).rejects.toThrow();
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');
});
});
@@ -123,7 +128,7 @@ describe('reservationsSlice', () => {
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
});
it('FE-RESERV-007: toggleReservationStatus rolls back on API failure (silent)', async () => {
it('FE-RESERV-007: toggleReservationStatus preserves optimistic status even on API failure', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'confirmed' });
seedStore(useTripStore, { reservations: [reservation] });
@@ -133,10 +138,10 @@ describe('reservationsSlice', () => {
),
);
// Does NOT throw (silent rollback)
await useTripStore.getState().toggleReservationStatus(1, 10);
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
// Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().reservations[0].status).toBe('pending');
});
it('FE-RESERV-008: toggleReservationStatus does nothing if reservation not found', async () => {
@@ -162,7 +167,7 @@ describe('reservationsSlice', () => {
expect(reservations[0].id).toBe(20);
});
it('FE-RESERV-010: deleteReservation on failure throws (no optimistic, server-first)', async () => {
it('FE-RESERV-010: deleteReservation removes permanently even on API error', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1 });
seedStore(useTripStore, { reservations: [reservation] });
@@ -172,10 +177,10 @@ describe('reservationsSlice', () => {
),
);
await expect(useTripStore.getState().deleteReservation(1, 10)).rejects.toThrow();
await useTripStore.getState().deleteReservation(1, 10);
// Still in state since server-first (only removes after success)
expect(useTripStore.getState().reservations).toHaveLength(1);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().reservations).toHaveLength(0);
});
});
});
+16 -12
View File
@@ -4,6 +4,7 @@ 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(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -34,16 +37,18 @@ describe('todoSlice', () => {
expect(items).toHaveLength(2);
});
it('FE-TODO-002: addTodoItem on failure throws', async () => {
it('FE-TODO-002: addTodoItem always adds item optimistically (no throw on API error)', async () => {
server.use(
http.post('/api/trips/1/todo', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addTodoItem(1, { name: 'Fail' })
).rejects.toThrow();
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');
});
});
@@ -69,7 +74,7 @@ describe('todoSlice', () => {
});
describe('deleteTodoItem', () => {
it('FE-TODO-004: deleteTodoItem optimistically removes item, rollback on failure', async () => {
it('FE-TODO-004: deleteTodoItem removes item permanently even on API error', async () => {
const item = buildTodoItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { todoItems: [item] });
@@ -79,10 +84,9 @@ describe('todoSlice', () => {
),
);
await expect(useTripStore.getState().deleteTodoItem(1, 10)).rejects.toThrow();
await useTripStore.getState().deleteTodoItem(1, 10);
expect(useTripStore.getState().todoItems).toHaveLength(1);
expect(useTripStore.getState().todoItems[0].id).toBe(10);
expect(useTripStore.getState().todoItems).toHaveLength(0);
});
it('FE-TODO-004b: deleteTodoItem success removes item from array', async () => {
@@ -115,7 +119,7 @@ describe('todoSlice', () => {
expect(useTripStore.getState().todoItems[0].checked).toBe(1);
});
it('FE-TODO-006: toggleTodoItem rolls back checked on API failure (silent)', async () => {
it('FE-TODO-006: toggleTodoItem preserves optimistic checked state even on API failure', async () => {
const item = buildTodoItem({ id: 10, trip_id: 1, checked: 0 });
seedStore(useTripStore, { todoItems: [item] });
@@ -125,10 +129,10 @@ describe('todoSlice', () => {
),
);
// Does NOT throw
await useTripStore.getState().toggleTodoItem(1, 10, true);
expect(useTripStore.getState().todoItems[0].checked).toBe(0);
// Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().todoItems[0].checked).toBe(1);
});
it('FE-TODO-007: toggleTodoItem preserves sort_order field', async () => {
+12 -3
View File
@@ -4,6 +4,7 @@ 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(),
@@ -17,7 +18,11 @@ vi.mock('../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
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()));
resetAllStores();
});
@@ -75,6 +80,10 @@ 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: [] })),
@@ -210,8 +219,8 @@ describe('tripStore', () => {
const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' });
expect(result).toEqual(updatedTrip);
expect(useTripStore.getState().trip).toEqual(updatedTrip);
expect(result.name).toBe('Updated Trip');
expect(useTripStore.getState().trip?.name).toBe('Updated Trip');
});
});
+5 -58
View File
@@ -7,65 +7,12 @@ export default defineConfig({
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectManifest: {
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] },
},
},
],
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
},
manifest: {
name: 'TREK \u2014 Travel Planner',
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-server",
"version": "3.0.17",
"version": "3.0.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
"version": "3.0.17",
"version": "3.0.15",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "3.0.17",
"version": "3.0.15",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
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.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

After

Width:  |  Height:  |  Size: 2.3 KiB

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

After

Width:  |  Height:  |  Size: 2.3 KiB

+15
View File
@@ -0,0 +1,15 @@
<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>

After

Width:  |  Height:  |  Size: 2.0 KiB

+33
View File
@@ -0,0 +1,33 @@
<!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

After

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

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

After

Width:  |  Height:  |  Size: 3.8 KiB

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

After

Width:  |  Height:  |  Size: 3.8 KiB

File diff suppressed because one or more lines are too long
+27 -50
View File
@@ -50,11 +50,11 @@ import { getCollabFeatures } from './services/adminService';
import { isAddonEnabled } from './services/adminService';
import { ADDON_IDS } from './addons';
import { ALL_SCOPES } from './mcp/scopes';
import { getAppUrl } from './services/oidcService';
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();
@@ -65,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) {
@@ -103,19 +103,19 @@ export function createApp(): express.Application {
// 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(
(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();
}
},
(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({
@@ -249,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');
@@ -276,10 +276,7 @@ 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.setHeader('Cache-Control', 'no-store, must-revalidate')
res.json({ status: 'ok' })
});
app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' }));
app.use('/api/config', publicConfigRoutes);
app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes);
@@ -388,7 +385,7 @@ export function createApp(): express.Application {
function getOAuthMetadata(): OAuthMetadata {
if (_oauthMetadata) return _oauthMetadata;
const base = getMcpSafeUrl().replace(/\/+$/, '');
const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, '');
_oauthMetadata = {
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
@@ -416,11 +413,14 @@ export function createApp(): express.Application {
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.
// Path-aware gate: only /.well-known/* returns 404 when disabled; other paths pass through
// so static files and SPA routes are unaffected when MCP is off.
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith('/.well-known/') && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const isMetadataPath =
req.path === '/.well-known/oauth-authorization-server' ||
req.path === '/.well-known/openid-configuration' ||
req.path.startsWith('/.well-known/oauth-protected-resource');
if (isMetadataPath && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
getMetaRouter()(req, res, next);
});
@@ -436,23 +436,6 @@ export function createApp(): express.Application {
});
});
// 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 }));
@@ -478,12 +461,6 @@ export function createApp(): express.Application {
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');
@@ -514,4 +491,4 @@ export function createApp(): express.Application {
});
return app;
}
}
+10 -31
View File
@@ -19,7 +19,6 @@ 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;
@@ -30,42 +29,22 @@ 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}`] : []),
` 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?.()}`,
` 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?.()}`,
'──────────────────────────────────────',
];
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!');
+20 -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 { getMcpSafeUrl } from '../services/notifications';
import { getAppUrl } from '../services/oidcService';
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,10 +153,10 @@ const sessionSweepInterval = setInterval(() => {
sessionSweepInterval.unref();
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
const base = (getAppUrl() || '').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/mcp", error="${error}"`);
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`);
}
interface VerifyTokenResult {
@@ -183,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 = `${(getMcpSafeUrl() || '').replace(/\/+$/, '')}/mcp`;
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
if (result.audience !== expected) return null;
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
}
@@ -279,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',
{
name: 'TREK MCP',
version: '1.0.0',
},
{
capabilities: {
resources: { listChanged: true },
tools: { listChanged: true },
prompts: { listChanged: true },
},
{
capabilities: {
resources: { listChanged: true },
tools: { listChanged: true },
prompts: { listChanged: true },
},
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
}
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;
@@ -348,4 +348,4 @@ export function closeMcpSessions(): void {
}
sessions.clear();
rateLimitMap.clear();
}
}
+151 -151
View File
@@ -7,16 +7,16 @@ import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/serv
import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors';
import { db } from '../db/database';
import {
createOAuthClient,
consumeAuthCode,
issueTokens,
refreshTokens,
revokeToken as serviceRevokeToken,
verifyPKCE,
getUserByAccessToken,
createOAuthClient,
consumeAuthCode,
issueTokens,
refreshTokens,
revokeToken as serviceRevokeToken,
verifyPKCE,
getUserByAccessToken,
} from '../services/oauthService';
import { ALL_SCOPES } from './scopes';
import { getMcpSafeUrl } from '../services/notifications';
import { getAppUrl } from '../services/oidcService';
import { writeAudit } from '../services/auditLog';
// ---------------------------------------------------------------------------
@@ -24,12 +24,12 @@ import { writeAudit } from '../services/auditLog';
// ---------------------------------------------------------------------------
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;
client_id: string;
name: string;
redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array
is_public: number; // 0 | 1
created_via: string;
}
// ---------------------------------------------------------------------------
@@ -37,23 +37,23 @@ interface OAuthClientRow {
// ---------------------------------------------------------------------------
const DANGEROUS_SCHEMES = new Set([
'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:',
'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');
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');
}
}
// ---------------------------------------------------------------------------
@@ -61,15 +61,15 @@ function assertValidRedirectUris(uris: string[]): void {
// ---------------------------------------------------------------------------
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'],
};
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'],
};
}
// ---------------------------------------------------------------------------
@@ -77,43 +77,43 @@ function rowToInfo(row: OAuthClientRow): OAuthClientInformationFull {
// ---------------------------------------------------------------------------
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 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);
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';
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');
// 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 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 } : {}),
};
},
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 } : {}),
};
},
};
// ---------------------------------------------------------------------------
@@ -121,100 +121,100 @@ export const trekClientsStore: OAuthRegisteredClientsStore = {
// ---------------------------------------------------------------------------
export const trekOAuthProvider: OAuthServerProvider = {
get clientsStore() { return trekClientsStore; },
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;
// Redirects browser to the SPA consent page with OAuth params forwarded.
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
const mcpResource = `${(getAppUrl() || '').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;
}
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 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);
res.redirect(302, `/oauth/consent?${qs.toString()}`);
},
res.redirect(302, `/oauth/consent?${qs.toString()}`);
},
// Not called because skipLocalPkceValidation = true.
// PKCE verification is done inline in exchangeAuthorizationCode.
skipLocalPkceValidation: true,
// 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 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.');
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.');
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.');
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.');
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;
},
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 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 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);
},
};
async revokeToken(
client: OAuthClientInformationFull,
request: OAuthTokenRevocationRequest,
): Promise<void> {
serviceRevokeToken(request.token, client.client_id, undefined, null);
},
};
+12 -12
View File
@@ -214,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
@@ -368,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();

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