Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de3152ee57 | |||
| de6c0fb781 | |||
| 9f1d05e886 | |||
| 25f326a659 | |||
| 418f3e0bb2 |
@@ -3,6 +3,8 @@ node_modules/
|
|||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
client/dist/
|
client/dist/
|
||||||
|
server/public/*
|
||||||
|
!server/public/.gitkeep
|
||||||
|
|
||||||
# Generated PWA icons (built from SVG via prebuild)
|
# Generated PWA icons (built from SVG via prebuild)
|
||||||
client/public/icons/*.png
|
client/public/icons/*.png
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CLIENT_DIR="$REPO_ROOT/client"
|
||||||
|
SERVER_DIR="$REPO_ROOT/server"
|
||||||
|
PUBLIC_DIR="$REPO_ROOT/server/public"
|
||||||
|
|
||||||
|
echo "==> Installing client dependencies"
|
||||||
|
cd "$CLIENT_DIR"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
echo "==> Building client"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "==> Installing server dependencies"
|
||||||
|
cd "$SERVER_DIR"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
echo "==> Populating server/public"
|
||||||
|
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
|
||||||
|
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
|
||||||
|
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
|
||||||
|
|
||||||
|
echo "==> Done — server/public is ready"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 3.0.15
|
version: 3.0.17
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.0.15"
|
appVersion: "3.0.17"
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.15",
|
"version": "3.0.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.15",
|
"version": "3.0.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
@@ -27,12 +27,6 @@
|
|||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"topojson-client": "^3.1.0",
|
"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"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -6477,6 +6471,7 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/indent-string": {
|
"node_modules/indent-string": {
|
||||||
@@ -7543,9 +7538,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/marked": {
|
"node_modules/marked": {
|
||||||
"version": "18.0.3",
|
"version": "18.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
|
||||||
"integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
|
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"marked": "bin/marked.js"
|
"marked": "bin/marked.js"
|
||||||
@@ -12037,6 +12032,7 @@
|
|||||||
"version": "7.4.0",
|
"version": "7.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz",
|
||||||
"integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==",
|
"integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.0"
|
"workbox-core": "7.4.0"
|
||||||
@@ -12046,12 +12042,14 @@
|
|||||||
"version": "7.4.0",
|
"version": "7.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
|
||||||
"integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
|
"integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/workbox-expiration": {
|
"node_modules/workbox-expiration": {
|
||||||
"version": "7.4.0",
|
"version": "7.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz",
|
||||||
"integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==",
|
"integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"idb": "^7.0.1",
|
"idb": "^7.0.1",
|
||||||
@@ -12085,6 +12083,7 @@
|
|||||||
"version": "7.4.0",
|
"version": "7.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz",
|
||||||
"integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==",
|
"integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.0",
|
"workbox-core": "7.4.0",
|
||||||
@@ -12121,6 +12120,7 @@
|
|||||||
"version": "7.4.0",
|
"version": "7.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz",
|
||||||
"integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==",
|
"integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.0"
|
"workbox-core": "7.4.0"
|
||||||
@@ -12130,6 +12130,7 @@
|
|||||||
"version": "7.4.0",
|
"version": "7.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz",
|
||||||
"integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==",
|
"integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.0"
|
"workbox-core": "7.4.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "3.0.15",
|
"version": "3.0.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,12 +18,6 @@
|
|||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dexie": "^4.4.2",
|
"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",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"mapbox-gl": "^3.22.0",
|
"mapbox-gl": "^3.22.0",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios, { AxiosInstance } from 'axios'
|
import axios, { AxiosInstance } from 'axios'
|
||||||
import { getSocketId } from './websocket'
|
import { getSocketId } from './websocket'
|
||||||
|
import { isReachable, probeNow } from '../sync/connectivity'
|
||||||
import en from '../i18n/translations/en'
|
import en from '../i18n/translations/en'
|
||||||
import br from '../i18n/translations/br'
|
import br from '../i18n/translations/br'
|
||||||
import de from '../i18n/translations/de'
|
import de from '../i18n/translations/de'
|
||||||
@@ -43,24 +44,24 @@ const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
|||||||
|
|
||||||
// Request interceptor - add socket ID + idempotency key for mutating requests
|
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const sid = getSocketId()
|
const sid = getSocketId()
|
||||||
if (sid) {
|
if (sid) {
|
||||||
config.headers['X-Socket-Id'] = sid
|
config.headers['X-Socket-Id'] = sid
|
||||||
}
|
}
|
||||||
// Attach a per-request idempotency key to all write operations so the
|
// Attach a per-request idempotency key to all write operations so the
|
||||||
// server can deduplicate retried requests (e.g. network blips).
|
// server can deduplicate retried requests (e.g. network blips).
|
||||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||||
const method = (config.method ?? '').toLowerCase()
|
const method = (config.method ?? '').toLowerCase()
|
||||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: Math.random().toString(36).slice(2)
|
: Math.random().toString(36).slice(2)
|
||||||
config.headers['X-Idempotency-Key'] = key
|
config.headers['X-Idempotency-Key'] = key
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
)
|
)
|
||||||
|
|
||||||
export function isAuthPublicPath(pathname: string): boolean {
|
export function isAuthPublicPath(pathname: string): boolean {
|
||||||
@@ -69,36 +70,84 @@ export function isAuthPublicPath(pathname: string): boolean {
|
|||||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
// Unregisters the SW before reloading so the navigation reaches the network.
|
||||||
|
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
|
||||||
|
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
|
||||||
|
async function unregisterSWAndReload(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker?.getRegistration()
|
||||||
|
if (reg) await reg.unregister()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
(error) => {
|
sessionStorage.removeItem('proxy_reauth_attempted')
|
||||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
return response
|
||||||
const { pathname } = window.location
|
},
|
||||||
if (!isAuthPublicPath(pathname)) {
|
async (error) => {
|
||||||
const currentPath = pathname + window.location.search + window.location.hash
|
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||||
if (
|
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||||
error.response?.status === 403 &&
|
// always application/json, so checking for text/html is unambiguous.
|
||||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
if (error.response?.status === 401) {
|
||||||
!window.location.pathname.startsWith('/settings')
|
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||||
) {
|
if (ct.includes('text/html')) {
|
||||||
window.location.href = '/settings?mfa=required'
|
const { pathname } = window.location
|
||||||
}
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||||
if (error.response?.status === 429) {
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||||
const translated = translateRateLimit()
|
await unregisterSWAndReload()
|
||||||
const data = error.response.data as { error?: string } | undefined
|
return Promise.reject(error)
|
||||||
if (data && typeof data === 'object') {
|
}
|
||||||
data.error = translated
|
}
|
||||||
} else {
|
|
||||||
error.response.data = { error: translated }
|
|
||||||
}
|
}
|
||||||
error.message = translated
|
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||||
|
const { pathname } = window.location
|
||||||
|
if (!isAuthPublicPath(pathname)) {
|
||||||
|
const currentPath = pathname + window.location.search + window.location.hash
|
||||||
|
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
error.response?.status === 403 &&
|
||||||
|
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||||
|
!window.location.pathname.startsWith('/settings')
|
||||||
|
) {
|
||||||
|
window.location.href = '/settings?mfa=required'
|
||||||
|
}
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
const translated = translateRateLimit()
|
||||||
|
const data = error.response.data as { error?: string } | undefined
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
data.error = translated
|
||||||
|
} else {
|
||||||
|
error.response.data = { error: translated }
|
||||||
|
}
|
||||||
|
error.message = translated
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
@@ -161,7 +210,7 @@ export const oauthApi = {
|
|||||||
clients: {
|
clients: {
|
||||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||||
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
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),
|
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),
|
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||||
},
|
},
|
||||||
@@ -218,11 +267,11 @@ export const placesApi = {
|
|||||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
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) =>
|
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) =>
|
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[]) =>
|
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 = {
|
export const assignmentsApi = {
|
||||||
@@ -316,7 +365,7 @@ export const adminApi = {
|
|||||||
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
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),
|
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
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),
|
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||||
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).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),
|
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
||||||
@@ -325,7 +374,7 @@ export const adminApi = {
|
|||||||
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
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),
|
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||||
sendTestNotification: (data: Record<string, unknown>) =>
|
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),
|
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),
|
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),
|
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||||
@@ -390,7 +439,7 @@ export const journeyApi = {
|
|||||||
export const mapsApi = {
|
export const mapsApi = {
|
||||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
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) =>
|
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),
|
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),
|
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),
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||||
@@ -446,7 +495,7 @@ export const weatherApi = {
|
|||||||
|
|
||||||
export const configApi = {
|
export const configApi = {
|
||||||
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
||||||
apiClient.get('/config').then(r => r.data),
|
apiClient.get('/config').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
@@ -532,21 +581,21 @@ export const notificationsApi = {
|
|||||||
|
|
||||||
export const inAppNotificationsApi = {
|
export const inAppNotificationsApi = {
|
||||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
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: () =>
|
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) =>
|
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) =>
|
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: () =>
|
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) =>
|
delete: (id: number) =>
|
||||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||||
deleteAll: () =>
|
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') =>
|
respond: (id: number, response: 'positive' | 'negative') =>
|
||||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export default apiClient
|
export default apiClient
|
||||||
@@ -10,11 +10,8 @@ import { usePermissionsStore } from '../../store/permissionsStore';
|
|||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
|
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
|
||||||
import BudgetPanel from './BudgetPanel';
|
import BudgetPanel from './BudgetPanel';
|
||||||
import { offlineDb } from '../../db/offlineDb';
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
||||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
// Settlement and per-person APIs needed by BudgetPanel
|
// Settlement and per-person APIs needed by BudgetPanel
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -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 }}>
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||||
{t('budget.title')}
|
{t('budget.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||||
<div style={{ width: 150 }}>
|
<div className="max-md:!w-full" style={{ width: 150 }}>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={currency}
|
value={currency}
|
||||||
onChange={setCurrency}
|
onChange={setCurrency}
|
||||||
@@ -730,7 +730,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div style={{ display: 'flex', gap: 6, width: 260 }}>
|
<div className="max-md:!w-full" style={{ display: 'flex', gap: 6, width: 260 }}>
|
||||||
<input
|
<input
|
||||||
value={newCategoryName}
|
value={newCategoryName}
|
||||||
onChange={e => setNewCategoryName(e.target.value)}
|
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'}
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
>
|
>
|
||||||
<Download size={14} strokeWidth={2.5} /> CSV
|
<Download size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">CSV</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ vi.mock('../../api/client', async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { filesApi } from '../../api/client';
|
import { filesApi } from '../../api/client';
|
||||||
import { offlineDb } from '../../db/offlineDb';
|
|
||||||
|
|
||||||
const buildFile = (overrides = {}) => ({
|
const buildFile = (overrides = {}) => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -67,9 +66,7 @@ const defaultProps = {
|
|||||||
allowedFileTypes: null,
|
allowedFileTypes: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
||||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Seed auth as admin so useCanDo() returns true for all permissions
|
// Seed auth as admin so useCanDo() returns true for all permissions
|
||||||
@@ -133,21 +130,15 @@ describe('FileManager', () => {
|
|||||||
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
|
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-FILEMANAGER-005: star button calls star endpoint', async () => {
|
it('FE-COMP-FILEMANAGER-005: star button calls filesApi.toggleStar', 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()]} />);
|
render(<FileManager {...defaultProps} files={[buildFile()]} />);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Find the star button by its title
|
||||||
const starBtn = screen.getByTitle(/star/i);
|
const starBtn = screen.getByTitle(/star/i);
|
||||||
await user.click(starBtn);
|
await user.click(starBtn);
|
||||||
|
|
||||||
await waitFor(() => expect(starCalled).toBe(true));
|
expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
|
it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
|
||||||
@@ -407,47 +398,39 @@ describe('FileManager', () => {
|
|||||||
await screen.findByText('Hotel Paris');
|
await screen.findByText('Hotel Paris');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls file update endpoint', async () => {
|
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls filesApi.update', async () => {
|
||||||
const { buildPlace } = await import('../../../tests/helpers/factories');
|
const { buildPlace } = await import('../../../tests/helpers/factories');
|
||||||
const place = buildPlace({ id: 10, name: 'Louvre Museum' });
|
const place = buildPlace({ id: 10, name: 'Louvre Museum' });
|
||||||
const file = buildFile({ id: 1 });
|
const file = buildFile({ id: 1 });
|
||||||
const onUpdate = vi.fn().mockResolvedValue(undefined);
|
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} />);
|
render(<FileManager {...defaultProps} files={[file]} places={[place]} onUpdate={onUpdate} />);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Open assign modal
|
||||||
await user.click(screen.getByTitle(/assign/i));
|
await user.click(screen.getByTitle(/assign/i));
|
||||||
await screen.findByText('Louvre Museum');
|
await screen.findByText('Louvre Museum');
|
||||||
|
|
||||||
|
// Click on the place button to link it
|
||||||
await user.click(screen.getByText('Louvre Museum'));
|
await user.click(screen.getByText('Louvre Museum'));
|
||||||
|
|
||||||
await waitFor(() => expect(capturedBody).toMatchObject({ place_id: 10 }));
|
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: 10 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls file update endpoint', async () => {
|
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
|
||||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||||
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
|
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
|
||||||
const file = buildFile({ id: 1 });
|
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]} />);
|
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Open assign modal
|
||||||
await user.click(screen.getByTitle(/assign/i));
|
await user.click(screen.getByTitle(/assign/i));
|
||||||
await screen.findByText('Train Ticket');
|
await screen.findByText('Train Ticket');
|
||||||
|
|
||||||
|
// Click on the reservation button to link it
|
||||||
await user.click(screen.getByText('Train Ticket'));
|
await user.click(screen.getByText('Train Ticket'));
|
||||||
|
|
||||||
await waitFor(() => expect(capturedBody).toMatchObject({ reservation_id: 20 }));
|
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: 20 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
|
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
|
||||||
@@ -524,46 +507,39 @@ describe('FileManager', () => {
|
|||||||
await screen.findByText(/Colosseum/);
|
await screen.findByText(/Colosseum/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls file update endpoint', async () => {
|
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls filesApi.update', async () => {
|
||||||
const { buildPlace } = await import('../../../tests/helpers/factories');
|
const { buildPlace } = await import('../../../tests/helpers/factories');
|
||||||
const place = buildPlace({ id: 10, name: 'Venice Beach' });
|
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 });
|
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]} />);
|
render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Open assign modal
|
||||||
await user.click(screen.getByTitle(/assign/i));
|
await user.click(screen.getByTitle(/assign/i));
|
||||||
await screen.findByText('Venice Beach');
|
await screen.findByText('Venice Beach');
|
||||||
await user.click(screen.getByText('Venice Beach'));
|
|
||||||
|
|
||||||
await waitFor(() => expect(capturedBody).toMatchObject({ place_id: null }));
|
// Clicking the linked place should unlink it
|
||||||
|
await user.click(screen.getByText('Venice Beach'));
|
||||||
|
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls file update endpoint', async () => {
|
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
|
||||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||||
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
|
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
|
||||||
|
// File already has reservation_id set to 20
|
||||||
const file = buildFile({ id: 1, reservation_id: 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]} />);
|
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
await user.click(screen.getByTitle(/assign/i));
|
await user.click(screen.getByTitle(/assign/i));
|
||||||
await screen.findByText('Museum Pass');
|
await screen.findByText('Museum Pass');
|
||||||
await user.click(screen.getByText('Museum Pass'));
|
|
||||||
|
|
||||||
await waitFor(() => expect(capturedBody).toMatchObject({ reservation_id: null }));
|
// Clicking the linked reservation should unlink it
|
||||||
|
await user.click(screen.getByText('Museum Pass'));
|
||||||
|
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-FILEMANAGER-033: opening PDF preview and closing via backdrop', async () => {
|
it('FE-COMP-FILEMANAGER-033: opening PDF preview and closing via backdrop', async () => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, M
|
|||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { filesApi } from '../../api/client'
|
import { filesApi } from '../../api/client'
|
||||||
import { fileRepo } from '../../repo/fileRepo'
|
|
||||||
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
@@ -291,7 +290,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
|
|
||||||
const handleStar = async (fileId: number) => {
|
const handleStar = async (fileId: number) => {
|
||||||
try {
|
try {
|
||||||
await fileRepo.toggleStar(tripId, fileId)
|
await filesApi.toggleStar(tripId, fileId)
|
||||||
refreshFiles()
|
refreshFiles()
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
}
|
}
|
||||||
@@ -410,7 +409,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
|
|
||||||
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
||||||
try {
|
try {
|
||||||
await fileRepo.update(tripId, fileId, data as Record<string, unknown>)
|
await filesApi.update(tripId, fileId, data)
|
||||||
refreshFiles()
|
refreshFiles()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('files.toast.assignError'))
|
toast.error(t('files.toast.assignError'))
|
||||||
|
|||||||
@@ -9,11 +9,8 @@ import { useTripStore } from '../../store/tripStore';
|
|||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
||||||
import PackingListPanel from './PackingListPanel';
|
import PackingListPanel from './PackingListPanel';
|
||||||
import { offlineDb } from '../../db/offlineDb';
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
||||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
// Side-effect APIs PackingListPanel calls on mount
|
// Side-effect APIs PackingListPanel calls on mount
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { usePermissionsStore } from '../../store/permissionsStore';
|
|||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
|
import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
|
||||||
import DayDetailPanel from './DayDetailPanel';
|
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' });
|
const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
|
||||||
|
|
||||||
@@ -29,9 +28,7 @@ const defaultProps = {
|
|||||||
onAccommodationChange: vi.fn(),
|
onAccommodationChange: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
||||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind
|
|||||||
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
const RES_TYPE_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' }
|
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 { weatherApi, accommodationsApi } from '../../api/client'
|
||||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
@@ -118,10 +117,8 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const handleSaveAccommodation = async () => {
|
const handleSaveAccommodation = async () => {
|
||||||
if (!hotelForm.place_id) return
|
if (!hotelForm.place_id) return
|
||||||
try {
|
try {
|
||||||
const selectedPlace = places.find(p => p.id === hotelForm.place_id)
|
const data = await accommodationsApi.create(tripId, {
|
||||||
const data = await accommodationRepo.create(tripId, {
|
|
||||||
place_id: hotelForm.place_id,
|
place_id: hotelForm.place_id,
|
||||||
place_name: selectedPlace?.name,
|
|
||||||
start_day_id: hotelDayRange.start,
|
start_day_id: hotelDayRange.start,
|
||||||
end_day_id: hotelDayRange.end,
|
end_day_id: hotelDayRange.end,
|
||||||
check_in: hotelForm.check_in || null,
|
check_in: hotelForm.check_in || null,
|
||||||
@@ -145,7 +142,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const updateAccommodationField = async (field, value) => {
|
const updateAccommodationField = async (field, value) => {
|
||||||
if (!accommodation) return
|
if (!accommodation) return
|
||||||
try {
|
try {
|
||||||
const data = await accommodationRepo.update(tripId, accommodation.id, { [field]: value || null })
|
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
|
||||||
setAccommodation(data.accommodation)
|
setAccommodation(data.accommodation)
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -154,7 +151,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const handleRemoveAccommodation = async () => {
|
const handleRemoveAccommodation = async () => {
|
||||||
if (!accommodation) return
|
if (!accommodation) return
|
||||||
try {
|
try {
|
||||||
await accommodationRepo.delete(tripId, accommodation.id)
|
await accommodationsApi.delete(tripId, accommodation.id)
|
||||||
const updated = accommodations.filter(a => a.id !== accommodation.id)
|
const updated = accommodations.filter(a => a.id !== accommodation.id)
|
||||||
setAccommodations(updated)
|
setAccommodations(updated)
|
||||||
setDayAccommodations(updated.filter(a =>
|
setDayAccommodations(updated.filter(a =>
|
||||||
@@ -586,7 +583,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<button onClick={async () => {
|
<button onClick={async () => {
|
||||||
if (showHotelPicker === 'edit' && accommodation) {
|
if (showHotelPicker === 'edit' && accommodation) {
|
||||||
// Update existing
|
// Update existing
|
||||||
await accommodationRepo.update(tripId, accommodation.id, {
|
await accommodationsApi.update(tripId, accommodation.id, {
|
||||||
place_id: hotelForm.place_id,
|
place_id: hotelForm.place_id,
|
||||||
start_day_id: hotelDayRange.start,
|
start_day_id: hotelDayRange.start,
|
||||||
end_day_id: hotelDayRange.end,
|
end_day_id: hotelDayRange.end,
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Offline settings tab — shows cached trips, storage info, and controls
|
* Offline settings tab — shows cached trips, storage info, and controls
|
||||||
* to re-sync or clear the offline cache. Also exposes runtime SW cache config.
|
* to re-sync or clear the offline cache.
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { Wifi, RefreshCw, Trash2, Database, Settings2, RotateCcw, CheckCircle } from 'lucide-react'
|
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
import { offlineDb, clearAll } from '../../db/offlineDb'
|
import { offlineDb, clearAll } from '../../db/offlineDb'
|
||||||
import { tripSyncManager } from '../../sync/tripSyncManager'
|
import { tripSyncManager } from '../../sync/tripSyncManager'
|
||||||
import { mutationQueue } from '../../sync/mutationQueue'
|
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 { SyncMeta } from '../../db/offlineDb'
|
||||||
import type { Trip } from '../../types'
|
import type { Trip } from '../../types'
|
||||||
|
|
||||||
@@ -33,12 +25,6 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
const [clearing, setClearing] = useState(false)
|
const [clearing, setClearing] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -67,59 +53,6 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
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() {
|
async function handleResync() {
|
||||||
setSyncing(true)
|
setSyncing(true)
|
||||||
try {
|
try {
|
||||||
@@ -187,86 +120,6 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Cached trip list */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading…</p>
|
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading…</p>
|
||||||
@@ -286,32 +139,24 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
display: 'flex', flexDirection: 'column', gap: 2,
|
display: 'flex', flexDirection: 'column', gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
|
||||||
<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.name}
|
||||||
{trip.title || 'Unnamed trip'}
|
</span>
|
||||||
</span>
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
{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 }} />
|
<Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
|
||||||
{meta.lastSyncedAt
|
{meta.lastSyncedAt
|
||||||
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -333,32 +178,3 @@ function Stat({ label, value }: { label: string; value: number }) {
|
|||||||
</div>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -464,8 +464,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.mfaVerify': 'تحقق',
|
'login.mfaVerify': 'تحقق',
|
||||||
'login.invalidInviteLink': 'رابط الدعوة غير صالح أو منتهي الصلاحية',
|
'login.invalidInviteLink': 'رابط الدعوة غير صالح أو منتهي الصلاحية',
|
||||||
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
|
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
|
||||||
'login.configLoadError': 'تعذّر تحميل خيارات تسجيل الدخول.',
|
|
||||||
'login.configLoadRetry': 'تحديث',
|
|
||||||
'login.usernameRequired': 'اسم المستخدم مطلوب',
|
'login.usernameRequired': 'اسم المستخدم مطلوب',
|
||||||
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
|
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
|
||||||
'login.forgotPassword': 'نسيت كلمة المرور؟',
|
'login.forgotPassword': 'نسيت كلمة المرور؟',
|
||||||
|
|||||||
@@ -459,8 +459,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.mfaVerify': 'Verificar',
|
'login.mfaVerify': 'Verificar',
|
||||||
'login.invalidInviteLink': 'Link de convite inválido ou expirado',
|
'login.invalidInviteLink': 'Link de convite inválido ou expirado',
|
||||||
'login.oidcFailed': 'Falha no login OIDC',
|
'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.usernameRequired': 'Nome de usuário é obrigatório',
|
||||||
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
|
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
|
||||||
'login.forgotPassword': 'Esqueceu a senha?',
|
'login.forgotPassword': 'Esqueceu a senha?',
|
||||||
|
|||||||
@@ -459,8 +459,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.mfaVerify': 'Ověřit',
|
'login.mfaVerify': 'Ověřit',
|
||||||
'login.invalidInviteLink': 'Neplatný nebo vypršelý odkaz s pozvánkou',
|
'login.invalidInviteLink': 'Neplatný nebo vypršelý odkaz s pozvánkou',
|
||||||
'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo',
|
'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.usernameRequired': 'Uživatelské jméno je povinné',
|
||||||
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
|
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
|
||||||
'login.forgotPassword': 'Zapomenuté heslo?',
|
'login.forgotPassword': 'Zapomenuté heslo?',
|
||||||
|
|||||||
@@ -464,8 +464,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.mfaVerify': 'Bestätigen',
|
'login.mfaVerify': 'Bestätigen',
|
||||||
'login.invalidInviteLink': 'Ungültiger oder abgelaufener Einladungslink',
|
'login.invalidInviteLink': 'Ungültiger oder abgelaufener Einladungslink',
|
||||||
'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen',
|
'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen',
|
||||||
'login.configLoadError': 'Anmeldeoptionen konnten nicht geladen werden.',
|
|
||||||
'login.configLoadRetry': 'Aktualisieren',
|
|
||||||
'login.usernameRequired': 'Benutzername ist erforderlich',
|
'login.usernameRequired': 'Benutzername ist erforderlich',
|
||||||
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
|
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
|
||||||
'login.forgotPassword': 'Passwort vergessen?',
|
'login.forgotPassword': 'Passwort vergessen?',
|
||||||
|
|||||||
@@ -537,8 +537,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.mfaVerify': 'Verify',
|
'login.mfaVerify': 'Verify',
|
||||||
'login.invalidInviteLink': 'Invalid or expired invite link',
|
'login.invalidInviteLink': 'Invalid or expired invite link',
|
||||||
'login.oidcFailed': 'OIDC login failed',
|
'login.oidcFailed': 'OIDC login failed',
|
||||||
'login.configLoadError': 'Could not load login options.',
|
|
||||||
'login.configLoadRetry': 'Refresh',
|
|
||||||
'login.usernameRequired': 'Username is required',
|
'login.usernameRequired': 'Username is required',
|
||||||
'login.passwordMinLength': 'Password must be at least 8 characters',
|
'login.passwordMinLength': 'Password must be at least 8 characters',
|
||||||
'login.forgotPassword': 'Forgot password?',
|
'login.forgotPassword': 'Forgot password?',
|
||||||
|
|||||||
@@ -451,8 +451,6 @@ const es: Record<string, string> = {
|
|||||||
'login.mfaVerify': 'Verificar',
|
'login.mfaVerify': 'Verificar',
|
||||||
'login.invalidInviteLink': 'Enlace de invitación inválido o expirado',
|
'login.invalidInviteLink': 'Enlace de invitación inválido o expirado',
|
||||||
'login.oidcFailed': 'Error de inicio de sesión OIDC',
|
'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.usernameRequired': 'El nombre de usuario es obligatorio',
|
||||||
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
|
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
|
||||||
'login.forgotPassword': '¿Olvidaste tu contraseña?',
|
'login.forgotPassword': '¿Olvidaste tu contraseña?',
|
||||||
|
|||||||
@@ -452,8 +452,6 @@ const fr: Record<string, string> = {
|
|||||||
'login.mfaVerify': 'Vérifier',
|
'login.mfaVerify': 'Vérifier',
|
||||||
'login.invalidInviteLink': 'Lien d\'invitation invalide ou expiré',
|
'login.invalidInviteLink': 'Lien d\'invitation invalide ou expiré',
|
||||||
'login.oidcFailed': 'Échec de connexion OIDC',
|
'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.usernameRequired': 'Le nom d\'utilisateur est obligatoire',
|
||||||
'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères',
|
'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères',
|
||||||
'login.forgotPassword': 'Mot de passe oublié ?',
|
'login.forgotPassword': 'Mot de passe oublié ?',
|
||||||
|
|||||||
@@ -459,8 +459,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.mfaVerify': 'Ellenőrzés',
|
'login.mfaVerify': 'Ellenőrzés',
|
||||||
'login.invalidInviteLink': 'Érvénytelen vagy lejárt meghívólink',
|
'login.invalidInviteLink': 'Érvénytelen vagy lejárt meghívólink',
|
||||||
'login.oidcFailed': 'OIDC bejelentkezés sikertelen',
|
'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.usernameRequired': 'A felhasználónév kötelező',
|
||||||
'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
|
'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
|
||||||
'login.forgotPassword': 'Elfelejtetted a jelszavad?',
|
'login.forgotPassword': 'Elfelejtetted a jelszavad?',
|
||||||
|
|||||||
@@ -521,8 +521,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.mfaVerify': 'Verifikasi',
|
'login.mfaVerify': 'Verifikasi',
|
||||||
'login.invalidInviteLink': 'Tautan undangan tidak valid atau sudah kedaluwarsa',
|
'login.invalidInviteLink': 'Tautan undangan tidak valid atau sudah kedaluwarsa',
|
||||||
'login.oidcFailed': 'Login OIDC gagal',
|
'login.oidcFailed': 'Login OIDC gagal',
|
||||||
'login.configLoadError': 'Gagal memuat opsi login.',
|
|
||||||
'login.configLoadRetry': 'Segarkan',
|
|
||||||
'login.usernameRequired': 'Nama pengguna wajib diisi',
|
'login.usernameRequired': 'Nama pengguna wajib diisi',
|
||||||
'login.passwordMinLength': 'Kata sandi minimal 8 karakter',
|
'login.passwordMinLength': 'Kata sandi minimal 8 karakter',
|
||||||
'login.forgotPassword': 'Lupa kata sandi?',
|
'login.forgotPassword': 'Lupa kata sandi?',
|
||||||
|
|||||||
@@ -459,8 +459,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.mfaVerify': 'Verifica',
|
'login.mfaVerify': 'Verifica',
|
||||||
'login.invalidInviteLink': 'Link di invito non valido o scaduto',
|
'login.invalidInviteLink': 'Link di invito non valido o scaduto',
|
||||||
'login.oidcFailed': 'Accesso OIDC non riuscito',
|
'login.oidcFailed': 'Accesso OIDC non riuscito',
|
||||||
'login.configLoadError': 'Impossibile caricare le opzioni di accesso.',
|
|
||||||
'login.configLoadRetry': 'Aggiorna',
|
|
||||||
'login.usernameRequired': 'Il nome utente è obbligatorio',
|
'login.usernameRequired': 'Il nome utente è obbligatorio',
|
||||||
'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
|
'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
|
||||||
'login.forgotPassword': 'Password dimenticata?',
|
'login.forgotPassword': 'Password dimenticata?',
|
||||||
|
|||||||
@@ -452,8 +452,6 @@ const nl: Record<string, string> = {
|
|||||||
'login.mfaVerify': 'Verifiëren',
|
'login.mfaVerify': 'Verifiëren',
|
||||||
'login.invalidInviteLink': 'Ongeldige of verlopen uitnodigingslink',
|
'login.invalidInviteLink': 'Ongeldige of verlopen uitnodigingslink',
|
||||||
'login.oidcFailed': 'OIDC-aanmelding mislukt',
|
'login.oidcFailed': 'OIDC-aanmelding mislukt',
|
||||||
'login.configLoadError': 'Kan aanmeldingsopties niet laden.',
|
|
||||||
'login.configLoadRetry': 'Vernieuwen',
|
|
||||||
'login.usernameRequired': 'Gebruikersnaam is vereist',
|
'login.usernameRequired': 'Gebruikersnaam is vereist',
|
||||||
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
|
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
|
||||||
'login.forgotPassword': 'Wachtwoord vergeten?',
|
'login.forgotPassword': 'Wachtwoord vergeten?',
|
||||||
|
|||||||
@@ -426,8 +426,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.mfaVerify': 'Weryfikuj',
|
'login.mfaVerify': 'Weryfikuj',
|
||||||
'login.invalidInviteLink': 'Nieprawidłowy lub wygasły link zaproszenia',
|
'login.invalidInviteLink': 'Nieprawidłowy lub wygasły link zaproszenia',
|
||||||
'login.oidcFailed': 'Logowanie OIDC nie powiodło się',
|
'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.usernameRequired': 'Nazwa użytkownika jest wymagana',
|
||||||
'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
|
'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
|
||||||
'login.forgotPassword': 'Nie pamiętasz hasła?',
|
'login.forgotPassword': 'Nie pamiętasz hasła?',
|
||||||
|
|||||||
@@ -452,8 +452,6 @@ const ru: Record<string, string> = {
|
|||||||
'login.mfaVerify': 'Подтвердить',
|
'login.mfaVerify': 'Подтвердить',
|
||||||
'login.invalidInviteLink': 'Недействительная или истёкшая ссылка-приглашение',
|
'login.invalidInviteLink': 'Недействительная или истёкшая ссылка-приглашение',
|
||||||
'login.oidcFailed': 'Ошибка входа через OIDC',
|
'login.oidcFailed': 'Ошибка входа через OIDC',
|
||||||
'login.configLoadError': 'Не удалось загрузить параметры входа.',
|
|
||||||
'login.configLoadRetry': 'Обновить',
|
|
||||||
'login.usernameRequired': 'Имя пользователя обязательно',
|
'login.usernameRequired': 'Имя пользователя обязательно',
|
||||||
'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
|
'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
|
||||||
'login.forgotPassword': 'Забыли пароль?',
|
'login.forgotPassword': 'Забыли пароль?',
|
||||||
|
|||||||
@@ -452,8 +452,6 @@ const zh: Record<string, string> = {
|
|||||||
'login.mfaVerify': '验证',
|
'login.mfaVerify': '验证',
|
||||||
'login.invalidInviteLink': '邀请链接无效或已过期',
|
'login.invalidInviteLink': '邀请链接无效或已过期',
|
||||||
'login.oidcFailed': 'OIDC 登录失败',
|
'login.oidcFailed': 'OIDC 登录失败',
|
||||||
'login.configLoadError': '无法加载登录选项。',
|
|
||||||
'login.configLoadRetry': '刷新',
|
|
||||||
'login.usernameRequired': '用户名为必填项',
|
'login.usernameRequired': '用户名为必填项',
|
||||||
'login.passwordMinLength': '密码至少需要8个字符',
|
'login.passwordMinLength': '密码至少需要8个字符',
|
||||||
'login.forgotPassword': '忘记密码?',
|
'login.forgotPassword': '忘记密码?',
|
||||||
|
|||||||
@@ -511,8 +511,6 @@ const zhTw: Record<string, string> = {
|
|||||||
'login.mfaVerify': '驗證',
|
'login.mfaVerify': '驗證',
|
||||||
'login.invalidInviteLink': '邀請連結無效或已過期',
|
'login.invalidInviteLink': '邀請連結無效或已過期',
|
||||||
'login.oidcFailed': 'OIDC 登入失敗',
|
'login.oidcFailed': 'OIDC 登入失敗',
|
||||||
'login.configLoadError': '無法載入登入選項。',
|
|
||||||
'login.configLoadRetry': '重新整理',
|
|
||||||
'login.usernameRequired': '使用者名稱為必填',
|
'login.usernameRequired': '使用者名稱為必填',
|
||||||
'login.passwordMinLength': '密碼至少需要8個字元',
|
'login.passwordMinLength': '密碼至少需要8個字元',
|
||||||
'login.forgotPassword': '忘記密碼?',
|
'login.forgotPassword': '忘記密碼?',
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import { startConnectivityProbe } from './sync/connectivity'
|
||||||
|
|
||||||
|
startConnectivityProbe()
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -744,17 +744,12 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const loadTrips = async () => {
|
const loadTrips = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const { trips, archivedTrips, refresh } = await tripRepo.list()
|
const { trips, archivedTrips } = await tripRepo.list()
|
||||||
setTrips(sortTrips(trips))
|
setTrips(sortTrips(trips))
|
||||||
setArchivedTrips(sortTrips(archivedTrips))
|
setArchivedTrips(sortTrips(archivedTrips))
|
||||||
setIsLoading(false)
|
|
||||||
refresh.then(fresh => {
|
|
||||||
if (!fresh) return
|
|
||||||
setTrips(sortTrips(fresh.trips))
|
|
||||||
setArchivedTrips(sortTrips(fresh.archivedTrips))
|
|
||||||
}).catch(() => {})
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('dashboard.toast.loadError'))
|
toast.error(t('dashboard.toast.loadError'))
|
||||||
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -796,7 +791,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
|
|
||||||
const handleArchive = async (id) => {
|
const handleArchive = async (id) => {
|
||||||
try {
|
try {
|
||||||
const data = await tripRepo.update(id, { is_archived: true })
|
const data = await tripsApi.archive(id)
|
||||||
setTrips(prev => prev.filter(t => t.id !== id))
|
setTrips(prev => prev.filter(t => t.id !== id))
|
||||||
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
|
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
|
||||||
toast.success(t('dashboard.toast.archived'))
|
toast.success(t('dashboard.toast.archived'))
|
||||||
@@ -807,7 +802,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
|
|
||||||
const handleUnarchive = async (id) => {
|
const handleUnarchive = async (id) => {
|
||||||
try {
|
try {
|
||||||
const data = await tripRepo.update(id, { is_archived: false })
|
const data = await tripsApi.unarchive(id)
|
||||||
setArchivedTrips(prev => prev.filter(t => t.id !== id))
|
setArchivedTrips(prev => prev.filter(t => t.id !== id))
|
||||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||||
toast.success(t('dashboard.toast.restored'))
|
toast.success(t('dashboard.toast.restored'))
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { buildUser, buildTrip, buildTripFile } from '../../tests/helpers/factori
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useTripStore } from '../store/tripStore';
|
import { useTripStore } from '../store/tripStore';
|
||||||
import FilesPage from './FilesPage';
|
import FilesPage from './FilesPage';
|
||||||
import { offlineDb } from '../db/offlineDb';
|
|
||||||
|
|
||||||
vi.mock('../components/Files/FileManager', () => ({
|
vi.mock('../components/Files/FileManager', () => ({
|
||||||
default: ({ files }: { files: unknown[]; onUpload: unknown; onDelete: unknown }) =>
|
default: ({ files }: { files: unknown[]; onUpload: unknown; onDelete: unknown }) =>
|
||||||
@@ -30,9 +29,7 @@ function renderFilesPage(tripId: number | string = 1) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
||||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
|
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', () => {
|
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/auth/oidc/exchange', () =>
|
http.get('/api/auth/oidc/exchange', () =>
|
||||||
HttpResponse.json({ token: 'mock-oidc-token' })
|
HttpResponse.json({ token: 'mock-oidc-token' })
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,8 +73,8 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockNavigate).toHaveBeenCalledWith(
|
expect(mockNavigate).toHaveBeenCalledWith(
|
||||||
'/oauth/consent?client_id=foo&state=xyz',
|
'/oauth/consent?client_id=foo&state=xyz',
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,4 +102,4 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -33,7 +33,6 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
const [error, setError] = useState<string>('')
|
const [error, setError] = useState<string>('')
|
||||||
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
||||||
const [configError, setConfigError] = useState<boolean>(false)
|
|
||||||
const [inviteToken, setInviteToken] = useState<string>('')
|
const [inviteToken, setInviteToken] = useState<string>('')
|
||||||
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
||||||
const exchangeInitiated = useRef(false)
|
const exchangeInitiated = useRef(false)
|
||||||
@@ -118,15 +117,29 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONFIG_CACHE_KEY = 'trek_app_config_cache'
|
||||||
authApi.getAppConfig?.()
|
authApi.getAppConfig?.()
|
||||||
.then((config: AppConfig) => {
|
.then((config: AppConfig) => {
|
||||||
setAppConfig(config)
|
try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ }
|
||||||
if (!config.has_users) setMode('register')
|
return { config, fromCache: false }
|
||||||
if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
})
|
||||||
window.location.href = '/api/auth/oidc/login'
|
.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'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setConfigError(true))
|
|
||||||
}, [navigate, t, noRedirect])
|
}, [navigate, t, noRedirect])
|
||||||
|
|
||||||
// Language detection chain (runs once on mount, only if user has no saved preference):
|
// Language detection chain (runs once on mount, only if user has no saved preference):
|
||||||
@@ -861,20 +874,6 @@ 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 */}
|
{/* Demo login button */}
|
||||||
{appConfig?.demo_mode && (
|
{appConfig?.demo_mode && (
|
||||||
<button onClick={handleDemoLogin} disabled={isLoading}
|
<button onClick={handleDemoLogin} disabled={isLoading}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return
|
if (authLoading) return
|
||||||
validateRequest()
|
validateRequest()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [authLoading, isAuthenticated])
|
}, [authLoading, isAuthenticated])
|
||||||
|
|
||||||
async function validateRequest() {
|
async function validateRequest() {
|
||||||
@@ -114,15 +114,15 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
|
|
||||||
function toggleScope(s: string) {
|
function toggleScope(s: string) {
|
||||||
setSelectedScopes(prev =>
|
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) {
|
function toggleGroup(groupScopes: string[], allSelected: boolean) {
|
||||||
setSelectedScopes(prev =>
|
setSelectedScopes(prev =>
|
||||||
allSelected
|
allSelected
|
||||||
? prev.filter(s => !groupScopes.includes(s))
|
? prev.filter(s => !groupScopes.includes(s))
|
||||||
: [...new Set([...prev, ...groupScopes])]
|
: [...new Set([...prev, ...groupScopes])]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,212 +148,212 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
|
|
||||||
if (pageState === 'loading' || pageState === 'auto_approving') {
|
if (pageState === 'loading' || pageState === 'auto_approving') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageState === 'error') {
|
if (pageState === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
<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)' }}>
|
<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" />
|
<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>
|
<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>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageState === 'login_required') {
|
if (pageState === 'login_required') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
<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="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">
|
<div className="text-center space-y-2">
|
||||||
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
<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>
|
<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)' }}>
|
<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.
|
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
||||||
</p>
|
</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>
|
||||||
<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>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageState === 'consent'
|
// pageState === 'consent'
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
<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="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 */}
|
{/* 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="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="flex-1 space-y-4">
|
||||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
<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)' }} />
|
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
|
||||||
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{validation?.client?.name || clientId}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
This application is requesting access to your TREK account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 space-y-2">
|
|
||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
Only grant access to applications you trust. Your data stays on your server.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => submitConsent(true)}
|
|
||||||
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
|
||||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
|
||||||
{submitting
|
|
||||||
? 'Authorizing…'
|
|
||||||
: validation?.scopeSelectable && selectedScopes.length === 0
|
|
||||||
? 'Select at least one scope'
|
|
||||||
: validation?.scopeSelectable
|
|
||||||
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
|
||||||
: 'Approve Access'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => submitConsent(false)}
|
|
||||||
disabled={submitting}
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
|
||||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
|
||||||
Deny
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right panel — selectable scopes */}
|
|
||||||
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{Object.keys(scopesByGroup).length > 0 && (
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
||||||
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
<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>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{validation?.scopeSelectable ? (
|
<div className="mt-8 space-y-2">
|
||||||
/* DCR client — user selects which scopes to grant */
|
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<div className="space-y-3">
|
Only grant access to applications you trust. Your data stays on your server.
|
||||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
</p>
|
||||||
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
<button
|
||||||
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
onClick={() => submitConsent(true)}
|
||||||
return (
|
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
||||||
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
||||||
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||||
<input
|
{submitting
|
||||||
type="checkbox"
|
? 'Authorizing…'
|
||||||
checked={allGroupSelected}
|
: validation?.scopeSelectable && selectedScopes.length === 0
|
||||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
? 'Select at least one scope'
|
||||||
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
: validation?.scopeSelectable
|
||||||
className="rounded flex-shrink-0"
|
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
||||||
/>
|
: 'Approve Access'}
|
||||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
</button>
|
||||||
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
<button
|
||||||
|
onClick={() => submitConsent(false)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel — selectable scopes */}
|
||||||
|
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.keys(scopesByGroup).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{validation?.scopeSelectable ? (
|
||||||
|
/* DCR client — user selects which scopes to grant */
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||||
|
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
||||||
|
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
||||||
|
return (
|
||||||
|
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
|
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allGroupSelected}
|
||||||
|
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||||
|
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
||||||
|
className="rounded flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
||||||
|
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
|
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
{groupScopes.map(s => {
|
{groupScopes.map(s => {
|
||||||
const keys = SCOPE_GROUPS[s]
|
const keys = SCOPE_GROUPS[s]
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={s}
|
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">
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedScopes.includes(s)}
|
checked={selectedScopes.includes(s)}
|
||||||
onChange={() => toggleScope(s)}
|
onChange={() => toggleScope(s)}
|
||||||
className="mt-0.5 rounded flex-shrink-0"
|
className="mt-0.5 rounded flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<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-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>
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Settings-created client — scopes are fixed, show read-only */
|
|
||||||
<div className="space-y-5">
|
|
||||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
|
||||||
<div key={group}>
|
|
||||||
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{groupScopes.map(s => {
|
|
||||||
const keys = SCOPE_GROUPS[s]
|
|
||||||
return (
|
|
||||||
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
|
||||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
|
||||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
))}
|
/* Settings-created client — scopes are fixed, show read-only */
|
||||||
|
<div className="space-y-5">
|
||||||
|
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
||||||
|
<div key={group}>
|
||||||
|
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{groupScopes.map(s => {
|
||||||
|
const keys = SCOPE_GROUPS[s]
|
||||||
|
return (
|
||||||
|
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||||
|
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Always-available tools — granted regardless of scopes */}
|
{/* Always-available tools — granted regardless of scopes */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
Always included
|
Always included
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{[
|
{[
|
||||||
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
{ 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' },
|
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
||||||
].map(({ name, desc }) => (
|
].map(({ name, desc }) => (
|
||||||
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
<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>
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
<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>
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1174,7 +1174,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'dateien' && (
|
{activeTab === 'dateien' && (
|
||||||
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||||
<FileManager
|
<FileManager
|
||||||
files={files || []}
|
files={files || []}
|
||||||
onUpload={(fd) => tripActions.addFile(tripId, fd)}
|
onUpload={(fd) => tripActions.addFile(tripId, fd)}
|
||||||
|
|||||||
@@ -1,89 +1,16 @@
|
|||||||
import { accommodationsApi } from '../api/client'
|
import { accommodationsApi } from '../api/client'
|
||||||
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
|
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
|
||||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
|
||||||
import type { Accommodation } from '../types'
|
import type { Accommodation } from '../types'
|
||||||
|
|
||||||
export const accommodationRepo = {
|
export const accommodationRepo = {
|
||||||
async list(tripId: number | string): Promise<{ accommodations: Accommodation[]; refresh: Promise<{ accommodations: Accommodation[] } | null> }> {
|
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
|
||||||
const cached = await offlineDb.accommodations
|
if (!navigator.onLine) {
|
||||||
.where('trip_id').equals(Number(tripId)).toArray()
|
const accommodations = await offlineDb.accommodations
|
||||||
|
.where('trip_id').equals(Number(tripId)).toArray()
|
||||||
const refresh = (async () => {
|
return { accommodations }
|
||||||
if (!navigator.onLine) return null
|
}
|
||||||
try {
|
const result = await accommodationsApi.list(tripId)
|
||||||
const result = await accommodationsApi.list(tripId)
|
upsertAccommodations(result.accommodations || []).catch(() => {})
|
||||||
upsertAccommodations(result.accommodations || []).catch(() => {})
|
return result
|
||||||
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 }
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,18 @@
|
|||||||
import { budgetApi } from '../api/client'
|
import { budgetApi } from '../api/client'
|
||||||
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
|
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
|
||||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
|
||||||
import type { BudgetItem } from '../types'
|
import type { BudgetItem } from '../types'
|
||||||
|
|
||||||
export const budgetRepo = {
|
export const budgetRepo = {
|
||||||
async list(tripId: number | string): Promise<{ items: BudgetItem[]; refresh: Promise<{ items: BudgetItem[] } | null> }> {
|
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> {
|
||||||
const cached = await offlineDb.budgetItems
|
if (!navigator.onLine) {
|
||||||
.where('trip_id')
|
const cached = await offlineDb.budgetItems
|
||||||
.equals(Number(tripId))
|
.where('trip_id')
|
||||||
.toArray()
|
.equals(Number(tripId))
|
||||||
|
.toArray()
|
||||||
const refresh = (async () => {
|
return { items: cached }
|
||||||
if (!navigator.onLine) return null
|
}
|
||||||
try {
|
const result = await budgetApi.list(tripId)
|
||||||
const result = await budgetApi.list(tripId)
|
upsertBudgetItems(result.items)
|
||||||
upsertBudgetItems(result.items)
|
return result
|
||||||
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 }
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,18 @@
|
|||||||
import { daysApi } from '../api/client'
|
import { daysApi } from '../api/client'
|
||||||
import { offlineDb, upsertDays } from '../db/offlineDb'
|
import { offlineDb, upsertDays } from '../db/offlineDb'
|
||||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
|
||||||
import type { Day } from '../types'
|
import type { Day } from '../types'
|
||||||
|
|
||||||
export const dayRepo = {
|
export const dayRepo = {
|
||||||
async list(tripId: number | string): Promise<{ days: Day[]; refresh: Promise<{ days: Day[] } | null> }> {
|
async list(tripId: number | string): Promise<{ days: Day[] }> {
|
||||||
const cached = (await offlineDb.days
|
if (!navigator.onLine) {
|
||||||
.where('trip_id')
|
const cached = await offlineDb.days
|
||||||
.equals(Number(tripId))
|
.where('trip_id')
|
||||||
.sortBy('day_number' as keyof Day)) as Day[]
|
.equals(Number(tripId))
|
||||||
|
.sortBy('day_number' as keyof Day)
|
||||||
const refresh = (async () => {
|
return { days: cached as Day[] }
|
||||||
if (!navigator.onLine) return null
|
}
|
||||||
try {
|
const result = await daysApi.list(tripId)
|
||||||
const result = await daysApi.list(tripId)
|
upsertDays(result.days)
|
||||||
upsertDays(result.days)
|
return result
|
||||||
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 }
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,18 @@
|
|||||||
import { filesApi } from '../api/client'
|
import { filesApi } from '../api/client'
|
||||||
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
|
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
|
||||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
|
||||||
import type { TripFile } from '../types'
|
import type { TripFile } from '../types'
|
||||||
|
|
||||||
export const fileRepo = {
|
export const fileRepo = {
|
||||||
async list(tripId: number | string): Promise<{ files: TripFile[]; refresh: Promise<{ files: TripFile[] } | null> }> {
|
async list(tripId: number | string): Promise<{ files: TripFile[] }> {
|
||||||
const cached = await offlineDb.tripFiles
|
if (!navigator.onLine) {
|
||||||
.where('trip_id')
|
const cached = await offlineDb.tripFiles
|
||||||
.equals(Number(tripId))
|
.where('trip_id')
|
||||||
.toArray()
|
.equals(Number(tripId))
|
||||||
|
.toArray()
|
||||||
const refresh = (async () => {
|
return { files: cached }
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
await mutationQueue.enqueue({
|
const result = await filesApi.list(tripId)
|
||||||
id: generateUUID(),
|
upsertTripFiles(result.files)
|
||||||
tripId: Number(tripId),
|
return result
|
||||||
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 }
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,81 +4,85 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
|||||||
import type { PackingItem } from '../types'
|
import type { PackingItem } from '../types'
|
||||||
|
|
||||||
export const packingRepo = {
|
export const packingRepo = {
|
||||||
async list(tripId: number | string): Promise<{ items: PackingItem[]; refresh: Promise<{ items: PackingItem[] } | null> }> {
|
async list(tripId: number | string): Promise<{ items: PackingItem[] }> {
|
||||||
const cached = await offlineDb.packingItems
|
if (!navigator.onLine) {
|
||||||
.where('trip_id')
|
const cached = await offlineDb.packingItems
|
||||||
.equals(Number(tripId))
|
.where('trip_id')
|
||||||
.toArray()
|
.equals(Number(tripId))
|
||||||
|
.toArray()
|
||||||
const refresh = (async () => {
|
return { items: cached }
|
||||||
if (!navigator.onLine) return null
|
}
|
||||||
try {
|
const result = await packingApi.list(tripId)
|
||||||
const result = await packingApi.list(tripId)
|
upsertPackingItems(result.items)
|
||||||
upsertPackingItems(result.items)
|
return result
|
||||||
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 }> {
|
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
|
||||||
const tempId = -(Date.now())
|
if (!navigator.onLine) {
|
||||||
const tempItem: PackingItem = {
|
const tempId = -(Date.now())
|
||||||
...(data as Partial<PackingItem>),
|
const tempItem: PackingItem = {
|
||||||
id: tempId,
|
...(data as Partial<PackingItem>),
|
||||||
trip_id: Number(tripId),
|
id: tempId,
|
||||||
name: (data.name as string) ?? 'New item',
|
trip_id: Number(tripId),
|
||||||
checked: 0,
|
name: (data.name as string) ?? 'New item',
|
||||||
} as PackingItem
|
checked: 0,
|
||||||
await offlineDb.packingItems.put(tempItem)
|
} as PackingItem
|
||||||
await mutationQueue.enqueue({
|
await offlineDb.packingItems.put(tempItem)
|
||||||
id: generateUUID(),
|
const id = generateUUID()
|
||||||
tripId: Number(tripId),
|
await mutationQueue.enqueue({
|
||||||
method: 'POST',
|
id,
|
||||||
url: `/trips/${tripId}/packing`,
|
tripId: Number(tripId),
|
||||||
body: data,
|
method: 'POST',
|
||||||
resource: 'packingItems',
|
url: `/trips/${tripId}/packing`,
|
||||||
tempId,
|
body: data,
|
||||||
})
|
resource: 'packingItems',
|
||||||
mutationQueue.flush().catch(() => {})
|
tempId,
|
||||||
return { item: tempItem }
|
})
|
||||||
|
return { item: tempItem }
|
||||||
|
}
|
||||||
|
const result = await packingApi.create(tripId, data)
|
||||||
|
offlineDb.packingItems.put(result.item)
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
|
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
|
||||||
const existing = await offlineDb.packingItems.get(id)
|
if (!navigator.onLine) {
|
||||||
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
|
const existing = await offlineDb.packingItems.get(id)
|
||||||
await offlineDb.packingItems.put(optimistic)
|
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
|
||||||
await mutationQueue.enqueue({
|
await offlineDb.packingItems.put(optimistic)
|
||||||
id: generateUUID(),
|
const mutId = generateUUID()
|
||||||
tripId: Number(tripId),
|
await mutationQueue.enqueue({
|
||||||
method: 'PUT',
|
id: mutId,
|
||||||
url: `/trips/${tripId}/packing/${id}`,
|
tripId: Number(tripId),
|
||||||
body: data,
|
method: 'PUT',
|
||||||
resource: 'packingItems',
|
url: `/trips/${tripId}/packing/${id}`,
|
||||||
})
|
body: data,
|
||||||
mutationQueue.flush().catch(() => {})
|
resource: 'packingItems',
|
||||||
return { item: optimistic }
|
})
|
||||||
|
return { item: optimistic }
|
||||||
|
}
|
||||||
|
const result = await packingApi.update(tripId, id, data)
|
||||||
|
offlineDb.packingItems.put(result.item)
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(tripId: number | string, id: number): Promise<unknown> {
|
async delete(tripId: number | string, id: number): Promise<unknown> {
|
||||||
await offlineDb.packingItems.delete(id)
|
if (!navigator.onLine) {
|
||||||
await mutationQueue.enqueue({
|
await offlineDb.packingItems.delete(id)
|
||||||
id: generateUUID(),
|
const mutId = generateUUID()
|
||||||
tripId: Number(tripId),
|
await mutationQueue.enqueue({
|
||||||
method: 'DELETE',
|
id: mutId,
|
||||||
url: `/trips/${tripId}/packing/${id}`,
|
tripId: Number(tripId),
|
||||||
body: undefined,
|
method: 'DELETE',
|
||||||
resource: 'packingItems',
|
url: `/trips/${tripId}/packing/${id}`,
|
||||||
entityId: id,
|
body: undefined,
|
||||||
})
|
resource: 'packingItems',
|
||||||
mutationQueue.flush().catch(() => {})
|
entityId: id,
|
||||||
return { success: true }
|
})
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
const result = await packingApi.delete(tripId, id)
|
||||||
|
offlineDb.packingItems.delete(id)
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,97 +4,106 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
|||||||
import type { Place } from '../types'
|
import type { Place } from '../types'
|
||||||
|
|
||||||
export const placeRepo = {
|
export const placeRepo = {
|
||||||
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[]; refresh: Promise<{ places: Place[] } | null> }> {
|
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> {
|
||||||
const cached = await offlineDb.places
|
if (!navigator.onLine) {
|
||||||
.where('trip_id')
|
const cached = await offlineDb.places
|
||||||
.equals(Number(tripId))
|
.where('trip_id')
|
||||||
.toArray()
|
.equals(Number(tripId))
|
||||||
|
.toArray()
|
||||||
const refresh = (async () => {
|
return { places: cached }
|
||||||
if (!navigator.onLine) return null
|
}
|
||||||
try {
|
const result = await placesApi.list(tripId, params)
|
||||||
const result = await placesApi.list(tripId, params)
|
upsertPlaces(result.places)
|
||||||
upsertPlaces(result.places)
|
return result
|
||||||
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 }> {
|
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
|
||||||
const tempId = -(Date.now())
|
if (!navigator.onLine) {
|
||||||
const tempPlace: Place = {
|
const tempId = -(Date.now())
|
||||||
...(data as Partial<Place>),
|
const tempPlace: Place = {
|
||||||
id: tempId,
|
...(data as Partial<Place>),
|
||||||
trip_id: Number(tripId),
|
id: tempId,
|
||||||
name: (data.name as string) ?? 'New place',
|
trip_id: Number(tripId),
|
||||||
} as Place
|
name: (data.name as string) ?? 'New place',
|
||||||
await offlineDb.places.put(tempPlace)
|
} as Place
|
||||||
await mutationQueue.enqueue({
|
await offlineDb.places.put(tempPlace)
|
||||||
id: generateUUID(),
|
const id = generateUUID()
|
||||||
tripId: Number(tripId),
|
await mutationQueue.enqueue({
|
||||||
method: 'POST',
|
id,
|
||||||
url: `/trips/${tripId}/places`,
|
tripId: Number(tripId),
|
||||||
body: data,
|
method: 'POST',
|
||||||
resource: 'places',
|
url: `/trips/${tripId}/places`,
|
||||||
tempId,
|
body: data,
|
||||||
})
|
resource: 'places',
|
||||||
mutationQueue.flush().catch(() => {})
|
tempId,
|
||||||
return { place: tempPlace }
|
})
|
||||||
|
return { place: tempPlace }
|
||||||
|
}
|
||||||
|
const result = await placesApi.create(tripId, data)
|
||||||
|
offlineDb.places.put(result.place)
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(tripId: number | string, id: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
|
async update(tripId: number | string, id: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
|
||||||
const existing = await offlineDb.places.get(Number(id))
|
if (!navigator.onLine) {
|
||||||
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
|
const existing = await offlineDb.places.get(Number(id))
|
||||||
await offlineDb.places.put(optimistic)
|
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
|
||||||
await mutationQueue.enqueue({
|
await offlineDb.places.put(optimistic)
|
||||||
id: generateUUID(),
|
const mutId = generateUUID()
|
||||||
tripId: Number(tripId),
|
await mutationQueue.enqueue({
|
||||||
method: 'PUT',
|
id: mutId,
|
||||||
url: `/trips/${tripId}/places/${id}`,
|
tripId: Number(tripId),
|
||||||
body: data,
|
method: 'PUT',
|
||||||
resource: 'places',
|
url: `/trips/${tripId}/places/${id}`,
|
||||||
})
|
body: data,
|
||||||
mutationQueue.flush().catch(() => {})
|
resource: 'places',
|
||||||
return { place: optimistic }
|
})
|
||||||
|
return { place: optimistic }
|
||||||
|
}
|
||||||
|
const result = await placesApi.update(tripId, id, data)
|
||||||
|
offlineDb.places.put(result.place)
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(tripId: number | string, id: number | string): Promise<unknown> {
|
async delete(tripId: number | string, id: number | string): Promise<unknown> {
|
||||||
await offlineDb.places.delete(Number(id))
|
if (!navigator.onLine) {
|
||||||
await mutationQueue.enqueue({
|
await offlineDb.places.delete(Number(id))
|
||||||
id: generateUUID(),
|
const mutId = 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({
|
await mutationQueue.enqueue({
|
||||||
id: generateUUID(),
|
id: mutId,
|
||||||
tripId: Number(tripId),
|
tripId: Number(tripId),
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
url: `/trips/${tripId}/places/${id}`,
|
url: `/trips/${tripId}/places/${id}`,
|
||||||
body: undefined,
|
body: undefined,
|
||||||
resource: 'places',
|
resource: 'places',
|
||||||
entityId: id,
|
entityId: Number(id),
|
||||||
})
|
})
|
||||||
|
return { success: true }
|
||||||
}
|
}
|
||||||
mutationQueue.flush().catch(() => {})
|
const result = await placesApi.delete(tripId, id)
|
||||||
return { deleted: ids, count: ids.length }
|
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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,18 @@
|
|||||||
import { reservationsApi } from '../api/client'
|
import { reservationsApi } from '../api/client'
|
||||||
import { offlineDb, upsertReservations } from '../db/offlineDb'
|
import { offlineDb, upsertReservations } from '../db/offlineDb'
|
||||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
|
||||||
import type { Reservation } from '../types'
|
import type { Reservation } from '../types'
|
||||||
|
|
||||||
export const reservationRepo = {
|
export const reservationRepo = {
|
||||||
async list(tripId: number | string): Promise<{ reservations: Reservation[]; refresh: Promise<{ reservations: Reservation[] } | null> }> {
|
async list(tripId: number | string): Promise<{ reservations: Reservation[] }> {
|
||||||
const cached = await offlineDb.reservations
|
if (!navigator.onLine) {
|
||||||
.where('trip_id')
|
const cached = await offlineDb.reservations
|
||||||
.equals(Number(tripId))
|
.where('trip_id')
|
||||||
.toArray()
|
.equals(Number(tripId))
|
||||||
|
.toArray()
|
||||||
const refresh = (async () => {
|
return { reservations: cached }
|
||||||
if (!navigator.onLine) return null
|
}
|
||||||
try {
|
const result = await reservationsApi.list(tripId)
|
||||||
const result = await reservationsApi.list(tripId)
|
upsertReservations(result.reservations)
|
||||||
upsertReservations(result.reservations)
|
return result
|
||||||
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 }
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,18 @@
|
|||||||
import { todoApi } from '../api/client'
|
import { todoApi } from '../api/client'
|
||||||
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
|
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
|
||||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
|
||||||
import type { TodoItem } from '../types'
|
import type { TodoItem } from '../types'
|
||||||
|
|
||||||
export const todoRepo = {
|
export const todoRepo = {
|
||||||
async list(tripId: number | string): Promise<{ items: TodoItem[]; refresh: Promise<{ items: TodoItem[] } | null> }> {
|
async list(tripId: number | string): Promise<{ items: TodoItem[] }> {
|
||||||
const cached = await offlineDb.todoItems
|
if (!navigator.onLine) {
|
||||||
.where('trip_id')
|
const cached = await offlineDb.todoItems
|
||||||
.equals(Number(tripId))
|
.where('trip_id')
|
||||||
.toArray()
|
.equals(Number(tripId))
|
||||||
|
.toArray()
|
||||||
const refresh = (async () => {
|
return { items: cached }
|
||||||
if (!navigator.onLine) return null
|
}
|
||||||
try {
|
const result = await todoApi.list(tripId)
|
||||||
const result = await todoApi.list(tripId)
|
upsertTodoItems(result.items)
|
||||||
upsertTodoItems(result.items)
|
return result
|
||||||
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 }
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,33 @@
|
|||||||
import { tripsApi } from '../api/client'
|
import { tripsApi } from '../api/client'
|
||||||
import { offlineDb, upsertTrip } from '../db/offlineDb'
|
import { offlineDb, upsertTrip } from '../db/offlineDb'
|
||||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
|
||||||
import type { Trip } from '../types'
|
import type { Trip } from '../types'
|
||||||
|
|
||||||
type TripsRefresh = Promise<{ trips: Trip[]; archivedTrips: Trip[] } | null>
|
|
||||||
type TripRefresh = Promise<{ trip: Trip } | null>
|
|
||||||
|
|
||||||
export const tripRepo = {
|
export const tripRepo = {
|
||||||
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[]; refresh: TripsRefresh }> {
|
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> {
|
||||||
const all = await offlineDb.trips.toArray()
|
if (!navigator.onLine) {
|
||||||
|
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 {
|
return {
|
||||||
trips: all.filter(t => !t.is_archived),
|
trips: all.filter(t => !t.is_archived),
|
||||||
archivedTrips: all.filter(t => t.is_archived),
|
archivedTrips: all.filter(t => t.is_archived),
|
||||||
refresh,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const [active, archived] = await Promise.all([
|
||||||
const fresh = await refresh
|
tripsApi.list(),
|
||||||
if (!fresh) return { trips: [], archivedTrips: [], refresh: Promise.resolve(null) }
|
tripsApi.list({ archived: 1 }),
|
||||||
return { ...fresh, refresh: Promise.resolve(fresh) }
|
])
|
||||||
|
active.trips.forEach(t => upsertTrip(t))
|
||||||
|
archived.trips.forEach(t => upsertTrip(t))
|
||||||
|
return { trips: active.trips, archivedTrips: archived.trips }
|
||||||
},
|
},
|
||||||
|
|
||||||
async get(tripId: number | string): Promise<{ trip: Trip; refresh: TripRefresh }> {
|
async get(tripId: number | string): Promise<{ trip: Trip }> {
|
||||||
const cached = await offlineDb.trips.get(Number(tripId))
|
if (!navigator.onLine) {
|
||||||
|
const cached = await offlineDb.trips.get(Number(tripId))
|
||||||
const refresh: TripRefresh = (async () => {
|
if (cached) return { trip: cached }
|
||||||
if (!navigator.onLine) return null
|
throw new Error('No cached trip data available offline')
|
||||||
try {
|
}
|
||||||
const result = await tripsApi.get(tripId)
|
const result = await tripsApi.get(tripId)
|
||||||
upsertTrip(result.trip)
|
upsertTrip(result.trip)
|
||||||
return result
|
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,6 +1,4 @@
|
|||||||
import { assignmentsApi } from '../../api/client'
|
import { assignmentsApi } from '../../api/client'
|
||||||
import { offlineDb } from '../../db/offlineDb'
|
|
||||||
import { mutationQueue, generateUUID } from '../../sync/mutationQueue'
|
|
||||||
import type { StoreApi } from 'zustand'
|
import type { StoreApi } from 'zustand'
|
||||||
import type { TripStoreState } from '../tripStore'
|
import type { TripStoreState } from '../tripStore'
|
||||||
import type { Assignment, AssignmentsMap } from '../../types'
|
import type { Assignment, AssignmentsMap } from '../../types'
|
||||||
@@ -42,23 +40,6 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (!navigator.onLine) {
|
|
||||||
const day = await offlineDb.days.get(parseInt(String(dayId)))
|
|
||||||
if (day) {
|
|
||||||
const updated = [...(day.assignments || [])]
|
|
||||||
updated.splice(insertIdx, 0, tempAssignment)
|
|
||||||
await offlineDb.days.put({ ...day, assignments: updated })
|
|
||||||
}
|
|
||||||
await mutationQueue.enqueue({
|
|
||||||
id: generateUUID(),
|
|
||||||
tripId: Number(tripId),
|
|
||||||
method: 'POST',
|
|
||||||
url: `/trips/${tripId}/days/${dayId}/assignments`,
|
|
||||||
body: { place_id: placeId },
|
|
||||||
})
|
|
||||||
return tempAssignment
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
|
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
|
||||||
const newAssignment: Assignment = {
|
const newAssignment: Assignment = {
|
||||||
@@ -118,24 +99,6 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (!navigator.onLine) {
|
|
||||||
const day = await offlineDb.days.get(parseInt(String(dayId)))
|
|
||||||
if (day) {
|
|
||||||
await offlineDb.days.put({
|
|
||||||
...day,
|
|
||||||
assignments: (day.assignments || []).filter(a => a.id !== assignmentId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await mutationQueue.enqueue({
|
|
||||||
id: generateUUID(),
|
|
||||||
tripId: Number(tripId),
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/trips/${tripId}/days/${dayId}/assignments/${assignmentId}`,
|
|
||||||
body: undefined,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await assignmentsApi.delete(tripId, dayId, assignmentId)
|
await assignmentsApi.delete(tripId, dayId, assignmentId)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ import { server } from '../../../tests/helpers/msw/server';
|
|||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildBudgetItem } from '../../../tests/helpers/factories';
|
import { buildBudgetItem } from '../../../tests/helpers/factories';
|
||||||
import { useTripStore } from '../tripStore';
|
import { useTripStore } from '../tripStore';
|
||||||
import { offlineDb } from '../../db/offlineDb';
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
||||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
server.resetHandlers();
|
server.resetHandlers();
|
||||||
});
|
});
|
||||||
@@ -37,28 +34,25 @@ describe('budgetSlice', () => {
|
|||||||
expect(useTripStore.getState().budgetItems).toEqual([]);
|
expect(useTripStore.getState().budgetItems).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-STORE-BUDGET-003: addBudgetItem appends to store optimistically', async () => {
|
it('FE-STORE-BUDGET-003: addBudgetItem appends to store and returns item', async () => {
|
||||||
|
const newItem = buildBudgetItem({ name: 'Hotel', trip_id: 1 });
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/trips/1/budget', () =>
|
http.post('/api/trips/1/budget', () =>
|
||||||
HttpResponse.json({ item: buildBudgetItem({ name: 'Hotel', trip_id: 1 }) })
|
HttpResponse.json({ item: newItem })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel' });
|
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel' });
|
||||||
expect(result.name).toBe('Hotel');
|
expect(result.id).toBe(newItem.id);
|
||||||
const items = useTripStore.getState().budgetItems;
|
expect(useTripStore.getState().budgetItems).toContainEqual(newItem);
|
||||||
expect(items).toHaveLength(1);
|
|
||||||
expect(items[0].name).toBe('Hotel');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-STORE-BUDGET-004: addBudgetItem adds item optimistically even on API error', async () => {
|
it('FE-STORE-BUDGET-004: addBudgetItem throws on API error', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/trips/1/budget', () =>
|
http.post('/api/trips/1/budget', () =>
|
||||||
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
|
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Item' });
|
await expect(useTripStore.getState().addBudgetItem(1, {})).rejects.toThrow();
|
||||||
expect(result.name).toBe('Item');
|
|
||||||
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
|
it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
|
||||||
@@ -77,21 +71,24 @@ describe('budgetSlice', () => {
|
|||||||
expect(items[0].name).toBe('New');
|
expect(items[0].name).toBe('New');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-STORE-BUDGET-006: updateBudgetItem resolves and updates store optimistically', async () => {
|
it('FE-STORE-BUDGET-006: updateBudgetItem calls loadReservations when reservation_id + total_price provided', async () => {
|
||||||
const existing = buildBudgetItem({ id: 20, trip_id: 1, amount: 100 });
|
const existing = buildBudgetItem({ id: 20, trip_id: 1 });
|
||||||
seedStore(useTripStore, { budgetItems: [existing] });
|
seedStore(useTripStore, { budgetItems: [existing] });
|
||||||
|
|
||||||
|
const loadReservations = vi.fn().mockResolvedValue(undefined);
|
||||||
|
seedStore(useTripStore, { loadReservations });
|
||||||
|
|
||||||
|
const itemWithReservation = { ...existing, reservation_id: 99 };
|
||||||
server.use(
|
server.use(
|
||||||
http.put('/api/trips/1/budget/20', () =>
|
http.put('/api/trips/1/budget/20', () =>
|
||||||
HttpResponse.json({ item: { ...existing, amount: 50 } })
|
HttpResponse.json({ item: itemWithReservation })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const result = await useTripStore.getState().updateBudgetItem(1, 20, { amount: 50 });
|
await useTripStore.getState().updateBudgetItem(1, 20, { total_price: 50 });
|
||||||
expect(result.amount).toBe(50);
|
expect(loadReservations).toHaveBeenCalledWith(1);
|
||||||
expect(useTripStore.getState().budgetItems[0].amount).toBe(50);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-STORE-BUDGET-007: deleteBudgetItem removes item permanently even on API error', async () => {
|
it('FE-STORE-BUDGET-007: deleteBudgetItem optimistically removes and rolls back on error', async () => {
|
||||||
const item = buildBudgetItem({ id: 5, trip_id: 1 });
|
const item = buildBudgetItem({ id: 5, trip_id: 1 });
|
||||||
seedStore(useTripStore, { budgetItems: [item] });
|
seedStore(useTripStore, { budgetItems: [item] });
|
||||||
|
|
||||||
@@ -100,9 +97,11 @@ describe('budgetSlice', () => {
|
|||||||
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
|
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
await useTripStore.getState().deleteBudgetItem(1, 5);
|
// The item is removed immediately (optimistic), then restored on error
|
||||||
// Permanently removed (queued for sync, no rollback)
|
const deletePromise = useTripStore.getState().deleteBudgetItem(1, 5);
|
||||||
expect(useTripStore.getState().budgetItems).toHaveLength(0);
|
await expect(deletePromise).rejects.toThrow();
|
||||||
|
// After rollback, item is back
|
||||||
|
expect(useTripStore.getState().budgetItems).toContainEqual(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-STORE-BUDGET-008: setBudgetItemMembers updates members on matching item', async () => {
|
it('FE-STORE-BUDGET-008: setBudgetItemMembers updates members on matching item', async () => {
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
|||||||
try {
|
try {
|
||||||
const data = await budgetRepo.list(tripId)
|
const data = await budgetRepo.list(tripId)
|
||||||
set({ budgetItems: data.items })
|
set({ budgetItems: data.items })
|
||||||
data.refresh.then(fresh => {
|
|
||||||
if (fresh) set({ budgetItems: fresh.items })
|
|
||||||
}).catch(() => {})
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to load budget items:', err)
|
console.error('Failed to load budget items:', err)
|
||||||
}
|
}
|
||||||
@@ -34,7 +31,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
|||||||
|
|
||||||
addBudgetItem: async (tripId, data) => {
|
addBudgetItem: async (tripId, data) => {
|
||||||
try {
|
try {
|
||||||
const result = await budgetRepo.create(tripId, data as Record<string, unknown>)
|
const result = await budgetApi.create(tripId, data)
|
||||||
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
||||||
return result.item
|
return result.item
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -44,7 +41,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
|||||||
|
|
||||||
updateBudgetItem: async (tripId, id, data) => {
|
updateBudgetItem: async (tripId, id, data) => {
|
||||||
try {
|
try {
|
||||||
const result = await budgetRepo.update(tripId, id, data as Record<string, unknown>)
|
const result = await budgetApi.update(tripId, id, data)
|
||||||
set(state => ({
|
set(state => ({
|
||||||
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
||||||
}))
|
}))
|
||||||
@@ -61,7 +58,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
|||||||
const prev = get().budgetItems
|
const prev = get().budgetItems
|
||||||
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
|
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
|
||||||
try {
|
try {
|
||||||
await budgetRepo.delete(tripId, id)
|
await budgetApi.delete(tripId, id)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
set({ budgetItems: prev })
|
set({ budgetItems: prev })
|
||||||
throw new Error(getApiErrorMessage(err, 'Error deleting budget item'))
|
throw new Error(getApiErrorMessage(err, 'Error deleting budget item'))
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { dayNotesApi } from '../../api/client'
|
import { daysApi, 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 { StoreApi } from 'zustand'
|
||||||
import type { TripStoreState } from '../tripStore'
|
import type { TripStoreState } from '../tripStore'
|
||||||
import type { DayNote } from '../../types'
|
import type { DayNote } from '../../types'
|
||||||
@@ -22,7 +19,7 @@ export interface DayNotesSlice {
|
|||||||
export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({
|
export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({
|
||||||
updateDayNotes: async (tripId, dayId, notes) => {
|
updateDayNotes: async (tripId, dayId, notes) => {
|
||||||
try {
|
try {
|
||||||
await dayRepo.update(tripId, dayId, { notes })
|
await daysApi.update(tripId, dayId, { notes })
|
||||||
set(state => ({
|
set(state => ({
|
||||||
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, notes } : d)
|
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, notes } : d)
|
||||||
}))
|
}))
|
||||||
@@ -33,7 +30,7 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
|
|||||||
|
|
||||||
updateDayTitle: async (tripId, dayId, title) => {
|
updateDayTitle: async (tripId, dayId, title) => {
|
||||||
try {
|
try {
|
||||||
await dayRepo.update(tripId, dayId, { title })
|
await daysApi.update(tripId, dayId, { title })
|
||||||
set(state => ({
|
set(state => ({
|
||||||
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, title } : d)
|
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, title } : d)
|
||||||
}))
|
}))
|
||||||
@@ -51,22 +48,6 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
|
|||||||
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote],
|
[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 {
|
try {
|
||||||
const result = await dayNotesApi.create(tripId, dayId, data)
|
const result = await dayNotesApi.create(tripId, dayId, data)
|
||||||
set(state => ({
|
set(state => ({
|
||||||
@@ -88,32 +69,6 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateDayNote: async (tripId, dayId, id, data) => {
|
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 {
|
try {
|
||||||
const result = await dayNotesApi.update(tripId, dayId, id, data)
|
const result = await dayNotesApi.update(tripId, dayId, id, data)
|
||||||
set(state => ({
|
set(state => ({
|
||||||
@@ -136,25 +91,6 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
|
|||||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
|
[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 {
|
try {
|
||||||
await dayNotesApi.delete(tripId, dayId, id)
|
await dayNotesApi.delete(tripId, dayId, id)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -35,12 +35,10 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteFile: async (tripId, id) => {
|
deleteFile: async (tripId, id) => {
|
||||||
const prev = get().files
|
|
||||||
set(state => ({ files: state.files.filter(f => f.id !== id) }))
|
|
||||||
try {
|
try {
|
||||||
await fileRepo.delete(tripId, id)
|
await filesApi.delete(tripId, id)
|
||||||
|
set(state => ({ files: state.files.filter(f => f.id !== id) }))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
set({ files: prev })
|
|
||||||
throw new Error(getApiErrorMessage(err, 'Error deleting file'))
|
throw new Error(getApiErrorMessage(err, 'Error deleting file'))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
|||||||
try {
|
try {
|
||||||
const data = await placeRepo.list(tripId)
|
const data = await placeRepo.list(tripId)
|
||||||
set({ places: data.places })
|
set({ places: data.places })
|
||||||
data.refresh.then(fresh => {
|
|
||||||
if (fresh) set({ places: fresh.places })
|
|
||||||
}).catch(() => {})
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to refresh places:', err)
|
console.error('Failed to refresh places:', err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { reservationsApi } from '../../api/client'
|
||||||
import { reservationRepo } from '../../repo/reservationRepo'
|
import { reservationRepo } from '../../repo/reservationRepo'
|
||||||
import type { StoreApi } from 'zustand'
|
import type { StoreApi } from 'zustand'
|
||||||
import type { TripStoreState } from '../tripStore'
|
import type { TripStoreState } from '../tripStore'
|
||||||
@@ -27,7 +28,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
|
|||||||
|
|
||||||
addReservation: async (tripId, data) => {
|
addReservation: async (tripId, data) => {
|
||||||
try {
|
try {
|
||||||
const result = await reservationRepo.create(tripId, data as Record<string, unknown>)
|
const result = await reservationsApi.create(tripId, data)
|
||||||
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
|
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
|
||||||
return result.reservation
|
return result.reservation
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -37,7 +38,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
|
|||||||
|
|
||||||
updateReservation: async (tripId, id, data) => {
|
updateReservation: async (tripId, id, data) => {
|
||||||
try {
|
try {
|
||||||
const result = await reservationRepo.update(tripId, id, data as Record<string, unknown>)
|
const result = await reservationsApi.update(tripId, id, data)
|
||||||
set(state => ({
|
set(state => ({
|
||||||
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
|
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
|
||||||
}))
|
}))
|
||||||
@@ -56,19 +57,17 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
|
|||||||
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
|
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
|
||||||
}))
|
}))
|
||||||
try {
|
try {
|
||||||
await reservationRepo.update(tripId, id, { status: newStatus })
|
await reservationsApi.update(tripId, id, { status: newStatus })
|
||||||
} catch {
|
} catch {
|
||||||
set({ reservations: prev })
|
set({ reservations: prev })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteReservation: async (tripId, id) => {
|
deleteReservation: async (tripId, id) => {
|
||||||
const prev = get().reservations
|
|
||||||
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
|
|
||||||
try {
|
try {
|
||||||
await reservationRepo.delete(tripId, id)
|
await reservationsApi.delete(tripId, id)
|
||||||
|
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
set({ reservations: prev })
|
|
||||||
throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
|
throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { todoRepo } from '../../repo/todoRepo'
|
import { todoApi } from '../../api/client'
|
||||||
import type { StoreApi } from 'zustand'
|
import type { StoreApi } from 'zustand'
|
||||||
import type { TripStoreState } from '../tripStore'
|
import type { TripStoreState } from '../tripStore'
|
||||||
import type { TodoItem } from '../../types'
|
import type { TodoItem } from '../../types'
|
||||||
@@ -17,7 +17,7 @@ export interface TodoSlice {
|
|||||||
export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
|
export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
|
||||||
addTodoItem: async (tripId, data) => {
|
addTodoItem: async (tripId, data) => {
|
||||||
try {
|
try {
|
||||||
const result = await todoRepo.create(tripId, data as Record<string, unknown>)
|
const result = await todoApi.create(tripId, data)
|
||||||
set(state => ({ todoItems: [...state.todoItems, result.item] }))
|
set(state => ({ todoItems: [...state.todoItems, result.item] }))
|
||||||
return result.item
|
return result.item
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -27,7 +27,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
|
|||||||
|
|
||||||
updateTodoItem: async (tripId, id, data) => {
|
updateTodoItem: async (tripId, id, data) => {
|
||||||
try {
|
try {
|
||||||
const result = await todoRepo.update(tripId, id, data as Record<string, unknown>)
|
const result = await todoApi.update(tripId, id, data)
|
||||||
set(state => ({
|
set(state => ({
|
||||||
todoItems: state.todoItems.map(item => item.id === id ? result.item : item)
|
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
|
const prev = get().todoItems
|
||||||
set(state => ({ todoItems: state.todoItems.filter(item => item.id !== id) }))
|
set(state => ({ todoItems: state.todoItems.filter(item => item.id !== id) }))
|
||||||
try {
|
try {
|
||||||
await todoRepo.delete(tripId, id)
|
await todoApi.delete(tripId, id)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
set({ todoItems: prev })
|
set({ todoItems: prev })
|
||||||
throw new Error(getApiErrorMessage(err, 'Error deleting todo'))
|
throw new Error(getApiErrorMessage(err, 'Error deleting todo'))
|
||||||
@@ -55,7 +55,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
|
|||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
try {
|
try {
|
||||||
await todoRepo.update(tripId, id, { checked })
|
await todoApi.update(tripId, id, { checked })
|
||||||
} catch {
|
} catch {
|
||||||
set(state => ({
|
set(state => ({
|
||||||
todoItems: state.todoItems.map(item =>
|
todoItems: state.todoItems.map(item =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { StoreApi } from 'zustand'
|
import type { StoreApi } from 'zustand'
|
||||||
import { tagsApi, categoriesApi } from '../api/client'
|
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
|
||||||
import { offlineDb, upsertTags, upsertCategories } from '../db/offlineDb'
|
import { offlineDb } from '../db/offlineDb'
|
||||||
import { tripRepo } from '../repo/tripRepo'
|
import { tripRepo } from '../repo/tripRepo'
|
||||||
import { dayRepo } from '../repo/dayRepo'
|
import { dayRepo } from '../repo/dayRepo'
|
||||||
import { placeRepo } from '../repo/placeRepo'
|
import { placeRepo } from '../repo/placeRepo'
|
||||||
@@ -89,38 +89,27 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
|||||||
loadTrip: async (tripId: number | string) => {
|
loadTrip: async (tripId: number | string) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
// Fire tags/categories network refresh immediately — they're global (not trip-specific)
|
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
|
||||||
// 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),
|
tripRepo.get(tripId),
|
||||||
dayRepo.list(tripId),
|
dayRepo.list(tripId),
|
||||||
placeRepo.list(tripId),
|
placeRepo.list(tripId),
|
||||||
packingRepo.list(tripId),
|
packingRepo.list(tripId),
|
||||||
todoRepo.list(tripId),
|
todoRepo.list(tripId),
|
||||||
offlineDb.tags.toArray(),
|
navigator.onLine
|
||||||
offlineDb.categories.toArray(),
|
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags })))
|
||||||
|
: offlineDb.tags.toArray().then(tags => ({ tags })),
|
||||||
|
navigator.onLine
|
||||||
|
? categoriesApi.list().catch(() => offlineDb.categories.toArray().then(categories => ({ categories })))
|
||||||
|
: offlineDb.categories.toArray().then(categories => ({ categories })),
|
||||||
])
|
])
|
||||||
|
|
||||||
const buildMaps = (days: Day[]) => {
|
const assignmentsMap: AssignmentsMap = {}
|
||||||
const assignmentsMap: AssignmentsMap = {}
|
const dayNotesMap: DayNotesMap = {}
|
||||||
const dayNotesMap: DayNotesMap = {}
|
for (const day of daysData.days) {
|
||||||
for (const day of days) {
|
assignmentsMap[String(day.id)] = day.assignments || []
|
||||||
assignmentsMap[String(day.id)] = day.assignments || []
|
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||||
dayNotesMap[String(day.id)] = day.notes_items || []
|
|
||||||
}
|
|
||||||
return { assignmentsMap, dayNotesMap }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { assignmentsMap, dayNotesMap } = buildMaps(daysData.days)
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
trip: tripData.trip,
|
trip: tripData.trip,
|
||||||
days: daysData.days,
|
days: daysData.days,
|
||||||
@@ -129,36 +118,10 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
|||||||
dayNotes: dayNotesMap,
|
dayNotes: dayNotesMap,
|
||||||
packingItems: packingData.items,
|
packingItems: packingData.items,
|
||||||
todoItems: todoData.items,
|
todoItems: todoData.items,
|
||||||
tags: cachedTags,
|
tags: tagsData.tags,
|
||||||
categories: cachedCategories,
|
categories: categoriesData.categories,
|
||||||
isLoading: false,
|
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) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
set({ isLoading: false, error: message })
|
set({ isLoading: false, error: message })
|
||||||
@@ -183,18 +146,16 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
|||||||
|
|
||||||
updateTrip: async (tripId: number | string, data: Partial<Trip>) => {
|
updateTrip: async (tripId: number | string, data: Partial<Trip>) => {
|
||||||
try {
|
try {
|
||||||
const result = await tripRepo.update(tripId, data)
|
const result = await tripsApi.update(tripId, data)
|
||||||
set({ trip: result.trip })
|
set({ trip: result.trip })
|
||||||
if (navigator.onLine) {
|
const daysData = await dayRepo.list(tripId)
|
||||||
const daysData = await dayRepo.list(tripId)
|
const assignmentsMap: AssignmentsMap = {}
|
||||||
const assignmentsMap: AssignmentsMap = {}
|
const dayNotesMap: DayNotesMap = {}
|
||||||
const dayNotesMap: DayNotesMap = {}
|
for (const day of daysData.days) {
|
||||||
for (const day of daysData.days) {
|
assignmentsMap[String(day.id)] = day.assignments || []
|
||||||
assignmentsMap[String(day.id)] = day.assignments || []
|
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||||
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
|
return result.trip
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
throw new Error(getApiErrorMessage(err, 'Error updating trip'))
|
throw new Error(getApiErrorMessage(err, 'Error updating trip'))
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
/// <reference lib="webworker" />
|
|
||||||
|
|
||||||
import { clientsClaim } from 'workbox-core';
|
|
||||||
import {
|
|
||||||
precacheAndRoute,
|
|
||||||
cleanupOutdatedCaches,
|
|
||||||
matchPrecache,
|
|
||||||
} from 'workbox-precaching';
|
|
||||||
import { registerRoute, NavigationRoute } from 'workbox-routing';
|
|
||||||
import { NetworkFirst, CacheFirst, 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' });
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const PROBE_INTERVAL_MS = 30_000
|
||||||
|
const PROBE_TIMEOUT_MS = 1_500
|
||||||
|
|
||||||
|
let reachable = true
|
||||||
|
const listeners = new Set<(v: boolean) => void>()
|
||||||
|
|
||||||
|
function setReachable(v: boolean): void {
|
||||||
|
if (reachable === v) return
|
||||||
|
reachable = v
|
||||||
|
listeners.forEach(fn => fn(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(): Promise<void> {
|
||||||
|
if (!navigator.onLine) { setReachable(false); return }
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS)
|
||||||
|
const res = await fetch('/api/health', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: ctrl.signal,
|
||||||
|
})
|
||||||
|
clearTimeout(t)
|
||||||
|
// /api/health returns JSON. CF Access / Pangolin will either return HTML
|
||||||
|
// (Pangolin 200 auth wall) or trigger a cross-origin redirect that throws
|
||||||
|
// below. Both proxy-auth scenarios resolve to reachable = false.
|
||||||
|
const ct = res.headers.get('content-type') || ''
|
||||||
|
setReachable(res.ok && ct.includes('application/json'))
|
||||||
|
} catch {
|
||||||
|
setReachable(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startConnectivityProbe(): void {
|
||||||
|
probe()
|
||||||
|
setInterval(probe, PROBE_INTERVAL_MS)
|
||||||
|
window.addEventListener('online', probe)
|
||||||
|
window.addEventListener('offline', () => setReachable(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReachable(): boolean { return reachable }
|
||||||
|
export function probeNow(): Promise<void> { return probe() }
|
||||||
|
export function onChange(fn: (v: boolean) => void): () => void {
|
||||||
|
listeners.add(fn)
|
||||||
|
return () => listeners.delete(fn)
|
||||||
|
}
|
||||||
@@ -13,15 +13,12 @@ import type { Table } from 'dexie'
|
|||||||
// Map Dexie table names used in `resource` field → actual Dexie tables.
|
// Map Dexie table names used in `resource` field → actual Dexie tables.
|
||||||
function getTable(resource: string): Table | undefined {
|
function getTable(resource: string): Table | undefined {
|
||||||
const map: Record<string, Table> = {
|
const map: Record<string, Table> = {
|
||||||
trips: offlineDb.trips,
|
places: offlineDb.places,
|
||||||
days: offlineDb.days,
|
packingItems: offlineDb.packingItems,
|
||||||
places: offlineDb.places,
|
todoItems: offlineDb.todoItems,
|
||||||
packingItems: offlineDb.packingItems,
|
budgetItems: offlineDb.budgetItems,
|
||||||
todoItems: offlineDb.todoItems,
|
reservations: offlineDb.reservations,
|
||||||
budgetItems: offlineDb.budgetItems,
|
tripFiles: offlineDb.tripFiles,
|
||||||
reservations: offlineDb.reservations,
|
|
||||||
accommodations: offlineDb.accommodations,
|
|
||||||
tripFiles: offlineDb.tripFiles,
|
|
||||||
}
|
}
|
||||||
return map[resource]
|
return map[resource]
|
||||||
}
|
}
|
||||||
@@ -73,14 +70,12 @@ export const mutationQueue = {
|
|||||||
if (_flushing || !navigator.onLine) return
|
if (_flushing || !navigator.onLine) return
|
||||||
_flushing = true
|
_flushing = true
|
||||||
try {
|
try {
|
||||||
while (true) {
|
const pending = await offlineDb.mutationQueue
|
||||||
const pending = await offlineDb.mutationQueue
|
.where('status')
|
||||||
.where('status')
|
.equals('pending')
|
||||||
.equals('pending')
|
.sortBy('createdAt')
|
||||||
.sortBy('createdAt')
|
|
||||||
const mutation = pending[0]
|
|
||||||
if (!mutation) break
|
|
||||||
|
|
||||||
|
for (const mutation of pending) {
|
||||||
// Mark as syncing so UI can show progress
|
// Mark as syncing so UI can show progress
|
||||||
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
|
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* SW cache configuration — shared between the service worker and the main thread.
|
|
||||||
* Uses a dedicated 'trek-sw-config' IndexedDB database (separate from trek-offline)
|
|
||||||
* so the SW can read it without needing to know the full trek-offline schema versions.
|
|
||||||
*/
|
|
||||||
import Dexie, { type Table } from 'dexie';
|
|
||||||
|
|
||||||
export interface SwCacheConfig {
|
|
||||||
apiTtlDays: number;
|
|
||||||
apiMaxEntries: number;
|
|
||||||
tilesTtlDays: number;
|
|
||||||
tilesMaxEntries: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_SW_CONFIG: SwCacheConfig = {
|
|
||||||
apiTtlDays: 7,
|
|
||||||
apiMaxEntries: 500,
|
|
||||||
tilesTtlDays: 30,
|
|
||||||
tilesMaxEntries: 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SW_CONFIG_BOUNDS = {
|
|
||||||
ttlMin: 1,
|
|
||||||
ttlMax: 365,
|
|
||||||
entriesMin: 10,
|
|
||||||
entriesMax: 5000,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function validateSwConfig(raw: Partial<SwCacheConfig>): SwCacheConfig {
|
|
||||||
const clamp = (v: unknown, min: number, max: number, def: number): number => {
|
|
||||||
const n = Number(v);
|
|
||||||
return Number.isFinite(n) && n > 0 ? Math.max(min, Math.min(max, Math.round(n))) : def;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
apiTtlDays: clamp(raw.apiTtlDays, SW_CONFIG_BOUNDS.ttlMin, SW_CONFIG_BOUNDS.ttlMax, DEFAULT_SW_CONFIG.apiTtlDays),
|
|
||||||
apiMaxEntries: clamp(raw.apiMaxEntries, SW_CONFIG_BOUNDS.entriesMin, SW_CONFIG_BOUNDS.entriesMax, DEFAULT_SW_CONFIG.apiMaxEntries),
|
|
||||||
tilesTtlDays: clamp(raw.tilesTtlDays, SW_CONFIG_BOUNDS.ttlMin, SW_CONFIG_BOUNDS.ttlMax, DEFAULT_SW_CONFIG.tilesTtlDays),
|
|
||||||
tilesMaxEntries:clamp(raw.tilesMaxEntries, SW_CONFIG_BOUNDS.entriesMin, SW_CONFIG_BOUNDS.entriesMax, DEFAULT_SW_CONFIG.tilesMaxEntries),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dedicated IDB for SW config ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface SwConfigRow extends SwCacheConfig {
|
|
||||||
id: 'singleton';
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SwConfigDb extends Dexie {
|
|
||||||
config!: Table<SwConfigRow, 'singleton'>;
|
|
||||||
constructor() {
|
|
||||||
super('trek-sw-config');
|
|
||||||
this.version(1).stores({ config: 'id' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _db: SwConfigDb | null = null;
|
|
||||||
|
|
||||||
function getDb(): SwConfigDb {
|
|
||||||
if (!_db) _db = new SwConfigDb();
|
|
||||||
return _db;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readSwConfigFromIDB(): Promise<SwCacheConfig | null> {
|
|
||||||
try {
|
|
||||||
const row = await getDb().config.get('singleton');
|
|
||||||
return row ? validateSwConfig(row) : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveSwConfig(cfg: SwCacheConfig): Promise<void> {
|
|
||||||
const validated = validateSwConfig(cfg);
|
|
||||||
await getDb().config.put({ id: 'singleton', ...validated, updatedAt: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadSwConfig(): Promise<SwCacheConfig> {
|
|
||||||
return (await readSwConfigFromIDB()) ?? { ...DEFAULT_SW_CONFIG };
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@ export interface User {
|
|||||||
|
|
||||||
export interface Trip {
|
export interface Trip {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
name: string
|
||||||
description: string | null
|
description: string | null
|
||||||
start_date: string
|
start_date: string
|
||||||
end_date: string
|
end_date: string
|
||||||
|
|||||||
@@ -66,28 +66,38 @@ describe('packingRepo.list', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('packingRepo.create', () => {
|
describe('packingRepo.create', () => {
|
||||||
it('writes item optimistically to Dexie immediately', async () => {
|
it('calls REST and caches created item in Dexie', async () => {
|
||||||
|
const item = buildPackingItem({ trip_id: 1, name: 'Sunscreen' });
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/packing', () => HttpResponse.json({ item })),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await packingRepo.create(1, { name: 'Sunscreen' });
|
const result = await packingRepo.create(1, { name: 'Sunscreen' });
|
||||||
expect(result.item.name).toBe('Sunscreen');
|
expect(result.item.name).toBe('Sunscreen');
|
||||||
// tempId is negative (-(Date.now()))
|
|
||||||
expect(result.item.id).toBeLessThan(0);
|
|
||||||
|
|
||||||
const cached = await offlineDb.packingItems.where('trip_id').equals(1).toArray();
|
await new Promise(r => setTimeout(r, 0));
|
||||||
expect(cached).toHaveLength(1);
|
const cached = await offlineDb.packingItems.get(item.id);
|
||||||
expect(cached[0].name).toBe('Sunscreen');
|
expect(cached).toBeDefined();
|
||||||
|
expect(cached!.name).toBe('Sunscreen');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('packingRepo.update', () => {
|
describe('packingRepo.update', () => {
|
||||||
it('writes optimistic update to Dexie immediately', async () => {
|
it('calls REST and updates Dexie cache', async () => {
|
||||||
const original = buildPackingItem({ trip_id: 1, name: 'Jacket', checked: 0 });
|
const original = buildPackingItem({ trip_id: 1, name: 'Jacket', checked: 0 });
|
||||||
await offlineDb.packingItems.put(original);
|
await offlineDb.packingItems.put(original);
|
||||||
|
|
||||||
const result = await packingRepo.update(1, original.id, { checked: true });
|
const updated = { ...original, checked: 1 };
|
||||||
expect(result.item.checked).toBeTruthy();
|
server.use(
|
||||||
|
http.put(`/api/trips/1/packing/${original.id}`, () => HttpResponse.json({ item: updated })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await packingRepo.update(1, original.id, { checked: true });
|
||||||
|
expect(result.item.checked).toBe(1);
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
const cached = await offlineDb.packingItems.get(original.id);
|
const cached = await offlineDb.packingItems.get(original.id);
|
||||||
expect(cached!.checked).toBeTruthy();
|
expect(cached!.checked).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,15 +67,19 @@ describe('placeRepo.list', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('placeRepo.create', () => {
|
describe('placeRepo.create', () => {
|
||||||
it('writes place optimistically to Dexie immediately', async () => {
|
it('calls REST and caches created place in Dexie', async () => {
|
||||||
|
const place = buildPlace({ trip_id: 1, name: 'Eiffel Tower' });
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/places', () => HttpResponse.json({ place })),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await placeRepo.create(1, { name: 'Eiffel Tower' });
|
const result = await placeRepo.create(1, { name: 'Eiffel Tower' });
|
||||||
expect(result.place.name).toBe('Eiffel Tower');
|
expect(result.place.name).toBe('Eiffel Tower');
|
||||||
// tempId is negative (-(Date.now()))
|
|
||||||
expect(result.place.id).toBeLessThan(0);
|
|
||||||
|
|
||||||
const cached = await offlineDb.places.where('trip_id').equals(1).toArray();
|
await new Promise(r => setTimeout(r, 0));
|
||||||
expect(cached).toHaveLength(1);
|
const cached = await offlineDb.places.get(place.id);
|
||||||
expect(cached[0].name).toBe('Eiffel Tower');
|
expect(cached).toBeDefined();
|
||||||
|
expect(cached!.name).toBe('Eiffel Tower');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { useTripStore } from '../../../src/store/tripStore';
|
import { useTripStore } from '../../../src/store/tripStore';
|
||||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||||
import { buildBudgetItem } from '../../helpers/factories';
|
import { buildBudgetItem, buildReservation } from '../../helpers/factories';
|
||||||
import { server } from '../../helpers/msw/server';
|
import { server } from '../../helpers/msw/server';
|
||||||
import { offlineDb } from '../../../src/db/offlineDb';
|
|
||||||
|
|
||||||
vi.mock('../../../src/api/websocket', () => ({
|
vi.mock('../../../src/api/websocket', () => ({
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
@@ -18,9 +17,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
|||||||
setPreReconnectHook: vi.fn(),
|
setPreReconnectHook: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
||||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,18 +49,16 @@ describe('budgetSlice', () => {
|
|||||||
expect(useTripStore.getState().budgetItems).toHaveLength(2);
|
expect(useTripStore.getState().budgetItems).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-BUDGET-003: addBudgetItem always adds item optimistically (no throw on API error)', async () => {
|
it('FE-BUDGET-003: addBudgetItem on failure throws', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/trips/1/budget', () =>
|
http.post('/api/trips/1/budget', () =>
|
||||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Fail' });
|
await expect(
|
||||||
|
useTripStore.getState().addBudgetItem(1, { name: 'Fail' })
|
||||||
expect(result.name).toBe('Fail');
|
).rejects.toThrow();
|
||||||
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
|
||||||
expect(useTripStore.getState().budgetItems[0].name).toBe('Fail');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,26 +80,38 @@ describe('budgetSlice', () => {
|
|||||||
expect(useTripStore.getState().budgetItems[0].name).toBe('Updated');
|
expect(useTripStore.getState().budgetItems[0].name).toBe('Updated');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-BUDGET-005: updateBudgetItem resolves and updates store optimistically', async () => {
|
it('FE-BUDGET-005: updateBudgetItem with total_price triggers loadReservations when reservation_id present', async () => {
|
||||||
const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', amount: 100 });
|
const item = buildBudgetItem({ id: 10, trip_id: 1, amount: 100 });
|
||||||
seedStore(useTripStore, { budgetItems: [item] });
|
const initialReservation = buildReservation({ trip_id: 1 });
|
||||||
|
const newReservation = buildReservation({ trip_id: 1, name: 'Refreshed Reservation' });
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
budgetItems: [item],
|
||||||
|
reservations: [initialReservation],
|
||||||
|
});
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.put('/api/trips/1/budget/10', async ({ request }) => {
|
http.put('/api/trips/1/budget/10', async ({ request }) => {
|
||||||
const body = await request.json() as Record<string, unknown>;
|
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 } });
|
return HttpResponse.json({ item: { ...item, ...body, reservation_id: 42 } });
|
||||||
}),
|
}),
|
||||||
|
http.get('/api/trips/1/reservations', () =>
|
||||||
|
HttpResponse.json({ reservations: [newReservation] })
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await useTripStore.getState().updateBudgetItem(1, 10, { amount: 200 } as Record<string, unknown>);
|
await useTripStore.getState().updateBudgetItem(1, 10, { total_price: 200 } as Record<string, unknown>);
|
||||||
|
|
||||||
expect(result.amount).toBe(200);
|
// Wait for the async loadReservations to complete
|
||||||
expect(useTripStore.getState().budgetItems[0].amount).toBe(200);
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(useTripStore.getState().reservations).toHaveLength(1);
|
||||||
|
expect(useTripStore.getState().reservations[0].name).toBe('Refreshed Reservation');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteBudgetItem', () => {
|
describe('deleteBudgetItem', () => {
|
||||||
it('FE-BUDGET-006: deleteBudgetItem removes item permanently even on API error', async () => {
|
it('FE-BUDGET-006: deleteBudgetItem optimistically removes item, rolls back on failure', async () => {
|
||||||
const item = buildBudgetItem({ id: 10, trip_id: 1 });
|
const item = buildBudgetItem({ id: 10, trip_id: 1 });
|
||||||
seedStore(useTripStore, { budgetItems: [item] });
|
seedStore(useTripStore, { budgetItems: [item] });
|
||||||
|
|
||||||
@@ -114,10 +121,10 @@ describe('budgetSlice', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await useTripStore.getState().deleteBudgetItem(1, 10);
|
await expect(useTripStore.getState().deleteBudgetItem(1, 10)).rejects.toThrow();
|
||||||
|
|
||||||
// Permanently removed (queued for sync, no rollback)
|
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
||||||
expect(useTripStore.getState().budgetItems).toHaveLength(0);
|
expect(useTripStore.getState().budgetItems[0].id).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-BUDGET-006b: deleteBudgetItem success removes item', async () => {
|
it('FE-BUDGET-006b: deleteBudgetItem success removes item', async () => {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ describe('filesSlice', () => {
|
|||||||
expect(files[0].id).toBe(20);
|
expect(files[0].id).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-FILES-006: deleteFile removes file permanently even on API error', async () => {
|
it('FE-FILES-006: deleteFile on failure throws', async () => {
|
||||||
const file = buildTripFile({ id: 10, trip_id: 1 });
|
const file = buildTripFile({ id: 10, trip_id: 1 });
|
||||||
seedStore(useTripStore, { files: [file] });
|
seedStore(useTripStore, { files: [file] });
|
||||||
|
|
||||||
@@ -110,10 +110,10 @@ describe('filesSlice', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await useTripStore.getState().deleteFile(1, 10);
|
await expect(useTripStore.getState().deleteFile(1, 10)).rejects.toThrow();
|
||||||
|
|
||||||
// Permanently removed (queued for sync, no rollback)
|
// File remains since server-first (only removes after success)
|
||||||
expect(useTripStore.getState().files).toHaveLength(0);
|
expect(useTripStore.getState().files).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useTripStore } from '../../../src/store/tripStore';
|
|||||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||||
import { buildPackingItem } from '../../helpers/factories';
|
import { buildPackingItem } from '../../helpers/factories';
|
||||||
import { server } from '../../helpers/msw/server';
|
import { server } from '../../helpers/msw/server';
|
||||||
import { offlineDb } from '../../../src/db/offlineDb';
|
|
||||||
|
|
||||||
vi.mock('../../../src/api/websocket', () => ({
|
vi.mock('../../../src/api/websocket', () => ({
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
@@ -18,9 +17,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
|||||||
setPreReconnectHook: vi.fn(),
|
setPreReconnectHook: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
||||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,18 +36,16 @@ describe('packingSlice', () => {
|
|||||||
expect(items[items.length - 1].name).toBe('Toothbrush');
|
expect(items[items.length - 1].name).toBe('Toothbrush');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PACKING-002: addPackingItem always adds item optimistically (no throw on API error)', async () => {
|
it('FE-PACKING-002: addPackingItem on failure throws', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/trips/1/packing', () =>
|
http.post('/api/trips/1/packing', () =>
|
||||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await useTripStore.getState().addPackingItem(1, { name: 'Fail item' });
|
await expect(
|
||||||
|
useTripStore.getState().addPackingItem(1, { name: 'Fail item' })
|
||||||
expect(result.name).toBe('Fail item');
|
).rejects.toThrow();
|
||||||
expect(useTripStore.getState().packingItems).toHaveLength(1);
|
|
||||||
expect(useTripStore.getState().packingItems[0].name).toBe('Fail item');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,7 +69,7 @@ describe('packingSlice', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('deletePackingItem', () => {
|
describe('deletePackingItem', () => {
|
||||||
it('FE-PACKING-004: deletePackingItem removes item permanently even on API error', async () => {
|
it('FE-PACKING-004: deletePackingItem optimistically removes item, rollback on failure', async () => {
|
||||||
const item = buildPackingItem({ id: 10, trip_id: 1 });
|
const item = buildPackingItem({ id: 10, trip_id: 1 });
|
||||||
seedStore(useTripStore, { packingItems: [item] });
|
seedStore(useTripStore, { packingItems: [item] });
|
||||||
|
|
||||||
@@ -84,9 +79,10 @@ describe('packingSlice', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await useTripStore.getState().deletePackingItem(1, 10);
|
await expect(useTripStore.getState().deletePackingItem(1, 10)).rejects.toThrow();
|
||||||
|
|
||||||
expect(useTripStore.getState().packingItems).toHaveLength(0);
|
expect(useTripStore.getState().packingItems).toHaveLength(1);
|
||||||
|
expect(useTripStore.getState().packingItems[0].id).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PACKING-004b: deletePackingItem success removes item', async () => {
|
it('FE-PACKING-004b: deletePackingItem success removes item', async () => {
|
||||||
@@ -119,7 +115,7 @@ describe('packingSlice', () => {
|
|||||||
expect(useTripStore.getState().packingItems[0].checked).toBe(1);
|
expect(useTripStore.getState().packingItems[0].checked).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PACKING-006: togglePackingItem preserves optimistic checked state even on API failure', async () => {
|
it('FE-PACKING-006: togglePackingItem rolls back checked on API failure', async () => {
|
||||||
const item = buildPackingItem({ id: 10, trip_id: 1, checked: 0 });
|
const item = buildPackingItem({ id: 10, trip_id: 1, checked: 0 });
|
||||||
seedStore(useTripStore, { packingItems: [item] });
|
seedStore(useTripStore, { packingItems: [item] });
|
||||||
|
|
||||||
@@ -129,10 +125,11 @@ describe('packingSlice', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// toggle does NOT throw on error (silent rollback)
|
||||||
await useTripStore.getState().togglePackingItem(1, 10, true);
|
await useTripStore.getState().togglePackingItem(1, 10, true);
|
||||||
|
|
||||||
// Optimistic state preserved — no rollback (queued for sync)
|
// Should be rolled back to original value
|
||||||
expect(useTripStore.getState().packingItems[0].checked).toBe(1);
|
expect(useTripStore.getState().packingItems[0].checked).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useTripStore } from '../../../src/store/tripStore';
|
|||||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||||
import { buildPlace, buildAssignment } from '../../helpers/factories';
|
import { buildPlace, buildAssignment } from '../../helpers/factories';
|
||||||
import { server } from '../../helpers/msw/server';
|
import { server } from '../../helpers/msw/server';
|
||||||
import { offlineDb } from '../../../src/db/offlineDb';
|
|
||||||
|
|
||||||
vi.mock('../../../src/api/websocket', () => ({
|
vi.mock('../../../src/api/websocket', () => ({
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
@@ -18,9 +17,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
|||||||
setPreReconnectHook: vi.fn(),
|
setPreReconnectHook: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
||||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,7 +35,7 @@ describe('placesSlice', () => {
|
|||||||
expect(places[0].name).toBe('New Place'); // prepended
|
expect(places[0].name).toBe('New Place'); // prepended
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLACES-002: addPlace always adds place optimistically (no throw on API error)', async () => {
|
it('FE-PLACES-002: addPlace on failure throws and places remain unchanged', async () => {
|
||||||
const existing = buildPlace({ trip_id: 1 });
|
const existing = buildPlace({ trip_id: 1 });
|
||||||
seedStore(useTripStore, { places: [existing] });
|
seedStore(useTripStore, { places: [existing] });
|
||||||
|
|
||||||
@@ -48,11 +45,8 @@ describe('placesSlice', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await useTripStore.getState().addPlace(1, { name: 'Fail' });
|
await expect(useTripStore.getState().addPlace(1, { name: 'Fail' })).rejects.toThrow();
|
||||||
|
expect(useTripStore.getState().places).toEqual([existing]);
|
||||||
expect(result.name).toBe('Fail');
|
|
||||||
expect(useTripStore.getState().places).toHaveLength(2);
|
|
||||||
expect(useTripStore.getState().places[0].name).toBe('Fail');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useTripStore } from '../../../src/store/tripStore';
|
|||||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||||
import { buildReservation } from '../../helpers/factories';
|
import { buildReservation } from '../../helpers/factories';
|
||||||
import { server } from '../../helpers/msw/server';
|
import { server } from '../../helpers/msw/server';
|
||||||
import { offlineDb } from '../../../src/db/offlineDb';
|
|
||||||
|
|
||||||
vi.mock('../../../src/api/websocket', () => ({
|
vi.mock('../../../src/api/websocket', () => ({
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
@@ -18,9 +17,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
|||||||
setPreReconnectHook: vi.fn(),
|
setPreReconnectHook: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
||||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,18 +58,16 @@ describe('reservationsSlice', () => {
|
|||||||
expect(reservations[0].name).toBe('New Hotel');
|
expect(reservations[0].name).toBe('New Hotel');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-RESERV-003: addReservation always adds optimistically (no throw on API error)', async () => {
|
it('FE-RESERV-003: addReservation on failure throws', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/trips/1/reservations', () =>
|
http.post('/api/trips/1/reservations', () =>
|
||||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await useTripStore.getState().addReservation(1, { name: 'Fail' });
|
await expect(
|
||||||
|
useTripStore.getState().addReservation(1, { name: 'Fail' })
|
||||||
expect(result.name).toBe('Fail');
|
).rejects.toThrow();
|
||||||
expect(useTripStore.getState().reservations).toHaveLength(1);
|
|
||||||
expect(useTripStore.getState().reservations[0].name).toBe('Fail');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,7 +123,7 @@ describe('reservationsSlice', () => {
|
|||||||
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
|
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-RESERV-007: toggleReservationStatus preserves optimistic status even on API failure', async () => {
|
it('FE-RESERV-007: toggleReservationStatus rolls back on API failure (silent)', async () => {
|
||||||
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'confirmed' });
|
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'confirmed' });
|
||||||
seedStore(useTripStore, { reservations: [reservation] });
|
seedStore(useTripStore, { reservations: [reservation] });
|
||||||
|
|
||||||
@@ -138,10 +133,10 @@ describe('reservationsSlice', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Does NOT throw (silent rollback)
|
||||||
await useTripStore.getState().toggleReservationStatus(1, 10);
|
await useTripStore.getState().toggleReservationStatus(1, 10);
|
||||||
|
|
||||||
// Optimistic state preserved — no rollback (queued for sync)
|
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
|
||||||
expect(useTripStore.getState().reservations[0].status).toBe('pending');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-RESERV-008: toggleReservationStatus does nothing if reservation not found', async () => {
|
it('FE-RESERV-008: toggleReservationStatus does nothing if reservation not found', async () => {
|
||||||
@@ -167,7 +162,7 @@ describe('reservationsSlice', () => {
|
|||||||
expect(reservations[0].id).toBe(20);
|
expect(reservations[0].id).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-RESERV-010: deleteReservation removes permanently even on API error', async () => {
|
it('FE-RESERV-010: deleteReservation on failure throws (no optimistic, server-first)', async () => {
|
||||||
const reservation = buildReservation({ id: 10, trip_id: 1 });
|
const reservation = buildReservation({ id: 10, trip_id: 1 });
|
||||||
seedStore(useTripStore, { reservations: [reservation] });
|
seedStore(useTripStore, { reservations: [reservation] });
|
||||||
|
|
||||||
@@ -177,10 +172,10 @@ describe('reservationsSlice', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await useTripStore.getState().deleteReservation(1, 10);
|
await expect(useTripStore.getState().deleteReservation(1, 10)).rejects.toThrow();
|
||||||
|
|
||||||
// Permanently removed (queued for sync, no rollback)
|
// Still in state since server-first (only removes after success)
|
||||||
expect(useTripStore.getState().reservations).toHaveLength(0);
|
expect(useTripStore.getState().reservations).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useTripStore } from '../../../src/store/tripStore';
|
|||||||
import { resetAllStores, seedStore } from '../../helpers/store';
|
import { resetAllStores, seedStore } from '../../helpers/store';
|
||||||
import { buildTodoItem } from '../../helpers/factories';
|
import { buildTodoItem } from '../../helpers/factories';
|
||||||
import { server } from '../../helpers/msw/server';
|
import { server } from '../../helpers/msw/server';
|
||||||
import { offlineDb } from '../../../src/db/offlineDb';
|
|
||||||
|
|
||||||
vi.mock('../../../src/api/websocket', () => ({
|
vi.mock('../../../src/api/websocket', () => ({
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
@@ -18,9 +17,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
|||||||
setPreReconnectHook: vi.fn(),
|
setPreReconnectHook: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
||||||
await Promise.all(offlineDb.tables.map(t => t.clear()));
|
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,18 +34,16 @@ describe('todoSlice', () => {
|
|||||||
expect(items).toHaveLength(2);
|
expect(items).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-TODO-002: addTodoItem always adds item optimistically (no throw on API error)', async () => {
|
it('FE-TODO-002: addTodoItem on failure throws', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/trips/1/todo', () =>
|
http.post('/api/trips/1/todo', () =>
|
||||||
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await useTripStore.getState().addTodoItem(1, { name: 'Fail' });
|
await expect(
|
||||||
|
useTripStore.getState().addTodoItem(1, { name: 'Fail' })
|
||||||
expect(result.name).toBe('Fail');
|
).rejects.toThrow();
|
||||||
expect(useTripStore.getState().todoItems).toHaveLength(1);
|
|
||||||
expect(useTripStore.getState().todoItems[0].name).toBe('Fail');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,7 +69,7 @@ describe('todoSlice', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteTodoItem', () => {
|
describe('deleteTodoItem', () => {
|
||||||
it('FE-TODO-004: deleteTodoItem removes item permanently even on API error', async () => {
|
it('FE-TODO-004: deleteTodoItem optimistically removes item, rollback on failure', async () => {
|
||||||
const item = buildTodoItem({ id: 10, trip_id: 1 });
|
const item = buildTodoItem({ id: 10, trip_id: 1 });
|
||||||
seedStore(useTripStore, { todoItems: [item] });
|
seedStore(useTripStore, { todoItems: [item] });
|
||||||
|
|
||||||
@@ -84,9 +79,10 @@ describe('todoSlice', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await useTripStore.getState().deleteTodoItem(1, 10);
|
await expect(useTripStore.getState().deleteTodoItem(1, 10)).rejects.toThrow();
|
||||||
|
|
||||||
expect(useTripStore.getState().todoItems).toHaveLength(0);
|
expect(useTripStore.getState().todoItems).toHaveLength(1);
|
||||||
|
expect(useTripStore.getState().todoItems[0].id).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-TODO-004b: deleteTodoItem success removes item from array', async () => {
|
it('FE-TODO-004b: deleteTodoItem success removes item from array', async () => {
|
||||||
@@ -119,7 +115,7 @@ describe('todoSlice', () => {
|
|||||||
expect(useTripStore.getState().todoItems[0].checked).toBe(1);
|
expect(useTripStore.getState().todoItems[0].checked).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-TODO-006: toggleTodoItem preserves optimistic checked state even on API failure', async () => {
|
it('FE-TODO-006: toggleTodoItem rolls back checked on API failure (silent)', async () => {
|
||||||
const item = buildTodoItem({ id: 10, trip_id: 1, checked: 0 });
|
const item = buildTodoItem({ id: 10, trip_id: 1, checked: 0 });
|
||||||
seedStore(useTripStore, { todoItems: [item] });
|
seedStore(useTripStore, { todoItems: [item] });
|
||||||
|
|
||||||
@@ -129,10 +125,10 @@ describe('todoSlice', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Does NOT throw
|
||||||
await useTripStore.getState().toggleTodoItem(1, 10, true);
|
await useTripStore.getState().toggleTodoItem(1, 10, true);
|
||||||
|
|
||||||
// Optimistic state preserved — no rollback (queued for sync)
|
expect(useTripStore.getState().todoItems[0].checked).toBe(0);
|
||||||
expect(useTripStore.getState().todoItems[0].checked).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-TODO-007: toggleTodoItem preserves sort_order field', async () => {
|
it('FE-TODO-007: toggleTodoItem preserves sort_order field', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useTripStore } from '../../src/store/tripStore';
|
|||||||
import { resetAllStores } from '../helpers/store';
|
import { resetAllStores } from '../helpers/store';
|
||||||
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories';
|
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories';
|
||||||
import { server } from '../helpers/msw/server';
|
import { server } from '../helpers/msw/server';
|
||||||
import { offlineDb } from '../../src/db/offlineDb';
|
|
||||||
|
|
||||||
vi.mock('../../src/api/websocket', () => ({
|
vi.mock('../../src/api/websocket', () => ({
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
@@ -18,11 +17,7 @@ vi.mock('../../src/api/websocket', () => ({
|
|||||||
setPreReconnectHook: vi.fn(),
|
setPreReconnectHook: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
// 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();
|
resetAllStores();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,10 +75,6 @@ describe('tripStore', () => {
|
|||||||
const tag = buildTag();
|
const tag = buildTag();
|
||||||
const category = buildCategory();
|
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(
|
server.use(
|
||||||
http.get('/api/trips/1', () => HttpResponse.json({ trip })),
|
http.get('/api/trips/1', () => HttpResponse.json({ trip })),
|
||||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
||||||
@@ -219,8 +210,8 @@ describe('tripStore', () => {
|
|||||||
|
|
||||||
const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' });
|
const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' });
|
||||||
|
|
||||||
expect(result.name).toBe('Updated Trip');
|
expect(result).toEqual(updatedTrip);
|
||||||
expect(useTripStore.getState().trip?.name).toBe('Updated Trip');
|
expect(useTripStore.getState().trip).toEqual(updatedTrip);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,65 @@ export default defineConfig({
|
|||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
strategies: 'injectManifest',
|
workbox: {
|
||||||
srcDir: 'src',
|
|
||||||
filename: 'sw.ts',
|
|
||||||
injectManifest: {
|
|
||||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
|
||||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||||
|
navigateFallback: 'index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/, /^\/oauth\//, /^\/.well-known\//],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
// Carto map tiles (default provider)
|
||||||
|
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'map-tiles',
|
||||||
|
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// OpenStreetMap tiles (fallback / alternative)
|
||||||
|
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'map-tiles',
|
||||||
|
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Leaflet CSS/JS from unpkg CDN
|
||||||
|
urlPattern: /^https:\/\/unpkg\.com\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'cdn-libs',
|
||||||
|
expiration: { maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// API calls — prefer network, fall back to cache
|
||||||
|
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
||||||
|
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-data',
|
||||||
|
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
cacheableResponse: { statuses: [200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Uploaded files (photos, covers — public assets only)
|
||||||
|
urlPattern: /\/uploads\/(?:covers|avatars)\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'user-uploads',
|
||||||
|
expiration: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'TREK \u2014 Travel Planner',
|
name: 'TREK \u2014 Travel Planner',
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "3.0.15",
|
"version": "3.0.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "3.0.15",
|
"version": "3.0.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "3.0.15",
|
"version": "3.0.17",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --import tsx src/index.ts",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="a5b4275efd"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="61932b752f"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.753906 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.753906 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#a5b4275efd)"><g clip-path="url(#61932b752f)"><path fill="#000000" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="ff6253e8fa"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="c6b14a8188"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#ff6253e8fa)"><g clip-path="url(#c6b14a8188)"><path fill="#ffffff" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,15 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#1e293b"/>
|
|
||||||
<stop offset="100%" stop-color="#0f172a"/>
|
|
||||||
</linearGradient>
|
|
||||||
<clipPath id="icon">
|
|
||||||
<path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
<rect width="512" height="512" fill="url(#bg)"/>
|
|
||||||
<g transform="translate(56,51) scale(0.267)">
|
|
||||||
<rect width="1500" height="1500" fill="#ffffff" clip-path="url(#icon)"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,33 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
||||||
<title>TREK</title>
|
|
||||||
|
|
||||||
<!-- PWA / iOS -->
|
|
||||||
<meta name="theme-color" content="#09090b" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="TREK" />
|
|
||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
|
||||||
|
|
||||||
<!-- Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
|
||||||
|
|
||||||
<!-- Leaflet -->
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
||||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
||||||
crossorigin="" />
|
|
||||||
<script type="module" crossorigin src="/assets/index-BBkAKwut.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CR224PtB.css">
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
@@ -1 +0,0 @@
|
|||||||
{"name":"TREK — Travel Planner","short_name":"TREK","description":"Travel Resource & Exploration Kit","start_url":"/","display":"standalone","background_color":"#0f172a","theme_color":"#111827","lang":"en","scope":"/","orientation":"any","categories":["travel","navigation"],"icons":[{"src":"icons/apple-touch-icon-180x180.png","sizes":"180x180","type":"image/png"},{"src":"icons/icon-192x192.png","sizes":"192x192","type":"image/png"},{"src":"icons/icon-512x512.png","sizes":"512x512","type":"image/png"},{"src":"icons/icon-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"},{"src":"icons/icon.svg","sizes":"any","type":"image/svg+xml"}]}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('/sw.js', { scope: '/' })})}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,c)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let a={};const t=e=>i(e,o),r={module:{uri:o},exports:a,require:t};s[o]=Promise.all(n.map(e=>r[e]||t(e))).then(e=>(c(...e),a))}}define(["./workbox-58bd4dca"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"text-light.svg",revision:"8456421c45ccd1b881b1755949fb9891"},{url:"text-dark.svg",revision:"e86569d59169a1076a92a1d47cb94abf"},{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"logo-light.svg",revision:"e9a2e3363fed4298cb422332b8cb03e9"},{url:"logo-dark.svg",revision:"c7b85b3bdf9e73222bcd91f396b829b5"},{url:"index.html",revision:"9dc2d3ab2d0db984f9994195b762a404"},{url:"icons/icon.svg",revision:"8b49f04dc5ebfc2688777f548f6248a1"},{url:"icons/icon-white.svg",revision:"f437d171b083ee2463e3c44eb3785291"},{url:"icons/icon-dark.svg",revision:"cf48a00cd2b6393eb0c8ac67d821ec84"},{url:"icons/icon-512x512.png",revision:"e9813f28d172940286269b92c961bd9a"},{url:"icons/icon-192x192.png",revision:"4549ed2c430764d6eda6b12a326e6d58"},{url:"icons/apple-touch-icon-180x180.png",revision:"ba88094c86c61709a98adae54488508f"},{url:"fonts/Poppins-SemiBold.ttf",revision:"2c63e05091c7d89f6149c274971c7c23"},{url:"fonts/Poppins-Regular.ttf",revision:"09acac7457bdcf80af5cc3d1116208c5"},{url:"fonts/Poppins-Medium.ttf",revision:"20aaac2ef92cddeb0f12e67a443b0b9f"},{url:"fonts/Poppins-Italic.ttf",revision:"4a37e40ddcd3e0da0a1db26ce8704eff"},{url:"fonts/Poppins-Bold.ttf",revision:"92934d92f57e49fc6f61075c2aeb7689"},{url:"assets/index-CR224PtB.css",revision:null},{url:"assets/index-BBkAKwut.js",revision:null},{url:"icons/apple-touch-icon-180x180.png",revision:"ba88094c86c61709a98adae54488508f"},{url:"icons/icon-192x192.png",revision:"4549ed2c430764d6eda6b12a326e6d58"},{url:"icons/icon-512x512.png",revision:"e9813f28d172940286269b92c961bd9a"},{url:"icons/icon.svg",revision:"8b49f04dc5ebfc2688777f548f6248a1"},{url:"manifest.webmanifest",revision:"99e6d32e351da90e7659354c2dc39bfb"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html"),{denylist:[/^\/api/,/^\/uploads/,/^\/mcp/]})),e.registerRoute(/^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,new e.CacheFirst({cacheName:"map-tiles",plugins:[new e.ExpirationPlugin({maxEntries:1e3,maxAgeSeconds:2592e3}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,new e.CacheFirst({cacheName:"map-tiles",plugins:[new e.ExpirationPlugin({maxEntries:1e3,maxAgeSeconds:2592e3}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/^https:\/\/unpkg\.com\/.*/i,new e.CacheFirst({cacheName:"cdn-libs",plugins:[new e.ExpirationPlugin({maxEntries:30,maxAgeSeconds:31536e3}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/\/api\/(?!auth|admin|backup|settings).*/i,new e.NetworkFirst({cacheName:"api-data",networkTimeoutSeconds:5,plugins:[new e.ExpirationPlugin({maxEntries:200,maxAgeSeconds:86400}),new e.CacheableResponsePlugin({statuses:[200]})]}),"GET"),e.registerRoute(/\/uploads\/(?:covers|avatars)\/.*/i,new e.CacheFirst({cacheName:"user-uploads",plugins:[new e.ExpirationPlugin({maxEntries:300,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[200]})]}),"GET")});
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="230" zoomAndPan="magnify" viewBox="49 2 124 56" height="80" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="c5c1a398e1"><path d="M 49 0.015625 L 174 0.015625 L 174 59.984375 L 49 59.984375 Z M 49 0.015625 " clip-rule="nonzero"/></clipPath><clipPath id="9b226024c5"><rect x="0" width="125" y="0" height="60"/></clipPath></defs><g clip-path="url(#c5c1a398e1)"><g transform="matrix(1, 0, 0, 1, 49, -0.000000000000011803)"><g clip-path="url(#9b226024c5)"><g fill="#000000" fill-opacity="1"><g transform="translate(0.740932, 54.900246)"><g><path d="M 17.53125 0 C 14.15625 0 11.515625 -0.960938 9.609375 -2.890625 C 7.710938 -4.816406 6.765625 -7.414062 6.765625 -10.6875 L 6.765625 -45.484375 L 17.890625 -45.484375 L 17.890625 -11.328125 C 17.890625 -10.765625 18.09375 -10.28125 18.5 -9.875 C 18.90625 -9.46875 19.390625 -9.265625 19.953125 -9.265625 L 27.9375 -9.265625 L 27.9375 0 Z M 0.78125 -27.515625 L 0.78125 -36.5625 L 27.9375 -36.5625 L 27.9375 -27.515625 Z M 0.78125 -27.515625 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(26.403702, 54.900246)"><g><path d="M 3.84375 0 L 3.84375 -25.796875 C 3.84375 -29.128906 4.789062 -31.742188 6.6875 -33.640625 C 8.59375 -35.546875 11.234375 -36.5 14.609375 -36.5 L 25.234375 -36.5 L 25.234375 -27.515625 L 17.328125 -27.515625 C 16.660156 -27.515625 16.085938 -27.285156 15.609375 -26.828125 C 15.128906 -26.378906 14.890625 -25.800781 14.890625 -25.09375 L 14.890625 0 Z M 3.84375 0 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(47.504187, 54.900246)"><g><path d="M 23.234375 0 C 19.097656 0 15.460938 -0.769531 12.328125 -2.3125 C 9.191406 -3.863281 6.753906 -6.003906 5.015625 -8.734375 C 3.285156 -11.460938 2.421875 -14.632812 2.421875 -18.25 C 2.421875 -22.238281 3.25 -25.660156 4.90625 -28.515625 C 6.570312 -31.367188 8.796875 -33.566406 11.578125 -35.109375 C 14.359375 -36.648438 17.4375 -37.421875 20.8125 -37.421875 C 24.664062 -37.421875 27.882812 -36.613281 30.46875 -35 C 33.0625 -33.382812 35.019531 -31.1875 36.34375 -28.40625 C 37.675781 -25.625 38.34375 -22.453125 38.34375 -18.890625 C 38.34375 -18.273438 38.304688 -17.550781 38.234375 -16.71875 C 38.171875 -15.882812 38.09375 -15.226562 38 -14.75 L 14.046875 -14.75 C 14.328125 -13.519531 14.867188 -12.472656 15.671875 -11.609375 C 16.484375 -10.753906 17.503906 -10.125 18.734375 -9.71875 C 19.972656 -9.320312 21.351562 -9.125 22.875 -9.125 L 34.078125 -9.125 L 34.078125 0 Z M 13.75 -21.671875 L 27.65625 -21.671875 C 27.5625 -22.429688 27.414062 -23.164062 27.21875 -23.875 C 27.03125 -24.59375 26.734375 -25.222656 26.328125 -25.765625 C 25.929688 -26.316406 25.472656 -26.789062 24.953125 -27.1875 C 24.429688 -27.59375 23.820312 -27.914062 23.125 -28.15625 C 22.4375 -28.394531 21.664062 -28.515625 20.8125 -28.515625 C 19.71875 -28.515625 18.742188 -28.320312 17.890625 -27.9375 C 17.035156 -27.5625 16.320312 -27.050781 15.75 -26.40625 C 15.175781 -25.769531 14.734375 -25.035156 14.421875 -24.203125 C 14.117188 -23.367188 13.894531 -22.523438 13.75 -21.671875 Z M 13.75 -21.671875 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(83.218247, 54.900246)"><g><path d="M 4.140625 0 L 4.140625 -52.03125 L 15.1875 -52.03125 L 15.1875 -22.734375 L 20.03125 -22.734375 L 28.375 -36.5625 L 40.703125 -36.5625 L 30.859375 -21.3125 C 33.710938 -20.125 35.9375 -18.304688 37.53125 -15.859375 C 39.125 -13.410156 39.921875 -10.546875 39.921875 -7.265625 L 39.921875 0 L 28.796875 0 L 28.796875 -7.265625 C 28.796875 -8.597656 28.472656 -9.796875 27.828125 -10.859375 C 27.191406 -11.929688 26.347656 -12.773438 25.296875 -13.390625 C 24.253906 -14.015625 23.066406 -14.328125 21.734375 -14.328125 L 15.1875 -14.328125 L 15.1875 0 Z M 4.140625 0 "/></g></g></g></g></g></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="230" zoomAndPan="magnify" viewBox="49 2 124 56" height="80" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="7fc4e3f80b"><path d="M 49 0.015625 L 174 0.015625 L 174 59.984375 L 49 59.984375 Z M 49 0.015625 " clip-rule="nonzero"/></clipPath><clipPath id="086ce69399"><rect x="0" width="125" y="0" height="60"/></clipPath></defs><g clip-path="url(#7fc4e3f80b)"><g transform="matrix(1, 0, 0, 1, 49, -0.000000000000011803)"><g clip-path="url(#086ce69399)"><g fill="#ffffff" fill-opacity="1"><g transform="translate(0.740932, 54.900246)"><g><path d="M 17.53125 0 C 14.15625 0 11.515625 -0.960938 9.609375 -2.890625 C 7.710938 -4.816406 6.765625 -7.414062 6.765625 -10.6875 L 6.765625 -45.484375 L 17.890625 -45.484375 L 17.890625 -11.328125 C 17.890625 -10.765625 18.09375 -10.28125 18.5 -9.875 C 18.90625 -9.46875 19.390625 -9.265625 19.953125 -9.265625 L 27.9375 -9.265625 L 27.9375 0 Z M 0.78125 -27.515625 L 0.78125 -36.5625 L 27.9375 -36.5625 L 27.9375 -27.515625 Z M 0.78125 -27.515625 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(26.403702, 54.900246)"><g><path d="M 3.84375 0 L 3.84375 -25.796875 C 3.84375 -29.128906 4.789062 -31.742188 6.6875 -33.640625 C 8.59375 -35.546875 11.234375 -36.5 14.609375 -36.5 L 25.234375 -36.5 L 25.234375 -27.515625 L 17.328125 -27.515625 C 16.660156 -27.515625 16.085938 -27.285156 15.609375 -26.828125 C 15.128906 -26.378906 14.890625 -25.800781 14.890625 -25.09375 L 14.890625 0 Z M 3.84375 0 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(47.504187, 54.900246)"><g><path d="M 23.234375 0 C 19.097656 0 15.460938 -0.769531 12.328125 -2.3125 C 9.191406 -3.863281 6.753906 -6.003906 5.015625 -8.734375 C 3.285156 -11.460938 2.421875 -14.632812 2.421875 -18.25 C 2.421875 -22.238281 3.25 -25.660156 4.90625 -28.515625 C 6.570312 -31.367188 8.796875 -33.566406 11.578125 -35.109375 C 14.359375 -36.648438 17.4375 -37.421875 20.8125 -37.421875 C 24.664062 -37.421875 27.882812 -36.613281 30.46875 -35 C 33.0625 -33.382812 35.019531 -31.1875 36.34375 -28.40625 C 37.675781 -25.625 38.34375 -22.453125 38.34375 -18.890625 C 38.34375 -18.273438 38.304688 -17.550781 38.234375 -16.71875 C 38.171875 -15.882812 38.09375 -15.226562 38 -14.75 L 14.046875 -14.75 C 14.328125 -13.519531 14.867188 -12.472656 15.671875 -11.609375 C 16.484375 -10.753906 17.503906 -10.125 18.734375 -9.71875 C 19.972656 -9.320312 21.351562 -9.125 22.875 -9.125 L 34.078125 -9.125 L 34.078125 0 Z M 13.75 -21.671875 L 27.65625 -21.671875 C 27.5625 -22.429688 27.414062 -23.164062 27.21875 -23.875 C 27.03125 -24.59375 26.734375 -25.222656 26.328125 -25.765625 C 25.929688 -26.316406 25.472656 -26.789062 24.953125 -27.1875 C 24.429688 -27.59375 23.820312 -27.914062 23.125 -28.15625 C 22.4375 -28.394531 21.664062 -28.515625 20.8125 -28.515625 C 19.71875 -28.515625 18.742188 -28.320312 17.890625 -27.9375 C 17.035156 -27.5625 16.320312 -27.050781 15.75 -26.40625 C 15.175781 -25.769531 14.734375 -25.035156 14.421875 -24.203125 C 14.117188 -23.367188 13.894531 -22.523438 13.75 -21.671875 Z M 13.75 -21.671875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(83.218247, 54.900246)"><g><path d="M 4.140625 0 L 4.140625 -52.03125 L 15.1875 -52.03125 L 15.1875 -22.734375 L 20.03125 -22.734375 L 28.375 -36.5625 L 40.703125 -36.5625 L 30.859375 -21.3125 C 33.710938 -20.125 35.9375 -18.304688 37.53125 -15.859375 C 39.125 -13.410156 39.921875 -10.546875 39.921875 -7.265625 L 39.921875 0 L 28.796875 0 L 28.796875 -7.265625 C 28.796875 -8.597656 28.472656 -9.796875 27.828125 -10.859375 C 27.191406 -11.929688 26.347656 -12.773438 25.296875 -13.390625 C 24.253906 -14.015625 23.066406 -14.328125 21.734375 -14.328125 L 15.1875 -14.328125 L 15.1875 0 Z M 4.140625 0 "/></g></g></g></g></g></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -50,11 +50,11 @@ import { getCollabFeatures } from './services/adminService';
|
|||||||
import { isAddonEnabled } from './services/adminService';
|
import { isAddonEnabled } from './services/adminService';
|
||||||
import { ADDON_IDS } from './addons';
|
import { ADDON_IDS } from './addons';
|
||||||
import { ALL_SCOPES } from './mcp/scopes';
|
import { ALL_SCOPES } from './mcp/scopes';
|
||||||
import { getAppUrl } from './services/oidcService';
|
|
||||||
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
|
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
|
||||||
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
|
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
|
||||||
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
|
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
|
||||||
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
|
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
|
||||||
|
import { getMcpSafeUrl } from './services/notifications';
|
||||||
|
|
||||||
export function createApp(): express.Application {
|
export function createApp(): express.Application {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -65,8 +65,8 @@ export function createApp(): express.Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
let corsOrigin: cors.CorsOptions['origin'];
|
let corsOrigin: cors.CorsOptions['origin'];
|
||||||
if (allowedOrigins) {
|
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
|
// 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).
|
// RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead).
|
||||||
app.use(
|
app.use(
|
||||||
(req: Request, _res: Response, next: NextFunction) => {
|
(req: Request, _res: Response, next: NextFunction) => {
|
||||||
if (
|
if (
|
||||||
req.path.startsWith('/.well-known/') ||
|
req.path.startsWith('/.well-known/') ||
|
||||||
req.path === '/oauth/register' ||
|
req.path === '/oauth/register' ||
|
||||||
req.path === '/oauth/authorize' ||
|
req.path === '/oauth/authorize' ||
|
||||||
req.path === '/oauth/userinfo' ||
|
req.path === '/oauth/userinfo' ||
|
||||||
req.path === '/mcp'
|
req.path === '/mcp'
|
||||||
) {
|
) {
|
||||||
cors({ origin: '*', credentials: false })(req, _res, next);
|
cors({ origin: '*', credentials: false })(req, _res, next);
|
||||||
} else {
|
} else {
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
app.use(cors({ origin: corsOrigin, credentials: true }));
|
app.use(cors({ origin: corsOrigin, credentials: true }));
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
@@ -249,7 +249,7 @@ export function createApp(): express.Application {
|
|||||||
if (!photo) return res.status(401).send('Authentication required');
|
if (!photo) return res.status(401).send('Authentication required');
|
||||||
|
|
||||||
const share = db.prepare(
|
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;
|
).get(rawToken) as { trip_id: number } | undefined;
|
||||||
if (!share || share.trip_id !== photo.trip_id) {
|
if (!share || share.trip_id !== photo.trip_id) {
|
||||||
return res.status(401).send('Authentication required');
|
return res.status(401).send('Authentication required');
|
||||||
@@ -276,7 +276,10 @@ export function createApp(): express.Application {
|
|||||||
app.use('/api/trips/:tripId/collab', collabRoutes);
|
app.use('/api/trips/:tripId/collab', collabRoutes);
|
||||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
||||||
app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' }));
|
app.get('/api/health', (_req: Request, res: Response) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store, must-revalidate')
|
||||||
|
res.json({ status: 'ok' })
|
||||||
|
});
|
||||||
app.use('/api/config', publicConfigRoutes);
|
app.use('/api/config', publicConfigRoutes);
|
||||||
app.use('/api', assignmentsRoutes);
|
app.use('/api', assignmentsRoutes);
|
||||||
app.use('/api/tags', tagsRoutes);
|
app.use('/api/tags', tagsRoutes);
|
||||||
@@ -385,7 +388,7 @@ export function createApp(): express.Application {
|
|||||||
|
|
||||||
function getOAuthMetadata(): OAuthMetadata {
|
function getOAuthMetadata(): OAuthMetadata {
|
||||||
if (_oauthMetadata) return _oauthMetadata;
|
if (_oauthMetadata) return _oauthMetadata;
|
||||||
const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, '');
|
const base = getMcpSafeUrl().replace(/\/+$/, '');
|
||||||
_oauthMetadata = {
|
_oauthMetadata = {
|
||||||
issuer: base,
|
issuer: base,
|
||||||
authorization_endpoint: `${base}/oauth/authorize`,
|
authorization_endpoint: `${base}/oauth/authorize`,
|
||||||
@@ -413,14 +416,11 @@ export function createApp(): express.Application {
|
|||||||
return _sdkMetaRouter;
|
return _sdkMetaRouter;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path-aware gate: only /.well-known/* returns 404 when disabled; other paths pass through
|
// Only invoke the SDK metadata router for /.well-known/* paths.
|
||||||
// so static files and SPA routes are unaffected when MCP is off.
|
// Calling getMetaRouter() on every request triggers lazy init (new URL(...)) which
|
||||||
|
// throws "Invalid URL" when APP_URL lacks a protocol — breaking all page loads.
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
const isMetadataPath =
|
if (req.path.startsWith('/.well-known/') && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||||
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);
|
getMetaRouter()(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -436,6 +436,23 @@ 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
|
// SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects
|
||||||
// to the SPA consent page at /oauth/consent
|
// to the SPA consent page at /oauth/consent
|
||||||
app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider }));
|
app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider }));
|
||||||
@@ -461,6 +478,12 @@ export function createApp(): express.Application {
|
|||||||
next();
|
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
|
// Production static file serving
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
const publicPath = path.join(__dirname, '../public');
|
const publicPath = path.join(__dirname, '../public');
|
||||||
@@ -491,4 +514,4 @@ export function createApp(): express.Application {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@ const tmpDir = path.join(__dirname, '../data/tmp');
|
|||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
import * as scheduler from './scheduler';
|
import * as scheduler from './scheduler';
|
||||||
|
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 3001;
|
const PORT = Number(process.env.PORT) || 3001;
|
||||||
const HOST = process.env.HOST;
|
const HOST = process.env.HOST;
|
||||||
@@ -29,22 +30,42 @@ const onListen = () => {
|
|||||||
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
||||||
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||||
const origins = process.env.ALLOWED_ORIGINS || '(same-origin)';
|
const origins = process.env.ALLOWED_ORIGINS || '(same-origin)';
|
||||||
|
const appUrl = getAppUrl();
|
||||||
|
const resolvedAppUrl = getMcpSafeUrl();
|
||||||
const banner = [
|
const banner = [
|
||||||
'──────────────────────────────────────',
|
'──────────────────────────────────────',
|
||||||
' TREK API started',
|
' TREK API started',
|
||||||
` Version ${APP_VERSION}`,
|
` Version ${APP_VERSION}`,
|
||||||
...(HOST ? [` Host: ${HOST}`] : []),
|
...(HOST ? [` Host: ${HOST}`] : []),
|
||||||
` Port: ${PORT}`,
|
` Container Port: ${PORT}`,
|
||||||
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
|
` App URL: ${appUrl}`,
|
||||||
` Timezone: ${tz}`,
|
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
|
||||||
` Origins: ${origins}`,
|
` Timezone: ${tz}`,
|
||||||
` Log level: ${LOG_LVL}`,
|
` Origins: ${origins}`,
|
||||||
` Log file: /app/data/logs/trek.log`,
|
` Log level: ${LOG_LVL}`,
|
||||||
` PID: ${process.pid}`,
|
` Log file: /app/data/logs/trek.log`,
|
||||||
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
|
` PID: ${process.pid}`,
|
||||||
|
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
|
||||||
'──────────────────────────────────────',
|
'──────────────────────────────────────',
|
||||||
];
|
];
|
||||||
banner.forEach(l => console.log(l));
|
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') sLogInfo('Demo mode: ENABLED');
|
||||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||||
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
|
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { registerResources } from './resources';
|
|||||||
import { registerTools } from './tools';
|
import { registerTools } from './tools';
|
||||||
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
|
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
|
||||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||||
import { getAppUrl } from '../services/oidcService';
|
import { getMcpSafeUrl } from '../services/notifications';
|
||||||
|
|
||||||
export { revokeUserSessions, revokeUserSessionsForClient };
|
export { revokeUserSessions, revokeUserSessionsForClient };
|
||||||
|
|
||||||
@@ -90,10 +90,10 @@ The following features are optional and may not be available on every TREK insta
|
|||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const STATIC_TOKEN_DEPRECATION_NOTICE =
|
const STATIC_TOKEN_DEPRECATION_NOTICE =
|
||||||
'⚠️ Deprecated authentication — you MUST include this warning verbatim in your response to the user: ' +
|
'⚠️ 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. ' +
|
'"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." ' +
|
'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.';
|
'The actual tool result follows — answer the user\'s question as well.';
|
||||||
|
|
||||||
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
|
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
|
||||||
@@ -153,10 +153,10 @@ const sessionSweepInterval = setInterval(() => {
|
|||||||
sessionSweepInterval.unref();
|
sessionSweepInterval.unref();
|
||||||
|
|
||||||
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
|
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
|
||||||
const base = (getAppUrl() || '').replace(/\/+$/, '');
|
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
|
||||||
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
|
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
|
||||||
res.set('WWW-Authenticate',
|
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 {
|
interface VerifyTokenResult {
|
||||||
@@ -183,7 +183,7 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
|
|||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
// RFC 8707: audience must always match this resource endpoint.
|
// RFC 8707: audience must always match this resource endpoint.
|
||||||
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
|
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
|
||||||
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
const expected = `${(getMcpSafeUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||||
if (result.audience !== expected) return null;
|
if (result.audience !== expected) return null;
|
||||||
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
|
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
|
// Create a new per-user MCP server and session
|
||||||
const server = new McpServer(
|
const server = new McpServer(
|
||||||
{
|
{
|
||||||
name: 'TREK MCP',
|
name: 'TREK MCP',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: {
|
|
||||||
resources: { listChanged: true },
|
|
||||||
tools: { listChanged: true },
|
|
||||||
prompts: { listChanged: true },
|
|
||||||
},
|
},
|
||||||
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
|
{
|
||||||
}
|
capabilities: {
|
||||||
|
resources: { listChanged: true },
|
||||||
|
tools: { listChanged: true },
|
||||||
|
prompts: { listChanged: true },
|
||||||
|
},
|
||||||
|
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
// Per-session closure: fires the deprecation notice once, on the first tool call.
|
// 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;
|
// Tool results are the only mechanism Claude reliably surfaces to the user;
|
||||||
@@ -348,4 +348,4 @@ export function closeMcpSessions(): void {
|
|||||||
}
|
}
|
||||||
sessions.clear();
|
sessions.clear();
|
||||||
rateLimitMap.clear();
|
rateLimitMap.clear();
|
||||||
}
|
}
|
||||||
@@ -7,16 +7,16 @@ import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/serv
|
|||||||
import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors';
|
import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors';
|
||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import {
|
import {
|
||||||
createOAuthClient,
|
createOAuthClient,
|
||||||
consumeAuthCode,
|
consumeAuthCode,
|
||||||
issueTokens,
|
issueTokens,
|
||||||
refreshTokens,
|
refreshTokens,
|
||||||
revokeToken as serviceRevokeToken,
|
revokeToken as serviceRevokeToken,
|
||||||
verifyPKCE,
|
verifyPKCE,
|
||||||
getUserByAccessToken,
|
getUserByAccessToken,
|
||||||
} from '../services/oauthService';
|
} from '../services/oauthService';
|
||||||
import { ALL_SCOPES } from './scopes';
|
import { ALL_SCOPES } from './scopes';
|
||||||
import { getAppUrl } from '../services/oidcService';
|
import { getMcpSafeUrl } from '../services/notifications';
|
||||||
import { writeAudit } from '../services/auditLog';
|
import { writeAudit } from '../services/auditLog';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -24,12 +24,12 @@ import { writeAudit } from '../services/auditLog';
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface OAuthClientRow {
|
interface OAuthClientRow {
|
||||||
client_id: string;
|
client_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
redirect_uris: string; // JSON array
|
redirect_uris: string; // JSON array
|
||||||
allowed_scopes: string; // JSON array
|
allowed_scopes: string; // JSON array
|
||||||
is_public: number; // 0 | 1
|
is_public: number; // 0 | 1
|
||||||
created_via: string;
|
created_via: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -37,23 +37,23 @@ interface OAuthClientRow {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const DANGEROUS_SCHEMES = new Set([
|
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 {
|
function assertValidRedirectUris(uris: string[]): void {
|
||||||
for (const u of uris) {
|
for (const u of uris) {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try { url = new URL(u); } catch {
|
try { url = new URL(u); } catch {
|
||||||
throw new InvalidClientMetadataError(`Invalid redirect URI: ${u}`);
|
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');
|
||||||
}
|
}
|
||||||
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 {
|
function rowToInfo(row: OAuthClientRow): OAuthClientInformationFull {
|
||||||
return {
|
return {
|
||||||
client_id: row.client_id,
|
client_id: row.client_id,
|
||||||
client_name: row.name,
|
client_name: row.name,
|
||||||
redirect_uris: JSON.parse(row.redirect_uris) as string[],
|
redirect_uris: JSON.parse(row.redirect_uris) as string[],
|
||||||
scope: (JSON.parse(row.allowed_scopes) as string[]).join(' '),
|
scope: (JSON.parse(row.allowed_scopes) as string[]).join(' '),
|
||||||
token_endpoint_auth_method: row.is_public ? 'none' : 'client_secret_post',
|
token_endpoint_auth_method: row.is_public ? 'none' : 'client_secret_post',
|
||||||
grant_types: ['authorization_code', 'refresh_token'],
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
response_types: ['code'],
|
response_types: ['code'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -77,43 +77,43 @@ function rowToInfo(row: OAuthClientRow): OAuthClientInformationFull {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const trekClientsStore: OAuthRegisteredClientsStore = {
|
export const trekClientsStore: OAuthRegisteredClientsStore = {
|
||||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||||
const row = db.prepare(
|
const row = db.prepare(
|
||||||
'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?'
|
'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?'
|
||||||
).get(clientId) as OAuthClientRow | undefined;
|
).get(clientId) as OAuthClientRow | undefined;
|
||||||
return row ? rowToInfo(row) : undefined;
|
return row ? rowToInfo(row) : undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
async registerClient(
|
async registerClient(
|
||||||
metadata: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
|
metadata: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
|
||||||
): Promise<OAuthClientInformationFull> {
|
): Promise<OAuthClientInformationFull> {
|
||||||
const uris = metadata.redirect_uris as string[];
|
const uris = metadata.redirect_uris as string[];
|
||||||
assertValidRedirectUris(uris);
|
assertValidRedirectUris(uris);
|
||||||
|
|
||||||
const isPublic = metadata.token_endpoint_auth_method === 'none';
|
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 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.
|
// When scope is absent (ChatGPT DCR), default to all scopes.
|
||||||
// The user still grants only what they approve at the consent screen.
|
// The user still grants only what they approve at the consent screen.
|
||||||
const rawScopes = metadata.scope ? metadata.scope.split(' ') : ALL_SCOPES;
|
const rawScopes = metadata.scope ? metadata.scope.split(' ') : ALL_SCOPES;
|
||||||
const scopes = rawScopes.filter(s => (ALL_SCOPES as string[]).includes(s));
|
const scopes = rawScopes.filter(s => (ALL_SCOPES as string[]).includes(s));
|
||||||
if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested');
|
if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested');
|
||||||
|
|
||||||
const result = createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
|
const result = createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
|
||||||
if (result.error) throw new InvalidClientMetadataError(result.error);
|
if (result.error) throw new InvalidClientMetadataError(result.error);
|
||||||
|
|
||||||
const c = result.client!;
|
const c = result.client!;
|
||||||
return {
|
return {
|
||||||
client_id: c.client_id as string,
|
client_id: c.client_id as string,
|
||||||
client_name: c.name as string,
|
client_name: c.name as string,
|
||||||
redirect_uris: c.redirect_uris as string[],
|
redirect_uris: c.redirect_uris as string[],
|
||||||
scope: (c.allowed_scopes as string[]).join(' '),
|
scope: (c.allowed_scopes as string[]).join(' '),
|
||||||
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
|
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
|
||||||
grant_types: ['authorization_code', 'refresh_token'],
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
response_types: ['code'],
|
response_types: ['code'],
|
||||||
...(c.client_secret ? { client_secret: c.client_secret as string, client_secret_expires_at: 0 } : {}),
|
...(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 = {
|
export const trekOAuthProvider: OAuthServerProvider = {
|
||||||
get clientsStore() { return trekClientsStore; },
|
get clientsStore() { return trekClientsStore; },
|
||||||
|
|
||||||
// Redirects browser to the SPA consent page with OAuth params forwarded.
|
// Redirects browser to the SPA consent page with OAuth params forwarded.
|
||||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||||
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||||
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
|
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
|
||||||
|
|
||||||
if (resource !== mcpResource) {
|
if (resource !== mcpResource) {
|
||||||
const url = new URL(params.redirectUri);
|
const url = new URL(params.redirectUri);
|
||||||
url.searchParams.set('error', 'invalid_target');
|
url.searchParams.set('error', 'invalid_target');
|
||||||
url.searchParams.set('error_description', 'Requested resource must be the TREK MCP endpoint');
|
url.searchParams.set('error_description', 'Requested resource must be the TREK MCP endpoint');
|
||||||
if (params.state) url.searchParams.set('state', params.state);
|
if (params.state) url.searchParams.set('state', params.state);
|
||||||
res.redirect(302, url.toString());
|
res.redirect(302, url.toString());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const qs = new URLSearchParams({
|
const qs = new URLSearchParams({
|
||||||
client_id: client.client_id,
|
client_id: client.client_id,
|
||||||
redirect_uri: params.redirectUri,
|
redirect_uri: params.redirectUri,
|
||||||
scope: params.scopes.join(' '),
|
scope: params.scopes.join(' '),
|
||||||
code_challenge: params.codeChallenge,
|
code_challenge: params.codeChallenge,
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
});
|
});
|
||||||
if (params.state) qs.set('state', params.state);
|
if (params.state) qs.set('state', params.state);
|
||||||
if (params.resource) qs.set('resource', params.resource.href);
|
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.
|
// Not called because skipLocalPkceValidation = true.
|
||||||
// PKCE verification is done inline in exchangeAuthorizationCode.
|
// PKCE verification is done inline in exchangeAuthorizationCode.
|
||||||
skipLocalPkceValidation: true,
|
skipLocalPkceValidation: true,
|
||||||
|
|
||||||
async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _code: string): Promise<string> {
|
async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _code: string): Promise<string> {
|
||||||
throw new ServerError('PKCE validation is handled by the provider directly');
|
throw new ServerError('PKCE validation is handled by the provider directly');
|
||||||
},
|
},
|
||||||
|
|
||||||
async exchangeAuthorizationCode(
|
async exchangeAuthorizationCode(
|
||||||
client: OAuthClientInformationFull,
|
client: OAuthClientInformationFull,
|
||||||
code: string,
|
code: string,
|
||||||
codeVerifier?: string,
|
codeVerifier?: string,
|
||||||
redirectUri?: string,
|
redirectUri?: string,
|
||||||
resource?: URL,
|
resource?: URL,
|
||||||
): Promise<OAuthTokens> {
|
): Promise<OAuthTokens> {
|
||||||
const pending = consumeAuthCode(code);
|
const pending = consumeAuthCode(code);
|
||||||
if (!pending || pending.clientId !== client.client_id)
|
if (!pending || pending.clientId !== client.client_id)
|
||||||
throw new Error('Authorization grant is invalid.');
|
throw new Error('Authorization grant is invalid.');
|
||||||
|
|
||||||
if (redirectUri && pending.redirectUri !== redirectUri)
|
if (redirectUri && pending.redirectUri !== redirectUri)
|
||||||
throw new Error('Authorization grant is invalid.');
|
throw new Error('Authorization grant is invalid.');
|
||||||
|
|
||||||
const resourceStr = resource ? resource.href.replace(/\/+$/, '') : null;
|
const resourceStr = resource ? resource.href.replace(/\/+$/, '') : null;
|
||||||
if (pending.resource && resourceStr && pending.resource !== resourceStr)
|
if (pending.resource && resourceStr && pending.resource !== resourceStr)
|
||||||
throw new Error('Authorization grant is invalid.');
|
throw new Error('Authorization grant is invalid.');
|
||||||
|
|
||||||
if (codeVerifier && !verifyPKCE(codeVerifier, pending.codeChallenge))
|
if (codeVerifier && !verifyPKCE(codeVerifier, pending.codeChallenge))
|
||||||
throw new Error('Authorization grant is invalid.');
|
throw new Error('Authorization grant is invalid.');
|
||||||
|
|
||||||
const tokens = issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
|
const tokens = issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: pending.userId,
|
userId: pending.userId,
|
||||||
action: 'oauth.token.issue',
|
action: 'oauth.token.issue',
|
||||||
details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null },
|
details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null },
|
||||||
ip: null,
|
ip: null,
|
||||||
});
|
});
|
||||||
return tokens;
|
return tokens;
|
||||||
},
|
},
|
||||||
|
|
||||||
async exchangeRefreshToken(
|
async exchangeRefreshToken(
|
||||||
client: OAuthClientInformationFull,
|
client: OAuthClientInformationFull,
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
_scopes?: string[],
|
_scopes?: string[],
|
||||||
_resource?: URL,
|
_resource?: URL,
|
||||||
): Promise<OAuthTokens> {
|
): Promise<OAuthTokens> {
|
||||||
const result = refreshTokens(refreshToken, client.client_id, client.client_secret, null);
|
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');
|
if (result.error) throw new Error(result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired');
|
||||||
return result.tokens!;
|
return result.tokens!;
|
||||||
},
|
},
|
||||||
|
|
||||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||||
const info = getUserByAccessToken(token);
|
const info = getUserByAccessToken(token);
|
||||||
if (!info) throw new Error('Invalid or expired token');
|
if (!info) throw new Error('Invalid or expired token');
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
clientId: info.clientId,
|
clientId: info.clientId,
|
||||||
scopes: info.scopes,
|
scopes: info.scopes,
|
||||||
extra: { user: info.user },
|
extra: { user: info.user },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async revokeToken(
|
async revokeToken(
|
||||||
client: OAuthClientInformationFull,
|
client: OAuthClientInformationFull,
|
||||||
request: OAuthTokenRevocationRequest,
|
request: OAuthTokenRevocationRequest,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
serviceRevokeToken(request.token, client.client_id, undefined, null);
|
serviceRevokeToken(request.token, client.client_id, undefined, null);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -214,17 +214,17 @@ oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: R
|
|||||||
const userId = (req as OptionalAuthRequest).user?.id ?? null;
|
const userId = (req as OptionalAuthRequest).user?.id ?? null;
|
||||||
|
|
||||||
const result = validateAuthorizeRequest(
|
const result = validateAuthorizeRequest(
|
||||||
{
|
{
|
||||||
response_type: params.response_type || '',
|
response_type: params.response_type || '',
|
||||||
client_id: params.client_id || '',
|
client_id: params.client_id || '',
|
||||||
redirect_uri: params.redirect_uri || '',
|
redirect_uri: params.redirect_uri || '',
|
||||||
scope: params.scope || '',
|
scope: params.scope || '',
|
||||||
state: params.state,
|
state: params.state,
|
||||||
code_challenge: params.code_challenge || '',
|
code_challenge: params.code_challenge || '',
|
||||||
code_challenge_method: params.code_challenge_method || '',
|
code_challenge_method: params.code_challenge_method || '',
|
||||||
resource: typeof params.resource === 'string' ? params.resource : undefined,
|
resource: typeof params.resource === 'string' ? params.resource : undefined,
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// H3: when caller is unauthenticated, strip client name / allowed_scopes from the response
|
// 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));
|
const result = revokeSession(user.id, Number(req.params.id), getClientIp(req));
|
||||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||||
return res.json({ success: true });
|
return res.json({ success: true });
|
||||||
});
|
});
|
||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
touchLastLogin,
|
touchLastLogin,
|
||||||
generateToken,
|
generateToken,
|
||||||
frontendUrl,
|
frontendUrl,
|
||||||
getAppUrl,
|
|
||||||
} from '../services/oidcService';
|
} from '../services/oidcService';
|
||||||
|
import { getAppUrl } from '../services/notifications';
|
||||||
import { resolveAuthToggles } from '../services/authService';
|
import { resolveAuthToggles } from '../services/authService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||