mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86b476f011 | |||
| 959d6c3714 | |||
| c37ee2c6c3 | |||
| 0175a06c9e | |||
| 39113e12de | |||
| d02ecf239e | |||
| 8691814330 | |||
| 48098ef5ec | |||
| c565f22bf2 | |||
| 5bf8dd8cef |
@@ -13,20 +13,6 @@ on:
|
|||||||
- '.github/workflows/test.yml'
|
- '.github/workflows/test.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
i18n-parity:
|
|
||||||
name: i18n Key Parity
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
|
|
||||||
- name: Check i18n key parity
|
|
||||||
run: node shared/scripts/i18n-parity.mjs --strict
|
|
||||||
|
|
||||||
shared-contracts:
|
shared-contracts:
|
||||||
name: Shared Contracts (Zod)
|
name: Shared Contracts (Zod)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+6
-28
@@ -31,7 +31,7 @@ COPY server/ ./server/
|
|||||||
RUN npm run build --workspace=server
|
RUN npm run build --workspace=server
|
||||||
|
|
||||||
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
||||||
FROM node:24-trixie-slim
|
FROM node:24-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Workspace manifests only — source never enters this stage.
|
# Workspace manifests only — source never enters this stage.
|
||||||
@@ -39,33 +39,11 @@ COPY package.json package-lock.json ./
|
|||||||
COPY shared/package.json ./shared/
|
COPY shared/package.json ./shared/
|
||||||
COPY server/package.json ./server/
|
COPY server/package.json ./server/
|
||||||
|
|
||||||
# better-sqlite3 native addon requires build tools (purged after compile).
|
# better-sqlite3 native addon requires build tools; purged after install.
|
||||||
# kitinerary-extractor for booking-confirmation import:
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
|
||||||
# arm64 — apt package (KDE publishes no arm64 static binary)
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
|
|
||||||
npm ci --workspace=server --omit=dev && \
|
npm ci --workspace=server --omit=dev && \
|
||||||
ARCH=$(dpkg --print-architecture) && \
|
apk del python3 make g++ && \
|
||||||
if [ "$ARCH" = "amd64" ]; then \
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.0.tgz && \
|
|
||||||
echo "b7058d98990053c7b61847fef0c21e02d59b60e323e2b171ca210b682334e801 /tmp/ki.tgz" | sha256sum -c && \
|
|
||||||
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
|
|
||||||
rm /tmp/ki.tgz; \
|
|
||||||
else \
|
|
||||||
apt-get install -y --no-install-recommends libkitinerary-bin && \
|
|
||||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
|
||||||
fi && \
|
|
||||||
apt-get purge -y python3 build-essential && \
|
|
||||||
apt-get autoremove -y && \
|
|
||||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
|
||||||
|
|
||||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
|
||||||
# Prevent Qt from probing for a display in headless containers.
|
|
||||||
ENV QT_QPA_PLATFORM=offscreen
|
|
||||||
# Fixed path for both amd64 (static binary) and arm64 (symlink to apt binary).
|
|
||||||
# Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere.
|
|
||||||
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
|
||||||
|
|
||||||
COPY --from=server-builder /app/server/dist ./server/dist
|
COPY --from=server-builder /app/server/dist ./server/dist
|
||||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||||
@@ -91,4 +69,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"]
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
|
|
||||||
#### 🧳 Travel management
|
#### 🧳 Travel management
|
||||||
|
|
||||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files
|
||||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<!-- Leaflet -->
|
<!-- Leaflet -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
|||||||
@@ -23,10 +23,7 @@
|
|||||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/geist-sans": "^5.2.5",
|
|
||||||
"@fontsource/poppins": "^5.2.7",
|
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@simplewebauthn/browser": "^13.1.2",
|
|
||||||
"@trek/shared": "*",
|
"@trek/shared": "*",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ import {
|
|||||||
type CreateTagRequest, type UpdateTagRequest,
|
type CreateTagRequest, type UpdateTagRequest,
|
||||||
type CreateCategoryRequest, type UpdateCategoryRequest,
|
type CreateCategoryRequest, type UpdateCategoryRequest,
|
||||||
type PlaceImportListRequest,
|
type PlaceImportListRequest,
|
||||||
type BookingImportPreviewItem,
|
|
||||||
type BookingImportPreviewResponse,
|
|
||||||
type BookingImportConfirmResponse,
|
|
||||||
} from '@trek/shared'
|
} from '@trek/shared'
|
||||||
import { getSocketId } from './websocket'
|
import { getSocketId } from './websocket'
|
||||||
import { isReachable, probeNow } from '../sync/connectivity'
|
import { isReachable, probeNow } from '../sync/connectivity'
|
||||||
@@ -261,24 +258,6 @@ export const authApi = {
|
|||||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
||||||
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||||
},
|
},
|
||||||
passkey: {
|
|
||||||
registerOptions: (password: string) => apiClient.post('/auth/passkey/register/options', { password }).then(r => r.data),
|
|
||||||
registerVerify: (attestationResponse: unknown, name?: string) => apiClient.post('/auth/passkey/register/verify', { attestationResponse, name }).then(r => r.data),
|
|
||||||
loginOptions: () => apiClient.post('/auth/passkey/login/options', {}).then(r => r.data),
|
|
||||||
loginVerify: (assertionResponse: unknown) => apiClient.post('/auth/passkey/login/verify', { assertionResponse }).then(r => r.data as { token: string; user: Record<string, unknown> }),
|
|
||||||
list: () => apiClient.get('/auth/passkey/credentials').then(r => r.data as { credentials: PasskeyCredential[] }),
|
|
||||||
rename: (id: number, name: string) => apiClient.patch(`/auth/passkey/credentials/${id}`, { name }).then(r => r.data),
|
|
||||||
delete: (id: number, password: string) => apiClient.delete(`/auth/passkey/credentials/${id}`, { data: { password } }).then(r => r.data),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasskeyCredential {
|
|
||||||
id: number
|
|
||||||
name: string | null
|
|
||||||
device_type: string | null
|
|
||||||
backed_up: boolean
|
|
||||||
created_at: string
|
|
||||||
last_used_at: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const oauthApi = {
|
export const oauthApi = {
|
||||||
@@ -432,7 +411,6 @@ export const adminApi = {
|
|||||||
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
|
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
|
||||||
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
|
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
|
||||||
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
|
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
|
||||||
resetUserPasskeys: (id: number) => apiClient.delete(`/admin/users/${id}/passkeys`).then(r => r.data),
|
|
||||||
stats: () => apiClient.get('/admin/stats').then(r => r.data),
|
stats: () => apiClient.get('/admin/stats').then(r => r.data),
|
||||||
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
|
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
|
||||||
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
|
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
|
||||||
@@ -570,11 +548,8 @@ export const budgetApi = {
|
|||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
||||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data),
|
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data),
|
||||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
|
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
|
||||||
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
|
|
||||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
||||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
|
||||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
|
||||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||||
}
|
}
|
||||||
@@ -602,17 +577,6 @@ export const reservationsApi = {
|
|||||||
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||||
importBookingPreview: (tripId: number | string, files: File[]): Promise<BookingImportPreviewResponse> => {
|
|
||||||
const fd = new FormData()
|
|
||||||
for (const f of files) fd.append('files', f)
|
|
||||||
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
|
||||||
},
|
|
||||||
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
|
|
||||||
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const healthApi = {
|
|
||||||
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const weatherApi = {
|
export const weatherApi = {
|
||||||
|
|||||||
@@ -1,814 +0,0 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
|
||||||
import { useSearchParams } from 'react-router-dom'
|
|
||||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
|
|
||||||
import { useTripStore } from '../../store/tripStore'
|
|
||||||
import { useAuthStore } from '../../store/authStore'
|
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
|
||||||
import { useToast } from '../shared/Toast'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import { budgetApi } from '../../api/client'
|
|
||||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
|
||||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
|
||||||
import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters'
|
|
||||||
import Modal from '../shared/Modal'
|
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
|
||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
|
||||||
import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants'
|
|
||||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
|
||||||
import type { BudgetItem } from '../../types'
|
|
||||||
import type { TripMember } from './BudgetPanelMemberChips'
|
|
||||||
|
|
||||||
interface CostsPanelProps {
|
|
||||||
tripId: number
|
|
||||||
tripMembers?: TripMember[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Settlement {
|
|
||||||
id: number
|
|
||||||
from_user_id: number
|
|
||||||
to_user_id: number
|
|
||||||
amount: number
|
|
||||||
created_at?: string
|
|
||||||
from_username?: string
|
|
||||||
to_username?: string
|
|
||||||
}
|
|
||||||
interface SettlementData {
|
|
||||||
balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[]
|
|
||||||
flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[]
|
|
||||||
settlements: Settlement[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
|
||||||
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
|
|
||||||
|
|
||||||
export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps) {
|
|
||||||
const { trip, budgetItems, deleteBudgetItem, loadBudgetItems } = useTripStore()
|
|
||||||
const me = useAuthStore(s => s.user?.id ?? -1)
|
|
||||||
const can = useCanDo()
|
|
||||||
const canEdit = can('budget_edit', trip)
|
|
||||||
const toast = useToast()
|
|
||||||
const { t, locale } = useTranslation()
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
|
|
||||||
// Display/base currency = the user's preferred currency (Settings), falling back
|
|
||||||
// to the trip's own currency. Everything in Costs is converted to and shown in it.
|
|
||||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
|
||||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
|
||||||
// Pre-rework rows stored currency = NULL, meaning "the trip's own currency".
|
|
||||||
const tripCurrency = (trip?.currency || base).toUpperCase()
|
|
||||||
const { convert } = useExchangeRates(base)
|
|
||||||
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
|
|
||||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
|
||||||
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [histOpen, setHistOpen] = useState(false)
|
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
|
||||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
|
||||||
|
|
||||||
const people = tripMembers
|
|
||||||
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
|
|
||||||
const personName = useCallback((id: number) => id === me ? t('costs.you') : (personById(id)?.username || '?'), [me, personById, t])
|
|
||||||
const colorFor = useCallback((id: number) => {
|
|
||||||
const idx = people.findIndex(p => p.id === id)
|
|
||||||
return SPLIT_COLORS[(idx >= 0 ? idx : 0) % SPLIT_COLORS.length].gradient
|
|
||||||
}, [people])
|
|
||||||
const initial = useCallback((id: number) => id === me ? t('costs.youShort') : (personById(id)?.username || '?').charAt(0).toUpperCase(), [me, personById, t])
|
|
||||||
|
|
||||||
const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale])
|
|
||||||
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
|
|
||||||
|
|
||||||
const loadSettlement = useCallback(() => {
|
|
||||||
budgetApi.settlement(tripId, base).then(setSettlement).catch(() => {})
|
|
||||||
}, [tripId, base])
|
|
||||||
|
|
||||||
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
|
|
||||||
useEffect(() => { loadSettlement() }, [budgetItems.length, base])
|
|
||||||
|
|
||||||
// The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense.
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchParams.get('create') === 'expense') {
|
|
||||||
setEditing(null); setModalOpen(true)
|
|
||||||
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
|
|
||||||
}
|
|
||||||
}, [searchParams])
|
|
||||||
|
|
||||||
// ── derived expense maths (everything converted to the base currency) ────
|
|
||||||
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
|
|
||||||
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
|
|
||||||
const myShareOf = (e: BudgetItem) => {
|
|
||||||
const n = (e.members || []).length
|
|
||||||
if (!n || !(e.members || []).some(m => m.user_id === me)) return 0
|
|
||||||
return baseTotal(e) / n
|
|
||||||
}
|
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
|
||||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
|
||||||
const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0)
|
|
||||||
const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0)
|
|
||||||
const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0)
|
|
||||||
const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0)
|
|
||||||
return { totalSpend, myPaid, myShare, owe, owed }
|
|
||||||
}, [budgetItems, settlement, me])
|
|
||||||
|
|
||||||
// ── filtering + day grouping ────────────────────────────────────────────
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
let list = budgetItems.slice()
|
|
||||||
if (filter === 'mine') list = list.filter(e => myPaidOf(e) > 0)
|
|
||||||
if (filter === 'owed') list = list.filter(e => round2(myPaidOf(e) - myShareOf(e)) > 0)
|
|
||||||
const q = search.trim().toLowerCase()
|
|
||||||
if (q) list = list.filter(e => e.name.toLowerCase().includes(q))
|
|
||||||
return list
|
|
||||||
}, [budgetItems, filter, search, me])
|
|
||||||
|
|
||||||
const dayGroups = useMemo(() => {
|
|
||||||
const groups: { day: string; items: BudgetItem[] }[] = []
|
|
||||||
const labelOf = (e: BudgetItem) => {
|
|
||||||
if (!e.expense_date) return t('costs.noDate')
|
|
||||||
try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
|
|
||||||
}
|
|
||||||
const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
|
|
||||||
for (const e of sorted) {
|
|
||||||
const day = labelOf(e)
|
|
||||||
let g = groups.find(x => x.day === day)
|
|
||||||
if (!g) { g = { day, items: [] }; groups.push(g) }
|
|
||||||
g.items.push(e)
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}, [filtered, locale, t])
|
|
||||||
|
|
||||||
// ── settle actions ──────────────────────────────────────────────────────
|
|
||||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
|
||||||
try {
|
|
||||||
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount })
|
|
||||||
loadSettlement()
|
|
||||||
} catch { toast.error(t('common.unknownError')) }
|
|
||||||
}
|
|
||||||
const undoSettlement = async (id: number) => {
|
|
||||||
try { await budgetApi.deleteSettlement(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) }
|
|
||||||
}
|
|
||||||
const settleAll = async () => {
|
|
||||||
const flows = settlement?.flows || []
|
|
||||||
if (!flows.length) return
|
|
||||||
try {
|
|
||||||
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount })
|
|
||||||
loadSettlement()
|
|
||||||
} catch { toast.error(t('common.unknownError')) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateMeta = useMemo(() => {
|
|
||||||
if (!trip?.start_date || !trip?.end_date) return null
|
|
||||||
try {
|
|
||||||
const s = new Date(trip.start_date + 'T00:00:00Z'), e = new Date(trip.end_date + 'T00:00:00Z')
|
|
||||||
const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1
|
|
||||||
const opt = { day: 'numeric', month: 'short', timeZone: 'UTC' } as const
|
|
||||||
return { range: `${s.toLocaleDateString(locale, opt)} – ${e.toLocaleDateString(locale, opt)}`, days }
|
|
||||||
} catch { return null }
|
|
||||||
}, [trip?.start_date, trip?.end_date, locale])
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
try { await deleteBudgetItem(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── small presentational helpers ────────────────────────────────────────
|
|
||||||
const Avatar = ({ id, size = 24 }: { id: number; size?: number }) => {
|
|
||||||
const url = personById(id)?.avatar_url
|
|
||||||
if (url) return <img src={url} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, display: 'block' }} />
|
|
||||||
return <span style={{ width: size, height: size, borderRadius: '50%', background: colorFor(id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: size * 0.4, fontWeight: 700, flexShrink: 0 }}>{initial(id)}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardCls = 'bg-surface-card border border-edge'
|
|
||||||
const labelCls = 'text-[11px] font-semibold uppercase tracking-[0.12em] text-content-faint'
|
|
||||||
|
|
||||||
// Big money number with the design's muted symbol/decimals, locale-correct via Intl.
|
|
||||||
const bigMoney = (amount: number, smallSize: number, mutedColor: string) => {
|
|
||||||
let parts: Intl.NumberFormatPart[] | null = null
|
|
||||||
try {
|
|
||||||
const d = currencyDecimals(base)
|
|
||||||
parts = new Intl.NumberFormat(currencyLocale(base), { style: 'currency', currency: base, minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0)
|
|
||||||
} catch { return <>{formatMoney(amount, base, locale)}</> }
|
|
||||||
const isBig = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign'
|
|
||||||
return <>{parts.map((p, i) => <span key={i} style={isBig(p) ? undefined : { fontSize: smallSize, fontWeight: 500, color: mutedColor }}>{p.value}</span>)}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
|
|
||||||
{isMobile ? <MobileBody /> : (
|
|
||||||
<div style={{ maxWidth: '100%', margin: '0 auto' }}>
|
|
||||||
{/* ── Header bar ── */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}>
|
|
||||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
|
||||||
{dateMeta && (
|
|
||||||
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 999, fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap' }}>
|
|
||||||
{dateMeta.range} · <b className="text-content">{t('costs.daysCount', { count: dateMeta.days })}</b>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px 8px 10px', borderRadius: 999, fontSize: 13, fontWeight: 500 }}>
|
|
||||||
<span style={{ display: 'inline-flex' }}>
|
|
||||||
{people.slice(0, 4).map((p, i) => {
|
|
||||||
const common = { width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--bg-card)', marginLeft: i ? -8 : 0, flexShrink: 0 } as const
|
|
||||||
return p.avatar_url
|
|
||||||
? <img key={p.id} src={p.avatar_url} alt="" style={{ ...common, objectFit: 'cover', display: 'block' }} />
|
|
||||||
: <span key={p.id} style={{ ...common, background: colorFor(p.id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<b className="text-content">{t('costs.travelers', { count: people.length })}</b>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{canEdit && (
|
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
|
||||||
<button onClick={settleAll} disabled={!(settlement?.flows || []).length}
|
|
||||||
className="bg-surface-card border border-edge text-content disabled:opacity-40"
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 14, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
||||||
<Check size={16} /> {t('costs.settleUp')}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => { setEditing(null); setModalOpen(true) }}
|
|
||||||
className="bg-[var(--text-primary)] text-[var(--bg-primary)]"
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 14, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
||||||
<Plus size={16} /> {t('costs.addExpense')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Summary cards ── */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.15fr', gap: 16, marginBottom: 36 }} className="costs-summary">
|
|
||||||
<SummaryCard label={t('costs.youOwe')} sub={t('costs.youOweSub')} amount={totals.owe} currency={base} locale={locale}
|
|
||||||
icon={<ArrowDown size={18} />} tone="owe"
|
|
||||||
foot={totals.owe > 0.01
|
|
||||||
? <FlowPills ids={(settlement?.flows || []).filter(f => f.from.user_id === me).map(f => f.to.user_id)} lead={t('costs.to')} Avatar={Avatar} name={personName} />
|
|
||||||
: <span className="text-content-faint">{t('costs.allSettled')}</span>} />
|
|
||||||
<SummaryCard label={t('costs.youreOwed')} sub={t('costs.youreOwedSub')} amount={totals.owed} currency={base} locale={locale}
|
|
||||||
icon={<ArrowUp size={18} />} tone="owed"
|
|
||||||
foot={totals.owed > 0.01
|
|
||||||
? <FlowPills ids={(settlement?.flows || []).filter(f => f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} />
|
|
||||||
: <span className="text-content-faint">{t('costs.nothingOwed')}</span>} />
|
|
||||||
<SummaryCard label={t('costs.totalSpend')} sub={t('costs.totalSpendSub')} amount={totals.totalSpend} currency={base} locale={locale}
|
|
||||||
icon={<BarChart3 size={18} />} tone="total"
|
|
||||||
foot={<span style={{ display: 'flex', gap: 16 }}><span>{t('costs.yourShare')} · <b>{fmt0(totals.myShare)}</b></span><span>{t('costs.youPaid')} · <b>{fmt0(totals.myPaid)}</b></span></span>} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Main grid ── */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 380px', gap: 32, alignItems: 'start' }} className="costs-grid">
|
|
||||||
{/* expenses */}
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}>
|
|
||||||
<h3 className="text-content" style={{ fontSize: 24, fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}>
|
|
||||||
{t('costs.expenses')}
|
|
||||||
</h3>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 6, borderRadius: 10, padding: '0 10px', height: 34 }}>
|
|
||||||
<Search size={15} className="text-content-faint" />
|
|
||||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')}
|
|
||||||
className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 13, width: 150, fontFamily: 'inherit' }} />
|
|
||||||
</div>
|
|
||||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
|
|
||||||
{(['all', 'mine', 'owed'] as const).map(f => (
|
|
||||||
<button key={f} onClick={() => setFilter(f)}
|
|
||||||
className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'}
|
|
||||||
style={{ padding: '6px 11px', fontSize: 12, borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
||||||
{t('costs.filter.' + f)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dayGroups.length === 0 ? (
|
|
||||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
|
|
||||||
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
|
||||||
</div>
|
|
||||||
) : dayGroups.map(g => {
|
|
||||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
|
||||||
return (
|
|
||||||
<div key={g.day} style={{ marginBottom: 22 }}>
|
|
||||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
|
|
||||||
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* sidebar */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
{/* settle up */}
|
|
||||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
|
||||||
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
|
||||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
|
|
||||||
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
||||||
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<SettleFlows />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* balances */}
|
|
||||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
|
||||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
|
|
||||||
<BalancesList balances={settlement?.balances || []} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* by category */}
|
|
||||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
|
||||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
|
||||||
<CategoryBreakdown />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
{modalOpen && (
|
|
||||||
<ExpenseModal tripId={tripId} base={base} people={people} me={me} editing={editing}
|
|
||||||
onClose={() => setModalOpen(false)}
|
|
||||||
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
|
|
||||||
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<style>{`
|
|
||||||
.costs-root {
|
|
||||||
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
|
|
||||||
--c-surface: #ffffff; --c-surface2: oklch(0.985 0.006 78);
|
|
||||||
--c-ink: oklch(0.22 0.012 65); --c-ink2: oklch(0.42 0.012 65); --c-ink3: oklch(0.62 0.01 65);
|
|
||||||
--c-line: oklch(0.92 0.008 70);
|
|
||||||
}
|
|
||||||
html.dark .costs-root {
|
|
||||||
--c-bg: #121215; --c-bg2: #18181c;
|
|
||||||
--c-surface: #1a1a1e; --c-surface2: #202027;
|
|
||||||
--c-ink: #f4f4f5; --c-ink2: #a1a1aa; --c-ink3: #71717a;
|
|
||||||
--c-line: #2a2a31;
|
|
||||||
}
|
|
||||||
.costs-root .bg-surface-card { background: var(--c-surface) !important; }
|
|
||||||
.costs-root .bg-surface-secondary, .costs-root .bg-surface-input { background: var(--c-surface2) !important; }
|
|
||||||
.costs-root .border-edge { border-color: var(--c-line) !important; }
|
|
||||||
/* dark = neutral zinc + a touch of liquid glass, matching the dashboard */
|
|
||||||
html.dark .costs-root .bg-surface-card {
|
|
||||||
background: rgba(255,255,255,0.035) !important;
|
|
||||||
border-color: rgba(255,255,255,0.08) !important;
|
|
||||||
backdrop-filter: blur(20px) saturate(1.4);
|
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
|
||||||
}
|
|
||||||
html.dark .costs-root .bg-surface-secondary,
|
|
||||||
html.dark .costs-root .bg-surface-input { background: rgba(255,255,255,0.05) !important; }
|
|
||||||
html.dark .costs-root .border-edge { border-color: rgba(255,255,255,0.08) !important; }
|
|
||||||
.costs-root .text-content { color: var(--c-ink) !important; }
|
|
||||||
.costs-root .text-content-muted { color: var(--c-ink2) !important; }
|
|
||||||
.costs-root .text-content-faint { color: var(--c-ink3) !important; }
|
|
||||||
.costs-root .exp-actions { opacity: 1; }
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.costs-root .costs-summary { grid-template-columns: 1fr !important; }
|
|
||||||
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── shared settle-flow list ──────────────────────────────────────────────
|
|
||||||
function SettleFlows() {
|
|
||||||
const flows = settlement?.flows || []
|
|
||||||
if (flows.length === 0) return (
|
|
||||||
<div style={{ textAlign: 'center', padding: '14px 8px' }}>
|
|
||||||
<div style={{ width: 46, height: 46, borderRadius: '50%', margin: '0 auto 10px', display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><Check size={22} /></div>
|
|
||||||
<div className="text-content" style={{ fontSize: 14.5, fontWeight: 600 }}>{t('costs.everyoneSquare')}</div>
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('costs.nothingOutstanding')}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
{flows.map((f, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${personName(f.from.user_id)} → ${f.to.user_id === me ? t('costs.youLower') : personName(f.to.user_id)}`}>
|
|
||||||
<Avatar id={f.from.user_id} size={32} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={f.to.user_id} size={32} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
|
||||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 700 }}>{fmt(f.amount)}</span>
|
|
||||||
{canEdit && <button onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 12, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── mobile layout (Budget1Mobile.html): single flat column, total card on top ──
|
|
||||||
function MobileBody() {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
|
|
||||||
{/* Total card */}
|
|
||||||
<section style={{ background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff', borderRadius: 22, padding: '20px 20px 16px', boxShadow: '0 8px 24px -8px rgba(0,0,0,0.28)' }}>
|
|
||||||
<div style={{ fontSize: 11.5, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>{t('costs.totalSpend')}</div>
|
|
||||||
<div style={{ fontSize: 44, fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1, marginTop: 8, display: 'flex', alignItems: 'baseline' }}>{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}</div>
|
|
||||||
<div style={{ display: 'flex', gap: 18, marginTop: 12, fontSize: 12, color: 'rgba(255,255,255,0.6)', flexWrap: 'wrap' }}>
|
|
||||||
<span>{t('costs.yourShare')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myShare)}</b></span>
|
|
||||||
<span>{t('costs.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span>
|
|
||||||
</div>
|
|
||||||
{canEdit && (
|
|
||||||
<button onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
||||||
<Plus size={17} /> {t('costs.addExpense')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Owe / Owed */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
|
||||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
|
||||||
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#dc262622', color: '#dc2626' }}><ArrowDown size={17} /></div>
|
|
||||||
<div className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youOwe')}</div>
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youOweSub')}</div>
|
|
||||||
<div style={{ fontSize: 27, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#dc2626' }}>{bigMoney(totals.owe, 16, 'var(--c-ink3)')}</div>
|
|
||||||
</div>
|
|
||||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
|
||||||
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#16a34a22', color: '#16a34a' }}><ArrowUp size={17} /></div>
|
|
||||||
<div className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youreOwed')}</div>
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youreOwedSub')}</div>
|
|
||||||
<div style={{ fontSize: 27, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#16a34a' }}>{bigMoney(totals.owed, 16, 'var(--c-ink3)')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settle up */}
|
|
||||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
|
||||||
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
|
||||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
|
|
||||||
</div>
|
|
||||||
<SettleFlows />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expenses */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
|
|
||||||
<div className="bg-surface-card border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 8, borderRadius: 12, padding: '0 12px', height: 42 }}>
|
|
||||||
<Search size={16} className="text-content-faint" />
|
|
||||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 14, width: '100%', fontFamily: 'inherit' }} />
|
|
||||||
</div>
|
|
||||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}>
|
|
||||||
{(['all', 'mine', 'owed'] as const).map(f => (
|
|
||||||
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 12.5, fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{dayGroups.length === 0
|
|
||||||
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
|
||||||
: dayGroups.map(g => {
|
|
||||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
|
||||||
return (
|
|
||||||
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Balances */}
|
|
||||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
|
||||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
|
|
||||||
<BalancesList balances={settlement?.balances || []} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* By category */}
|
|
||||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
|
||||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
|
||||||
<CategoryBreakdown />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── inline subcomponents (close over helpers) ────────────────────────────
|
|
||||||
function ExpenseRow({ e }: { e: BudgetItem }) {
|
|
||||||
const c = catMeta(e.category)
|
|
||||||
const Icon = c.Icon
|
|
||||||
const cur = curOf(e)
|
|
||||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
|
||||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
|
||||||
return (
|
|
||||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
|
||||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
|
|
||||||
{payers.length > 0 && (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
|
||||||
{payers.map(p => (
|
|
||||||
<span key={p.user_id} className="bg-surface-secondary border border-edge" title={personName(p.user_id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 11.5 }}>
|
|
||||||
<Avatar id={p.user_id} size={18} />
|
|
||||||
<span className="text-content" style={{ fontWeight: 700 }}>{fmt(convert(p.amount, cur))}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isMobile && (
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
||||||
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)} → ${fmt(baseTotal(e))}` : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
|
||||||
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
|
||||||
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
|
||||||
{(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
|
||||||
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
|
||||||
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{canEdit && (
|
|
||||||
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
|
||||||
<button title={t('common.edit')} onClick={() => { setEditing(e); setModalOpen(true) }} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
|
|
||||||
<button title={t('common.delete')} onClick={() => handleDelete(e.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><Trash2 size={13} /></button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
|
||||||
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
|
||||||
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
||||||
{rows.map(r => {
|
|
||||||
const pct = Math.min(100, Math.abs(r.balance) / max * 100)
|
|
||||||
const pos = r.balance > 0.01, neg = r.balance < -0.01
|
|
||||||
return (
|
|
||||||
<div key={r.user_id} style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto', gap: 10, alignItems: 'center' }}>
|
|
||||||
<Avatar id={r.user_id} size={28} />
|
|
||||||
<div>
|
|
||||||
<div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{personName(r.user_id)}</div>
|
|
||||||
<div className="bg-surface-secondary" style={{ height: 5, borderRadius: 3, marginTop: 5, position: 'relative', overflow: 'hidden' }}>
|
|
||||||
<span style={{ position: 'absolute', left: '50%', top: -1, bottom: -1, width: 1, background: 'var(--border-primary)' }} />
|
|
||||||
{pos && <span style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#16a34a', borderRadius: 3 }} />}
|
|
||||||
{neg && <span style={{ position: 'absolute', right: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#dc2626', borderRadius: 3 }} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, textAlign: 'right', color: pos ? '#16a34a' : neg ? '#dc2626' : 'var(--text-faint)' }}>
|
|
||||||
{pos ? '+' + fmt(r.balance) : neg ? '−' + fmt(-r.balance) : fmt(0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CategoryBreakdown() {
|
|
||||||
const tot: Record<string, number> = {}
|
|
||||||
let grand = 0
|
|
||||||
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
|
|
||||||
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
|
||||||
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
{rows.map(c => {
|
|
||||||
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
|
|
||||||
return (
|
|
||||||
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
|
||||||
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
|
||||||
<span className="text-content" style={{ fontSize: 13, fontWeight: 500 }}>{t(c.labelKey)}</span>
|
|
||||||
<span className="text-content-muted" style={{ fontSize: 13, fontWeight: 600 }}>{fmt0(v)}</span>
|
|
||||||
<div className="bg-surface-secondary" style={{ gridColumn: '1 / -1', height: 5, borderRadius: 3, overflow: 'hidden', marginTop: -2 }}>
|
|
||||||
<span style={{ display: 'block', height: '100%', width: pct + '%', background: c.color, borderRadius: 3 }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── pure subcomponents ─────────────────────────────────────────────────────
|
|
||||||
function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' }) {
|
|
||||||
const total = tone === 'total'
|
|
||||||
const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : undefined
|
|
||||||
const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)'
|
|
||||||
// formatToParts keeps the design's "big integer + muted symbol/decimals" styling
|
|
||||||
// while letting Intl place the symbol and pick separators per locale + currency.
|
|
||||||
let parts: Intl.NumberFormatPart[] | null = null
|
|
||||||
try {
|
|
||||||
const d = currencyDecimals(currency)
|
|
||||||
parts = new Intl.NumberFormat(currencyLocale(currency), { style: 'currency', currency: (currency || 'EUR').toUpperCase(), minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0)
|
|
||||||
} catch { parts = null }
|
|
||||||
const big = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign'
|
|
||||||
return (
|
|
||||||
<div className={total ? '' : 'bg-surface-card border border-edge'}
|
|
||||||
style={{ borderRadius: 22, padding: '26px 28px', position: 'relative', overflow: 'hidden', ...(total ? { background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff' } : {}) }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
|
|
||||||
<span style={{ width: 36, height: 36, borderRadius: 11, display: 'grid', placeItems: 'center', background: total ? 'rgba(255,255,255,0.12)' : (accent + '22'), color: total ? '#fff' : accent }}>{icon}</span>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600 }} className={total ? '' : 'text-content'}>{label}</div>
|
|
||||||
<div style={{ fontSize: 12, opacity: total ? 0.6 : 1 }} className={total ? '' : 'text-content-faint'}>{sub}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 46, fontWeight: 600, letterSpacing: '-0.035em', lineHeight: 1, marginTop: 20, display: 'flex', alignItems: 'baseline', color: total ? '#fff' : accent }}>
|
|
||||||
{parts
|
|
||||||
? parts.map((p, i) => <span key={i} style={big(p) ? undefined : { fontSize: 26, fontWeight: 500, color: muted }}>{p.value}</span>)
|
|
||||||
: <span>{formatMoney(amount, currency, locale)}</span>}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 16, fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', opacity: total ? 0.85 : 1 }}>{foot}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string }) {
|
|
||||||
const uniq = Array.from(new Set(ids))
|
|
||||||
return (
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
|
||||||
<span className="text-content-faint">{lead}</span>
|
|
||||||
{uniq.map(id => (
|
|
||||||
<span key={id} className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 12, fontWeight: 600 }}>
|
|
||||||
<Avatar id={id} size={18} />{name(id)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
|
|
||||||
settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
|
|
||||||
const total = settlements.reduce((a, s) => a + s.amount, 0)
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
|
|
||||||
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
||||||
{settlements.map(s => (
|
|
||||||
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)} → ${name(s.to_user_id)}`}>
|
|
||||||
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
|
|
||||||
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Add / edit expense modal ───────────────────────────────────────────────
|
|
||||||
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
|
||||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
|
|
||||||
}) {
|
|
||||||
const { t, locale } = useTranslation()
|
|
||||||
const toast = useToast()
|
|
||||||
const { addBudgetItem, updateBudgetItem } = useTripStore()
|
|
||||||
const { convert } = useExchangeRates(base)
|
|
||||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
|
||||||
|
|
||||||
const [name, setName] = useState(editing?.name || '')
|
|
||||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
|
|
||||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
|
||||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
|
||||||
const [payers, setPayers] = useState<Record<number, string>>(() => {
|
|
||||||
const m: Record<number, string> = {}
|
|
||||||
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
|
|
||||||
return m
|
|
||||||
})
|
|
||||||
const [split, setSplit] = useState<Set<number>>(() =>
|
|
||||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
|
|
||||||
const each = split.size > 0 ? payersTotal / split.size : 0
|
|
||||||
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (!valid) return
|
|
||||||
setSaving(true)
|
|
||||||
const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
|
|
||||||
const data = {
|
|
||||||
name: name.trim(), category: cat,
|
|
||||||
// Store the actual currency the amounts were entered in; conversion to the
|
|
||||||
// viewer's display currency happens live (real rates), no manual rate.
|
|
||||||
currency,
|
|
||||||
payers: payerList, member_ids: [...split],
|
|
||||||
expense_date: day || null,
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
|
||||||
else await addBudgetItem(tripId, data)
|
|
||||||
onSaved()
|
|
||||||
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
|
||||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size="2xl"
|
|
||||||
footer={
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
||||||
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
|
||||||
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
||||||
<div>
|
|
||||||
<label className={labelCls}>{t('costs.whatFor')}</label>
|
|
||||||
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
|
||||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
|
||||||
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
|
||||||
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<label className={labelCls}>{t('costs.currency')}</label>
|
|
||||||
<CustomSelect value={currency} onChange={v => setCurrency(String(v))} searchable
|
|
||||||
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
|
||||||
style={{ width: '100%' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<label className={labelCls}>{t('costs.day')}</label>
|
|
||||||
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currency !== base && payersTotal > 0 && (
|
|
||||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<span>{formatMoney(payersTotal, currency, locale)}</span>
|
|
||||||
<span className="text-content-faint">≈</span>
|
|
||||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
|
|
||||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className={labelCls}>{t('costs.category')}</label>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
|
||||||
{COST_CATEGORY_LIST.map(c => {
|
|
||||||
const Icon = c.Icon; const on = cat === c.key
|
|
||||||
return (
|
|
||||||
<button key={c.key} onClick={() => setCat(c.key)}
|
|
||||||
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 11px 6px 7px', borderRadius: 999, fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
|
||||||
<span style={{ width: 20, height: 20, borderRadius: 6, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={12} /></span>
|
|
||||||
{t(c.labelKey)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
|
||||||
{people.map(p => (
|
|
||||||
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10 }}>
|
|
||||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
|
|
||||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
|
||||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
|
||||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
|
|
||||||
onChange={e => setPayers(prev => ({ ...prev, [p.id]: e.target.value }))}
|
|
||||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className={labelCls}>{t('costs.splitBetween')}</label>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
|
||||||
{people.map(p => {
|
|
||||||
const on = split.has(p.id)
|
|
||||||
return (
|
|
||||||
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
|
|
||||||
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
|
||||||
{p.avatar_url
|
|
||||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
|
|
||||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
|
||||||
{p.id === me ? t('costs.you') : p.username}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
|
|
||||||
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal } from 'lucide-react'
|
|
||||||
import type { LucideIcon } from 'lucide-react'
|
|
||||||
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fixed Costs categories. Users can't add their own — every expense maps to
|
|
||||||
* one of these. Category colour is the one place an accent is allowed (it
|
|
||||||
* visualises the category); everything else stays black/white. The label comes
|
|
||||||
* from i18n (`costs.cat.*`).
|
|
||||||
*/
|
|
||||||
export interface CostCategoryMeta {
|
|
||||||
key: CostCategory
|
|
||||||
labelKey: string
|
|
||||||
Icon: LucideIcon
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
|
||||||
accommodation: { key: 'accommodation', labelKey: 'costs.cat.accommodation', Icon: Hotel, color: '#16a34a' },
|
|
||||||
food: { key: 'food', labelKey: 'costs.cat.food', Icon: Utensils, color: '#ea580c' },
|
|
||||||
groceries: { key: 'groceries', labelKey: 'costs.cat.groceries', Icon: ShoppingCart, color: '#65a30d' },
|
|
||||||
transport: { key: 'transport', labelKey: 'costs.cat.transport', Icon: Bus, color: '#2563eb' },
|
|
||||||
flights: { key: 'flights', labelKey: 'costs.cat.flights', Icon: Plane, color: '#0ea5e9' },
|
|
||||||
activities: { key: 'activities', labelKey: 'costs.cat.activities', Icon: Ticket, color: '#9333ea' },
|
|
||||||
sightseeing: { key: 'sightseeing', labelKey: 'costs.cat.sightseeing', Icon: Camera, color: '#db2777' },
|
|
||||||
shopping: { key: 'shopping', labelKey: 'costs.cat.shopping', Icon: ShoppingBag, color: '#e11d48' },
|
|
||||||
fees: { key: 'fees', labelKey: 'costs.cat.fees', Icon: FileText, color: '#475569' },
|
|
||||||
health: { key: 'health', labelKey: 'costs.cat.health', Icon: HeartPulse, color: '#dc2626' },
|
|
||||||
tips: { key: 'tips', labelKey: 'costs.cat.tips', Icon: Coins, color: '#d97706' },
|
|
||||||
other: { key: 'other', labelKey: 'costs.cat.other', Icon: MoreHorizontal, color: '#6b7280' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
|
||||||
|
|
||||||
/** Map any stored category (incl. legacy free-text values) to a known meta. */
|
|
||||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
|
||||||
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
|
||||||
return COST_CAT_META.other
|
|
||||||
}
|
|
||||||
@@ -12,10 +12,10 @@ export function ChatMessages(props: any) {
|
|||||||
<>
|
<>
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32, textAlign: 'center' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
|
||||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||||
<span style={{ fontSize: 12, opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span>
|
<span style={{ fontSize: 12, opacity: 0.6 }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const FONT = "var(--font-system)"
|
export const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
|
||||||
|
|
||||||
export const NOTE_COLORS = [
|
export const NOTE_COLORS = [
|
||||||
{ value: '#6366f1', label: 'Indigo' },
|
{ value: '#6366f1', label: 'Indigo' },
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ interface Poll {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const FONT = "var(--font-system)"
|
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
|
||||||
|
|
||||||
function timeRemaining(deadline) {
|
function timeRemaining(deadline) {
|
||||||
if (!deadline) return null
|
if (!deadline) return null
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise'])
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FileText, FileImage, File, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
import { FileText, FileImage, File, Plane, Train, Car, Ship } from 'lucide-react'
|
||||||
import { downloadFile } from '../../utils/fileDownload'
|
import { downloadFile } from '../../utils/fileDownload'
|
||||||
|
|
||||||
export function isImage(mimeType?: string | null) {
|
export function isImage(mimeType?: string | null) {
|
||||||
@@ -33,12 +33,7 @@ export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
|
|||||||
|
|
||||||
export function transportIcon(type: string) {
|
export function transportIcon(type: string) {
|
||||||
if (type === 'train') return Train
|
if (type === 'train') return Train
|
||||||
if (type === 'bus') return Bus
|
|
||||||
if (type === 'car') return Car
|
if (type === 'car') return Car
|
||||||
if (type === 'taxi') return CarTaxiFront
|
|
||||||
if (type === 'bicycle') return Bike
|
|
||||||
if (type === 'cruise') return Ship
|
if (type === 'cruise') return Ship
|
||||||
if (type === 'ferry') return Sailboat
|
|
||||||
if (type === 'transport_other') return Route
|
|
||||||
return Plane
|
return Plane
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function FileManager(props: FileManagerProps) {
|
|||||||
const S = useFileManager(props)
|
const S = useFileManager(props)
|
||||||
const { lightboxIndex, setLightboxIndex, imageFiles, assignFileId, previewFile, handlePaste, showTrash } = S
|
const { lightboxIndex, setLightboxIndex, imageFiles, assignFileId, previewFile, handlePaste, showTrash } = S
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}>
|
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
|
||||||
{/* Lightbox */}
|
{/* Lightbox */}
|
||||||
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st
|
|||||||
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
|
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
||||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function ensureJourneyPopupStyle() {
|
|||||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||||
font-family:var(--font-system);
|
font-family: -apple-system, system-ui, sans-serif;
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
}
|
}
|
||||||
@@ -185,7 +185,7 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
|
|||||||
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
|
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
</svg>`
|
</svg>`
|
||||||
wrap.appendChild(inner)
|
wrap.appendChild(inner)
|
||||||
return wrap
|
return wrap
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed', inset: 0, zIndex: 10000,
|
position: 'fixed', inset: 0, zIndex: 500,
|
||||||
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
||||||
display: 'flex', flexDirection: 'column',
|
display: 'flex', flexDirection: 'column',
|
||||||
paddingBottom: 'var(--bottom-nav-h)',
|
paddingBottom: 'var(--bottom-nav-h)',
|
||||||
|
|||||||
@@ -25,11 +25,6 @@ function useCreateAction(): { label: string; run: () => void } {
|
|||||||
const onJourneyList = useMatch('/journey')
|
const onJourneyList = useMatch('/journey')
|
||||||
|
|
||||||
if (inTrip) {
|
if (inTrip) {
|
||||||
// On the Costs tab the "+" adds an expense; otherwise it adds a place.
|
|
||||||
const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${inTrip.params.id}`) : null
|
|
||||||
if (tripTab === 'finanzplan') {
|
|
||||||
return { label: t('costs.addExpense'), run: () => navigate(`/trips/${inTrip.params.id}?create=expense`) }
|
|
||||||
}
|
|
||||||
return { label: t('places.addPlace'), run: () => navigate(`/trips/${inTrip.params.id}?create=place`) }
|
return { label: t('places.addPlace'), run: () => navigate(`/trips/${inTrip.params.id}?create=place`) }
|
||||||
}
|
}
|
||||||
if (inJourney) {
|
if (inJourney) {
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||||
paddingLeft: 16, paddingRight: 16,
|
paddingLeft: 16, paddingRight: 16,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
fontFamily: "var(--font-system)",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
}} onClick={() => setDismissed(true)}>
|
}} onClick={() => setDismissed(true)}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
||||||
font-family:var(--font-system);line-height:1;
|
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
||||||
box-sizing:border-box;white-space:nowrap;
|
box-sizing:border-box;white-space:nowrap;
|
||||||
">${label}</span>`
|
">${label}</span>`
|
||||||
}
|
}
|
||||||
@@ -592,7 +592,7 @@ export const MapView = memo(function MapView({
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
||||||
padding: '6px 10px',
|
padding: '6px 10px',
|
||||||
fontFamily: "var(--font-system)",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
maxWidth: 220,
|
maxWidth: 220,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_
|
|||||||
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
||||||
font-family:var(--font-system);line-height:1;
|
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
||||||
box-sizing:border-box;white-space:nowrap;
|
box-sizing:border-box;white-space:nowrap;
|
||||||
">${label}</span>`
|
">${label}</span>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createElement, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
|
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
import { Plane, Train, Ship, Car } from 'lucide-react'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@ const ENDPOINT_PANE = 'reservation-endpoints'
|
|||||||
const AIRPORT_BADGE_HALF_PX = 16
|
const AIRPORT_BADGE_HALF_PX = 16
|
||||||
const BADGE_GAP_PX = 5
|
const BADGE_GAP_PX = 5
|
||||||
|
|
||||||
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transport_other'
|
type TransportType = 'flight' | 'train' | 'cruise' | 'car'
|
||||||
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transport_other']
|
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car']
|
||||||
|
|
||||||
const TRANSPORT_COLOR = '#3b82f6'
|
const TRANSPORT_COLOR = '#3b82f6'
|
||||||
|
|
||||||
@@ -20,11 +20,6 @@ const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geod
|
|||||||
train: { color: TRANSPORT_COLOR, icon: Train, geodesic: false },
|
train: { color: TRANSPORT_COLOR, icon: Train, geodesic: false },
|
||||||
cruise: { color: TRANSPORT_COLOR, icon: Ship, geodesic: true },
|
cruise: { color: TRANSPORT_COLOR, icon: Ship, geodesic: true },
|
||||||
car: { color: TRANSPORT_COLOR, icon: Car, geodesic: false },
|
car: { color: TRANSPORT_COLOR, icon: Car, geodesic: false },
|
||||||
bus: { color: TRANSPORT_COLOR, icon: Bus, geodesic: false },
|
|
||||||
taxi: { color: TRANSPORT_COLOR, icon: CarTaxiFront, geodesic: false },
|
|
||||||
bicycle: { color: TRANSPORT_COLOR, icon: Bike, geodesic: false },
|
|
||||||
ferry: { color: TRANSPORT_COLOR, icon: Sailboat, geodesic: true },
|
|
||||||
transport_other: { color: TRANSPORT_COLOR, icon: Route, geodesic: false },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useEndpointPane() {
|
function useEndpointPane() {
|
||||||
@@ -51,7 +46,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
|||||||
padding:0 8px;border-radius:999px;
|
padding:0 8px;border-radius:999px;
|
||||||
background:${color};box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
background:${color};box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
||||||
border:1.5px solid #fff;color:#fff;
|
border:1.5px solid #fff;color:#fff;
|
||||||
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
||||||
box-sizing:border-box;height:22px;white-space:nowrap;
|
box-sizing:border-box;height:22px;white-space:nowrap;
|
||||||
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''}</div>`,
|
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''}</div>`,
|
||||||
iconSize: [estWidth, 22],
|
iconSize: [estWidth, 22],
|
||||||
@@ -181,7 +176,7 @@ function buildStatsHtml(color: string, mainLabel: string | null, subLabel: strin
|
|||||||
background:rgba(17,24,39,0.92);color:#fff;
|
background:rgba(17,24,39,0.92);color:#fff;
|
||||||
box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
||||||
border:1px solid ${color}aa;
|
border:1px solid ${color}aa;
|
||||||
font-family:var(--font-system);
|
font-family:-apple-system,system-ui,'SF Pro Text',sans-serif;
|
||||||
white-space:nowrap;box-sizing:border-box;
|
white-space:nowrap;box-sizing:border-box;
|
||||||
transform-origin:center;
|
transform-origin:center;
|
||||||
will-change:transform;
|
will-change:transform;
|
||||||
|
|||||||
@@ -9,14 +9,14 @@
|
|||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import mapboxgl from 'mapbox-gl'
|
import mapboxgl from 'mapbox-gl'
|
||||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
import { Plane, Train, Ship, Car } from 'lucide-react'
|
||||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||||
|
|
||||||
export const RESERVATION_SOURCE_ID = 'trek-reservations'
|
export const RESERVATION_SOURCE_ID = 'trek-reservations'
|
||||||
export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
|
export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
|
||||||
|
|
||||||
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transport_other'
|
type TransportType = 'flight' | 'train' | 'cruise' | 'car'
|
||||||
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transport_other']
|
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car']
|
||||||
const TRANSPORT_COLOR = '#3b82f6'
|
const TRANSPORT_COLOR = '#3b82f6'
|
||||||
|
|
||||||
const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
|
const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
|
||||||
@@ -24,11 +24,6 @@ const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }
|
|||||||
train: { icon: Train, geodesic: false },
|
train: { icon: Train, geodesic: false },
|
||||||
cruise: { icon: Ship, geodesic: true },
|
cruise: { icon: Ship, geodesic: true },
|
||||||
car: { icon: Car, geodesic: false },
|
car: { icon: Car, geodesic: false },
|
||||||
bus: { icon: Bus, geodesic: false },
|
|
||||||
taxi: { icon: CarTaxiFront, geodesic: false },
|
|
||||||
bicycle: { icon: Bike, geodesic: false },
|
|
||||||
ferry: { icon: Sailboat, geodesic: true },
|
|
||||||
transport_other: { icon: Route, geodesic: false },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── geometry helpers (ported from ReservationOverlay.tsx) ────────────────
|
// ── geometry helpers (ported from ReservationOverlay.tsx) ────────────────
|
||||||
@@ -167,7 +162,7 @@ function endpointMarkerHtml(type: TransportType, label: string | null): string {
|
|||||||
padding:0 8px;border-radius:999px;
|
padding:0 8px;border-radius:999px;
|
||||||
background:${TRANSPORT_COLOR};box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
background:${TRANSPORT_COLOR};box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
||||||
border:1.5px solid #fff;color:#fff;
|
border:1.5px solid #fff;color:#fff;
|
||||||
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
||||||
box-sizing:border-box;height:22px;white-space:nowrap;cursor:pointer;
|
box-sizing:border-box;height:22px;white-space:nowrap;cursor:pointer;
|
||||||
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`
|
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`
|
||||||
}
|
}
|
||||||
@@ -188,7 +183,7 @@ function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { ht
|
|||||||
background:rgba(17,24,39,0.92);color:#fff;
|
background:rgba(17,24,39,0.92);color:#fff;
|
||||||
box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
||||||
border:1px solid ${TRANSPORT_COLOR}aa;
|
border:1px solid ${TRANSPORT_COLOR}aa;
|
||||||
font-family:var(--font-system);
|
font-family:-apple-system,system-ui,'SF Pro Text',sans-serif;
|
||||||
white-space:nowrap;box-sizing:border-box;pointer-events:none;
|
white-space:nowrap;box-sizing:border-box;pointer-events:none;
|
||||||
transform-origin:center;will-change:transform;
|
transform-origin:center;will-change:transform;
|
||||||
">${main}${sub}</div>`
|
">${main}${sub}</div>`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Trip PDF via browser print window
|
// Trip PDF via browser print window
|
||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Sailboat, Bike, CarTaxiFront, Route, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
|
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
|
||||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
||||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||||
@@ -20,8 +20,8 @@ function noteIconSvg(iconId) {
|
|||||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
|
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, taxi: CarTaxiFront, bicycle: Bike, cruise: Ship, ferry: Sailboat, transport_other: Route, restaurant: Utensils, event: Ticket, tour: Users, other: FileText }
|
const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship, restaurant: Utensils, event: Ticket, tour: Users, other: FileText }
|
||||||
const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', car: '#6b7280', taxi: '#ca8a04', bicycle: '#84cc16', cruise: '#0ea5e9', ferry: '#0d9488', transport_other: '#6b7280', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#6b7280', car: '#6b7280', cruise: '#0ea5e9', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||||
function reservationIconSvg(type) {
|
function reservationIconSvg(type) {
|
||||||
const Icon = RESERVATION_ICON_MAP[type] || Ticket
|
const Icon = RESERVATION_ICON_MAP[type] || Ticket
|
||||||
const color = RESERVATION_COLOR_MAP[type] || '#3b82f6'
|
const color = RESERVATION_COLOR_MAP[type] || '#3b82f6'
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
reader.readAsText(file)
|
reader.readAsText(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
const font = { fontFamily: "var(--font-system)" }
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tripId, items, inlineHeader, t, canEdit, font,
|
tripId, items, inlineHeader, t, canEdit, font,
|
||||||
|
|||||||
@@ -1,382 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom'
|
|
||||||
import { useState, useRef, useEffect } from 'react'
|
|
||||||
import { Upload, Plane, Train, Hotel, UtensilsCrossed, Car, Anchor, Calendar, ArrowLeft, X } from 'lucide-react'
|
|
||||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import { useToast } from '../shared/Toast'
|
|
||||||
import { reservationsApi } from '../../api/client'
|
|
||||||
import { useTripStore } from '../../store/tripStore'
|
|
||||||
|
|
||||||
interface BookingImportModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
tripId: number
|
|
||||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACCEPTED_EXTS = ['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']
|
|
||||||
const MAX_FILE_BYTES = 10 * 1024 * 1024
|
|
||||||
const MAX_FILES = 5
|
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, React.FC<{ size: number; color?: string }>> = {
|
|
||||||
flight: Plane,
|
|
||||||
train: Train,
|
|
||||||
hotel: Hotel,
|
|
||||||
restaurant: UtensilsCrossed,
|
|
||||||
car: Car,
|
|
||||||
cruise: Anchor,
|
|
||||||
event: Calendar,
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeColor(type: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
flight: '#3b82f6',
|
|
||||||
train: '#10b981',
|
|
||||||
hotel: '#8b5cf6',
|
|
||||||
restaurant: '#f59e0b',
|
|
||||||
car: '#6b7280',
|
|
||||||
cruise: '#06b6d4',
|
|
||||||
event: '#ec4899',
|
|
||||||
}
|
|
||||||
return map[type] ?? 'var(--text-faint)'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(iso: unknown): string {
|
|
||||||
if (!iso) return ''
|
|
||||||
const str = typeof iso === 'string' ? iso : typeof iso === 'object' ? JSON.stringify(iso) : String(iso)
|
|
||||||
const date = str.slice(0, 10)
|
|
||||||
const time = str.length > 10 ? str.slice(11, 16) : ''
|
|
||||||
return [date, time].filter(Boolean).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }: BookingImportModalProps) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const toast = useToast()
|
|
||||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
|
||||||
|
|
||||||
type Phase = 'upload' | 'preview' | 'confirming'
|
|
||||||
const [phase, setPhase] = useState<Phase>('upload')
|
|
||||||
const [files, setFiles] = useState<File[]>([])
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [previewItems, setPreviewItems] = useState<BookingImportPreviewItem[]>([])
|
|
||||||
const [warnings, setWarnings] = useState<string[]>([])
|
|
||||||
const [excluded, setExcluded] = useState<Set<number>>(() => new Set())
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setPhase('upload')
|
|
||||||
setFiles([])
|
|
||||||
setIsDragOver(false)
|
|
||||||
setLoading(false)
|
|
||||||
setError('')
|
|
||||||
setPreviewItems([])
|
|
||||||
setWarnings([])
|
|
||||||
setExcluded(new Set())
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) reset()
|
|
||||||
// reset is stable — intentional
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [isOpen])
|
|
||||||
|
|
||||||
const handleClose = () => { reset(); onClose() }
|
|
||||||
|
|
||||||
const validateFile = (f: File): string | null => {
|
|
||||||
const ext = ('.' + f.name.toLowerCase().split('.').pop()) as string
|
|
||||||
if (!ACCEPTED_EXTS.includes(ext)) return t('reservations.import.unsupportedFormat')
|
|
||||||
if (f.size > MAX_FILE_BYTES) return t('reservations.import.fileTooLarge', { name: f.name })
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectFiles = (incoming: File[]) => {
|
|
||||||
const valid: File[] = []
|
|
||||||
let firstErr: string | null = null
|
|
||||||
for (const f of incoming.slice(0, MAX_FILES)) {
|
|
||||||
const err = validateFile(f)
|
|
||||||
if (err) { firstErr = firstErr ?? err; continue }
|
|
||||||
valid.push(f)
|
|
||||||
}
|
|
||||||
if (valid.length === 0) { setError(firstErr ?? ''); return }
|
|
||||||
setFiles(valid)
|
|
||||||
setError(firstErr ?? '')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const list = e.target.files ? Array.from(e.target.files) : []
|
|
||||||
e.target.value = ''
|
|
||||||
if (list.length) selectFiles(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true) }
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => { if (e.target === e.currentTarget) setIsDragOver(false) }
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setIsDragOver(false)
|
|
||||||
const list = Array.from(e.dataTransfer.files)
|
|
||||||
if (list.length) selectFiles(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleParse = async () => {
|
|
||||||
if (files.length === 0 || loading) return
|
|
||||||
setLoading(true)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
const result = await reservationsApi.importBookingPreview(tripId, files)
|
|
||||||
setPreviewItems(result.items ?? [])
|
|
||||||
setWarnings(result.warnings ?? [])
|
|
||||||
setExcluded(new Set())
|
|
||||||
setPhase('preview')
|
|
||||||
} catch (err: any) {
|
|
||||||
const msg = err?.response?.data?.error ?? t('reservations.import.error')
|
|
||||||
setError(msg)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
const toImport = previewItems.filter((_, i) => !excluded.has(i))
|
|
||||||
if (toImport.length === 0) return
|
|
||||||
setPhase('confirming')
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
const result = await reservationsApi.importBookingConfirm(tripId, toImport)
|
|
||||||
const created = result.created ?? []
|
|
||||||
await loadTrip(tripId)
|
|
||||||
|
|
||||||
if (created.length > 0) {
|
|
||||||
pushUndo?.(t('undo.importBooking'), async () => {
|
|
||||||
try {
|
|
||||||
const { reservationsApi: rApi } = await import('../../api/client')
|
|
||||||
await Promise.all(created.map((r) => rApi.delete(tripId, r.id).catch(() => {})))
|
|
||||||
} catch {}
|
|
||||||
await loadTrip(tripId)
|
|
||||||
})
|
|
||||||
toast.success(t('reservations.import.success', { count: created.length }))
|
|
||||||
} else {
|
|
||||||
toast.warning(t('reservations.import.previewEmpty'))
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClose()
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.response?.data?.error ?? t('reservations.import.error'))
|
|
||||||
setPhase('preview')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleExclude = (idx: number) => {
|
|
||||||
setExcluded(prev => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(idx)) next.delete(idx); else next.add(idx)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeCount = previewItems.filter((_, i) => !excluded.has(i)).length
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
|
||||||
<div
|
|
||||||
className="bg-[rgba(0,0,0,0.4)]"
|
|
||||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
|
||||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
|
||||||
onClick={e => {
|
|
||||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
|
|
||||||
mouseDownTarget.current = null
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
className="bg-surface-card"
|
|
||||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
|
||||||
{phase === 'preview' && (
|
|
||||||
<button onClick={() => setPhase('upload')} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
|
||||||
{t('reservations.import.title')}
|
|
||||||
</div>
|
|
||||||
<button onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
|
||||||
{/* Upload phase */}
|
|
||||||
{phase === 'upload' && (
|
|
||||||
<>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
|
||||||
{t('reservations.import.acceptedFormats')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={ACCEPTED_EXTS.join(',')}
|
|
||||||
multiple
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnter={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
|
|
||||||
style={{
|
|
||||||
width: '100%', minHeight: 100, borderRadius: 12,
|
|
||||||
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
|
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
||||||
gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer',
|
|
||||||
marginBottom: 12, padding: 16, boxSizing: 'border-box',
|
|
||||||
transition: 'border-color 0.15s, background 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
|
||||||
{isDragOver ? (
|
|
||||||
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
|
|
||||||
) : files.length > 0 ? (
|
|
||||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map(f => f.name).join(', ')}</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Preview phase */}
|
|
||||||
{(phase === 'preview' || phase === 'confirming') && (
|
|
||||||
<>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 10 }}>
|
|
||||||
{t('reservations.import.previewHeading', { count: previewItems.length })}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{previewItems.length === 0 && (
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
|
||||||
{t('reservations.import.previewEmpty')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{previewItems.map((item, idx) => {
|
|
||||||
const Icon = TYPE_ICONS[item.type] ?? Calendar
|
|
||||||
const isExcluded = excluded.has(idx)
|
|
||||||
const fromEp = item.endpoints?.find(e => e.role === 'from')
|
|
||||||
const toEp = item.endpoints?.find(e => e.role === 'to')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${item.source.fileName}-${idx}`}
|
|
||||||
className={isExcluded ? 'bg-surface-tertiary' : 'bg-surface-secondary'}
|
|
||||||
style={{
|
|
||||||
borderRadius: 10, padding: '10px 12px', marginBottom: 8,
|
|
||||||
border: `1px solid ${isExcluded ? 'var(--border-faint)' : 'var(--border-primary)'}`,
|
|
||||||
opacity: isExcluded ? 0.5 : 1, transition: 'opacity 0.15s',
|
|
||||||
display: 'flex', gap: 10, alignItems: 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flexShrink: 0, marginTop: 2 }}>
|
|
||||||
<Icon size={15} color={typeColor(item.type)} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
{fromEp && toEp && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>
|
|
||||||
{fromEp.code ?? fromEp.name} → {toEp.code ?? toEp.name}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.reservation_time && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
|
||||||
{formatDateTime(item.reservation_time)}
|
|
||||||
{item.reservation_end_time && ` – ${formatDateTime(item.reservation_end_time)}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item._accommodation?.check_in && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
|
||||||
{formatDateTime(item._accommodation.check_in)} – {formatDateTime(item._accommodation.check_out)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.confirmation_number && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'monospace' }}>
|
|
||||||
{item.confirmation_number}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleExclude(idx)}
|
|
||||||
className="bg-transparent text-content-faint"
|
|
||||||
style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, flexShrink: 0, fontSize: 11, fontFamily: 'inherit', fontWeight: 500 }}
|
|
||||||
title={t('reservations.import.removeItem')}
|
|
||||||
>
|
|
||||||
{isExcluded ? '+' : <X size={12} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Warnings */}
|
|
||||||
{warnings.length > 0 && (
|
|
||||||
<div className="bg-[rgba(245,158,11,0.08)] text-[#92400e]" style={{ border: '1px solid rgba(245,158,11,0.3)', borderRadius: 10, padding: '8px 10px', fontSize: 12, marginTop: 8, whiteSpace: 'pre-wrap' }}>
|
|
||||||
{warnings.join('\n')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{phase === 'upload' && (
|
|
||||||
<button
|
|
||||||
onClick={handleParse}
|
|
||||||
disabled={files.length === 0 || loading}
|
|
||||||
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
|
||||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
|
||||||
>
|
|
||||||
{loading ? t('reservations.import.parsing') : t('common.import')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(phase === 'preview' || phase === 'confirming') && (
|
|
||||||
<button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={activeCount === 0 || phase === 'confirming'}
|
|
||||||
className={activeCount > 0 && phase !== 'confirming' ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
|
||||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: activeCount > 0 && phase !== 'confirming' ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
|
||||||
>
|
|
||||||
{phase === 'confirming' ? t('common.loading') : t('reservations.import.confirm', { count: activeCount })}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -94,7 +94,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
) : null
|
) : null
|
||||||
|
|
||||||
const placesWithCoords = places.filter(p => p.lat && p.lng)
|
const placesWithCoords = places.filter(p => p.lat && p.lng)
|
||||||
const font = { fontFamily: "var(--font-system)" }
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...(mobile ? { zIndex: 10000 } : null), ...font }}>
|
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...(mobile ? { zIndex: 10000 } : null), ...font }}>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
|
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
|
||||||
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
|
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
|
||||||
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
|
ShoppingBag, Bookmark, Hotel, Utensils, Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
|
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||||
|
|
||||||
export const NOTE_ICONS = [
|
export const NOTE_ICONS = [
|
||||||
{ id: 'FileText', Icon: FileText },
|
{ id: 'FileText', Icon: FileText },
|
||||||
@@ -33,8 +33,7 @@ export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
|
|||||||
|
|
||||||
export const TYPE_ICONS = {
|
export const TYPE_ICONS = {
|
||||||
flight: '✈️', hotel: '🏨', restaurant: '🍽️', train: '🚆',
|
flight: '✈️', hotel: '🏨', restaurant: '🍽️', train: '🚆',
|
||||||
car: '🚗', cruise: '🚢', bus: '🚌', ferry: '⛴️', bicycle: '🚲', taxi: '🚕',
|
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
|
||||||
transport_other: '🧭', event: '🎫', other: '📋',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TRANSPORT_DETAIL_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', ferry: '#0d9488', bicycle: '#84cc16', taxi: '#ca8a04', car: '#6b7280', cruise: '#0ea5e9', transport_other: '#6b7280' }
|
export const TRANSPORT_DETAIL_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#f59e0b', car: '#6b7280', cruise: '#0ea5e9' }
|
||||||
|
|||||||
@@ -1068,7 +1068,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
anyGeoPlace,
|
anyGeoPlace,
|
||||||
} = S
|
} = S
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<DayPlanSidebarToolbar
|
<DayPlanSidebarToolbar
|
||||||
tripId={tripId}
|
tripId={tripId}
|
||||||
@@ -1608,7 +1608,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEditDays && (() => {
|
{canEditDays && (() => {
|
||||||
const isTransport = TRANSPORT_TYPES.has(res.type)
|
const isTransport = ['flight','train','car','cruise','bus'].includes(res.type)
|
||||||
const handler = isTransport ? onEditTransport : onEditReservation
|
const handler = isTransport ? onEditTransport : onEditReservation
|
||||||
if (!handler) return null
|
if (!handler) return null
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
|||||||
<div
|
<div
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
className="bg-surface-card"
|
className="bg-surface-card"
|
||||||
style={{ borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)" }}
|
style={{ borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 6 }}>
|
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 6 }}>
|
||||||
{t('places.importFile')}
|
{t('places.importFile')}
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export default function PlaceInspector({
|
|||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
|
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
fontFamily: "var(--font-system)",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-surface-elevated" style={{
|
<div className="bg-surface-elevated" style={{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
|||||||
onDragOver={handleSidebarDragOver}
|
onDragOver={handleSidebarDragOver}
|
||||||
onDragLeave={handleSidebarDragLeave}
|
onDragLeave={handleSidebarDragLeave}
|
||||||
onDrop={handleSidebarDrop}
|
onDrop={handleSidebarDrop}
|
||||||
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "var(--font-system)", position: 'relative' }}
|
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}
|
||||||
>
|
>
|
||||||
{sidebarDragOver && <PlacesDropOverlay {...S} />}
|
{sidebarDragOver && <PlacesDropOverlay {...S} />}
|
||||||
{/* Kopfbereich */}
|
{/* Kopfbereich */}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { useSettingsStore } from '../../store/settingsStore'
|
|||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import {
|
import {
|
||||||
Plane, Hotel, Utensils, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Ticket, FileText, MapPin,
|
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
|
||||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
||||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle, Download,
|
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
@@ -31,13 +31,8 @@ const TYPE_OPTIONS = [
|
|||||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel, color: '#8b5cf6' },
|
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel, color: '#8b5cf6' },
|
||||||
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils, color: '#ef4444' },
|
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils, color: '#ef4444' },
|
||||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train, color: '#06b6d4' },
|
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train, color: '#06b6d4' },
|
||||||
{ value: 'bus', labelKey: 'reservations.type.bus', Icon: Bus, color: '#059669' },
|
|
||||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car, color: '#6b7280' },
|
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car, color: '#6b7280' },
|
||||||
{ value: 'taxi', labelKey: 'reservations.type.taxi', Icon: CarTaxiFront, color: '#ca8a04' },
|
|
||||||
{ value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike, color: '#84cc16' },
|
|
||||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' },
|
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' },
|
||||||
{ value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat, color: '#0d9488' },
|
|
||||||
{ value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route, color: '#6b7280' },
|
|
||||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' },
|
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' },
|
||||||
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' },
|
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' },
|
||||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText, color: '#6b7280' },
|
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText, color: '#6b7280' },
|
||||||
@@ -109,7 +104,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
const hasCode = !!r.confirmation_number
|
const hasCode = !!r.confirmation_number
|
||||||
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
||||||
|
|
||||||
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
||||||
const isHotel = r.type === 'hotel'
|
const isHotel = r.type === 'hotel'
|
||||||
const startDay = r.day_id ? days.find(d => d.id === r.day_id)
|
const startDay = r.day_id ? days.find(d => d.id === r.day_id)
|
||||||
@@ -468,8 +463,6 @@ interface ReservationsPanelProps {
|
|||||||
assignments: AssignmentsMap
|
assignments: AssignmentsMap
|
||||||
files?: TripFile[]
|
files?: TripFile[]
|
||||||
onAdd: () => void
|
onAdd: () => void
|
||||||
onImport?: () => void
|
|
||||||
bookingImportAvailable?: boolean
|
|
||||||
onEdit: (reservation: Reservation) => void
|
onEdit: (reservation: Reservation) => void
|
||||||
onDelete: (id: number) => void
|
onDelete: (id: number) => void
|
||||||
onNavigateToFiles: () => void
|
onNavigateToFiles: () => void
|
||||||
@@ -477,7 +470,7 @@ interface ReservationsPanelProps {
|
|||||||
addManualKey?: string
|
addManualKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const trip = useTripStore((s) => s.trip)
|
const trip = useTripStore((s) => s.trip)
|
||||||
@@ -519,7 +512,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
|||||||
}, [reservations])
|
}, [reservations])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "var(--font-system)" }}>
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||||
{/* Unified toolbar */}
|
{/* Unified toolbar */}
|
||||||
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
||||||
<div className="bg-surface-tertiary" style={{
|
<div className="bg-surface-tertiary" style={{
|
||||||
@@ -584,35 +577,20 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', flexShrink: 0 }}>
|
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
|
||||||
{onImport && bookingImportAvailable && (
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
<button onClick={onImport} className="bg-surface-card text-content" style={{
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
appearance: 'none', border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
|
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
flexShrink: 0,
|
||||||
padding: '8px 13px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
marginLeft: 'auto',
|
||||||
transition: 'opacity 0.15s ease',
|
transition: 'opacity 0.15s ease',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.75'}
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
title={t('reservations.import.title')}
|
>
|
||||||
>
|
<Plus size={14} strokeWidth={2.5} />
|
||||||
<Download size={14} strokeWidth={2} />
|
<span className="hidden sm:inline">{t(addManualKey)}</span>
|
||||||
<span className="hidden sm:inline">{t('reservations.import.cta')}</span>
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
|
|
||||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
||||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
|
||||||
transition: 'opacity 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
|
||||||
>
|
|
||||||
<Plus size={14} strokeWidth={2.5} />
|
|
||||||
<span className="hidden sm:inline">{t(addManualKey)}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
|
import { Plane, Train, Car, Ship, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
@@ -15,7 +15,7 @@ import { openFile } from '../../utils/fileDownload'
|
|||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
|
||||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||||
|
|
||||||
interface EndpointPick {
|
interface EndpointPick {
|
||||||
@@ -64,15 +64,10 @@ function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||||
{ value: 'bus', labelKey: 'reservations.type.bus', Icon: Bus },
|
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
||||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
||||||
{ value: 'taxi', labelKey: 'reservations.type.taxi', Icon: CarTaxiFront },
|
|
||||||
{ value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike },
|
|
||||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
|
||||||
{ value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat },
|
|
||||||
{ value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const defaultForm = {
|
const defaultForm = {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { authApi, adminApi } from '../../api/client'
|
|||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
import type { UserWithOidc } from '../../types'
|
import type { UserWithOidc } from '../../types'
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
import PasskeysSection from './PasskeysSection'
|
|
||||||
|
|
||||||
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
||||||
|
|
||||||
@@ -396,9 +395,6 @@ export default function AccountTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Passkeys */}
|
|
||||||
<PasskeysSection demoMode={demoMode} />
|
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { Palette, Sun, Moon, Monitor, ChevronDown, Check } from 'lucide-react'
|
|||||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
|
||||||
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
|
|
||||||
export default function DisplaySettingsTab(): React.ReactElement {
|
export default function DisplaySettingsTab(): React.ReactElement {
|
||||||
@@ -30,21 +28,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t('settings.display')} icon={Palette}>
|
<Section title={t('settings.display')} icon={Palette}>
|
||||||
{/* Display currency */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.currency')}</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={settings.default_currency || 'EUR'}
|
|
||||||
onChange={async v => {
|
|
||||||
try { await updateSetting('default_currency', String(v)) }
|
|
||||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
|
||||||
}}
|
|
||||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} — ${SYMBOLS[c] || c}` }))}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-content-faint mt-2">{t('settings.currencyHint')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Color Mode */}
|
{/* Color Mode */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.colorMode')}</label>
|
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.colorMode')}</label>
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { Fingerprint, Plus, Trash2, Pencil, Check, X } from 'lucide-react'
|
|
||||||
import { startRegistration } from '@simplewebauthn/browser'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import { useToast } from '../shared/Toast'
|
|
||||||
import { authApi, type PasskeyCredential } from '../../api/client'
|
|
||||||
import { getApiErrorMessage } from '../../types'
|
|
||||||
|
|
||||||
/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */
|
|
||||||
function fmtDate(ts: string | null): string | null {
|
|
||||||
if (!ts) return null
|
|
||||||
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T')
|
|
||||||
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z')
|
|
||||||
return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the browser cancellation / no-matching-credential DOMExceptions fire. */
|
|
||||||
function isWebauthnAbort(err: unknown): boolean {
|
|
||||||
const name = (err as { name?: string })?.name
|
|
||||||
return name === 'NotAllowedError' || name === 'AbortError'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passkey enrolment + management. Mirrors the MFA block: list / add (with a
|
|
||||||
* password step-up + the WebAuthn ceremony) / rename / delete (password step-up).
|
|
||||||
* The "Add a passkey" action only appears when the instance toggle is on AND a
|
|
||||||
* usable RP ID resolves; the existing-credential list stays reachable even when
|
|
||||||
* the feature is later disabled so users can always clean up.
|
|
||||||
*/
|
|
||||||
export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): React.ReactElement | null {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const [enabled, setEnabled] = useState(false)
|
|
||||||
const [configured, setConfigured] = useState(false)
|
|
||||||
const [creds, setCreds] = useState<PasskeyCredential[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
|
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
|
||||||
const [addPwd, setAddPwd] = useState('')
|
|
||||||
const [addName, setAddName] = useState('')
|
|
||||||
|
|
||||||
const [renamingId, setRenamingId] = useState<number | null>(null)
|
|
||||||
const [renameVal, setRenameVal] = useState('')
|
|
||||||
|
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
|
||||||
const [deletePwd, setDeletePwd] = useState('')
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
authApi.passkey.list()
|
|
||||||
.then(r => setCreds(r.credentials))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
authApi.getAppConfig?.()
|
|
||||||
.then(c => { setEnabled(!!c?.passkey_login); setConfigured(!!c?.passkey_configured) })
|
|
||||||
.catch(() => {})
|
|
||||||
refresh()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const canAdd = enabled && configured
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
if (!addPwd) { toast.error(t('settings.passkey.passwordRequired')); return }
|
|
||||||
setBusy(true)
|
|
||||||
try {
|
|
||||||
const options = await authApi.passkey.registerOptions(addPwd)
|
|
||||||
const attResp = await startRegistration({ optionsJSON: options })
|
|
||||||
await authApi.passkey.registerVerify(attResp, addName.trim() || undefined)
|
|
||||||
toast.success(t('settings.passkey.addedToast'))
|
|
||||||
setAddOpen(false); setAddPwd(''); setAddName('')
|
|
||||||
refresh()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (isWebauthnAbort(err)) toast.error(t('settings.passkey.cancelled'))
|
|
||||||
else toast.error(getApiErrorMessage(err, t('settings.passkey.addError')))
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRename = async (id: number) => {
|
|
||||||
const name = renameVal.trim()
|
|
||||||
if (!name) { setRenamingId(null); return }
|
|
||||||
try {
|
|
||||||
await authApi.passkey.rename(id, name)
|
|
||||||
setRenamingId(null)
|
|
||||||
refresh()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
if (!deletePwd) { toast.error(t('settings.passkey.passwordRequired')); return }
|
|
||||||
setBusy(true)
|
|
||||||
try {
|
|
||||||
await authApi.passkey.delete(id, deletePwd)
|
|
||||||
toast.success(t('settings.passkey.deleted'))
|
|
||||||
setDeletingId(null); setDeletePwd('')
|
|
||||||
refresh()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (demoMode) return null
|
|
||||||
// Nothing to show: feature off and the user has no credentials to manage.
|
|
||||||
if (!loading && !enabled && creds.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pt-4 mt-4 border-t border-edge-secondary">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Fingerprint className="w-5 h-5 text-content-secondary" />
|
|
||||||
<h3 className="font-semibold text-base m-0 text-content">{t('settings.passkey.title')}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm m-0 text-content-muted" style={{ lineHeight: 1.5 }}>{t('settings.passkey.description')}</p>
|
|
||||||
|
|
||||||
{enabled && !configured && (
|
|
||||||
<p className="text-sm m-0 text-amber-700">{t('settings.passkey.notConfigured')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{creds.length > 0 && (
|
|
||||||
<ul className="space-y-2 list-none p-0 m-0">
|
|
||||||
{creds.map(c => (
|
|
||||||
<li key={c.id} className="flex items-center gap-3 p-3 rounded-lg border border-edge bg-surface-card">
|
|
||||||
<Fingerprint className="w-4 h-4 flex-shrink-0 text-content-secondary" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{renamingId === c.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
type="text"
|
|
||||||
value={renameVal}
|
|
||||||
onChange={e => setRenameVal(e.target.value)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleRename(c.id); if (e.key === 'Escape') setRenamingId(null) }}
|
|
||||||
className="flex-1 px-2 py-1 border border-slate-300 rounded text-sm"
|
|
||||||
/>
|
|
||||||
<button type="button" onClick={() => handleRename(c.id)} className="p-1 text-emerald-600" aria-label={t('common.save')}><Check size={16} /></button>
|
|
||||||
<button type="button" onClick={() => setRenamingId(null)} className="p-1 text-content-muted" aria-label={t('common.cancel')}><X size={16} /></button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-content truncate">{c.name || t('settings.passkey.defaultName')}</span>
|
|
||||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-surface-hover text-content-secondary">
|
|
||||||
{c.backed_up ? t('settings.passkey.synced') : t('settings.passkey.deviceBound')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs m-0 mt-0.5 text-content-faint">
|
|
||||||
{t('settings.passkey.added')}: {fmtDate(c.created_at) || '—'}
|
|
||||||
{' · '}
|
|
||||||
{c.last_used_at
|
|
||||||
? `${t('settings.passkey.lastUsed')}: ${fmtDate(c.last_used_at)}`
|
|
||||||
: t('settings.passkey.neverUsed')}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{renamingId !== c.id && (
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setRenamingId(c.id); setRenameVal(c.name || '') }}
|
|
||||||
className="p-1.5 rounded text-content-muted hover:text-content"
|
|
||||||
aria-label={t('settings.passkey.rename')}
|
|
||||||
>
|
|
||||||
<Pencil size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setDeletingId(c.id); setDeletePwd('') }}
|
|
||||||
className="p-1.5 rounded text-red-500 hover:bg-red-50"
|
|
||||||
aria-label={t('common.delete')}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete confirmation (password step-up) */}
|
|
||||||
{deletingId !== null && (
|
|
||||||
<div className="space-y-2 p-3 rounded-lg border border-red-200 bg-red-50/40">
|
|
||||||
<p className="text-sm font-medium m-0 text-content">{t('settings.passkey.deleteConfirm')}</p>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={deletePwd}
|
|
||||||
onChange={e => setDeletePwd(e.target.value)}
|
|
||||||
placeholder={t('settings.currentPassword')}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy || !deletePwd}
|
|
||||||
onClick={() => handleDelete(deletingId)}
|
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('common.delete')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setDeletingId(null); setDeletePwd('') }}
|
|
||||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add a passkey */}
|
|
||||||
{canAdd && (addOpen ? (
|
|
||||||
<div className="space-y-2 p-3 rounded-lg border border-edge bg-surface-hover">
|
|
||||||
<p className="text-sm font-medium m-0 text-content">{t('settings.passkey.addTitle')}</p>
|
|
||||||
<p className="text-xs m-0 text-content-muted">{t('settings.passkey.passwordPrompt')}</p>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={addPwd}
|
|
||||||
onChange={e => setAddPwd(e.target.value)}
|
|
||||||
placeholder={t('settings.currentPassword')}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={addName}
|
|
||||||
onChange={e => setAddName(e.target.value)}
|
|
||||||
placeholder={t('settings.passkey.namePlaceholder')}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy || !addPwd}
|
|
||||||
onClick={handleAdd}
|
|
||||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{busy ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : t('settings.passkey.add')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setAddOpen(false); setAddPwd(''); setAddName('') }}
|
|
||||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAddOpen(true)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors border border-edge bg-surface-card text-content"
|
|
||||||
>
|
|
||||||
<Plus size={14} />
|
|
||||||
{t('settings.passkey.add')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -259,7 +259,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
|
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
||||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||||
|
|
||||||
{/* Left column: Members */}
|
{/* Left column: Members */}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false, stacked
|
|||||||
|
|
||||||
if (!lat || !lng) return null
|
if (!lat || !lng) return null
|
||||||
|
|
||||||
const fontStyle = { fontFamily: "var(--font-system)" }
|
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
|||||||
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
||||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
minWidth: 160,
|
minWidth: 160,
|
||||||
fontFamily: "var(--font-system)",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
transformOrigin: 'top left',
|
transformOrigin: 'top left',
|
||||||
}}>
|
}}>
|
||||||
{menu.items.filter(Boolean).map((item, i) => {
|
{menu.items.filter(Boolean).map((item, i) => {
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function ToastContainer() {
|
|||||||
<span style={{
|
<span style={{
|
||||||
flex: 1, fontSize: 13, fontWeight: 500, color: 'rgba(255, 255, 255, 0.9)',
|
flex: 1, fontSize: 13, fontWeight: 500, color: 'rgba(255, 255, 255, 0.9)',
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
fontFamily: "var(--font-system)",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
}}>
|
}}>
|
||||||
{toast.message}
|
{toast.message}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, ch
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
fontFamily: "var(--font-system)",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
|
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Live FX rates for the Costs panel, used to convert every amount into the user's
|
|
||||||
* display currency. Fetches exchangerate-api.com (no key, already CSP-allowlisted
|
|
||||||
* for the dashboard widget) for the given base and caches per base in memory +
|
|
||||||
* localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in
|
|
||||||
* currency C converts to base as `amount / rates[C]`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TTL_MS = 6 * 60 * 60 * 1000 // 6h
|
|
||||||
const mem = new Map<string, { rates: Record<string, number>; ts: number }>()
|
|
||||||
|
|
||||||
function readCache(base: string): { rates: Record<string, number>; ts: number } | null {
|
|
||||||
const m = mem.get(base)
|
|
||||||
if (m) return m
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem('trek_fx_' + base)
|
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw) as { rates: Record<string, number>; ts: number }
|
|
||||||
if (parsed?.rates) { mem.set(base, parsed); return parsed }
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useExchangeRates(base: string) {
|
|
||||||
const upper = (base || 'EUR').toUpperCase()
|
|
||||||
const [rates, setRates] = useState<Record<string, number> | null>(() => readCache(upper)?.rates ?? null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cached = readCache(upper)
|
|
||||||
if (cached) setRates(cached.rates)
|
|
||||||
if (cached && Date.now() - cached.ts < TTL_MS) return
|
|
||||||
let cancelled = false
|
|
||||||
fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(upper)}`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then((d: { rates?: Record<string, number> }) => {
|
|
||||||
if (cancelled || !d?.rates) return
|
|
||||||
const entry = { rates: d.rates, ts: Date.now() }
|
|
||||||
mem.set(upper, entry)
|
|
||||||
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
|
|
||||||
setRates(d.rates)
|
|
||||||
})
|
|
||||||
.catch(() => { /* offline → keep cached/identity */ })
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [upper])
|
|
||||||
|
|
||||||
const convert = useCallback(
|
|
||||||
(amount: number, from: string | null | undefined): number => {
|
|
||||||
const f = (from || upper).toUpperCase()
|
|
||||||
if (f === upper || !rates) return amount
|
|
||||||
const r = rates[f]
|
|
||||||
return r && r > 0 ? amount / r : amount
|
|
||||||
},
|
|
||||||
[rates, upper],
|
|
||||||
)
|
|
||||||
|
|
||||||
return { rates, convert }
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
|||||||
import type { TripStoreState } from '../store/tripStore'
|
import type { TripStoreState } from '../store/tripStore'
|
||||||
import type { RouteSegment, RouteResult } from '../types'
|
import type { RouteSegment, RouteResult } from '../types'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
|
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||||
|
|||||||
@@ -431,9 +431,7 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
|||||||
--safe-top: env(safe-area-inset-top, 0px);
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
--nav-h: 0px;
|
--nav-h: 0px;
|
||||||
--bottom-nav-h: 0px;
|
--bottom-nav-h: 0px;
|
||||||
--font-system: 'Poppins', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
--font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
||||||
/* Secondary "subtext"/caption tier renders in Geist; primary text + headings stay Poppins. */
|
|
||||||
--font-subtext: 'Geist Sans', 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
||||||
--sp-1: 4px;
|
--sp-1: 4px;
|
||||||
--sp-2: 8px;
|
--sp-2: 8px;
|
||||||
--sp-3: 12px;
|
--sp-3: 12px;
|
||||||
@@ -541,11 +539,6 @@ body {
|
|||||||
transition: background-color 0.2s, color 0.2s;
|
transition: background-color 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subtext tier in Geist. The faint text token is TREK's caption/secondary tier;
|
|
||||||
a direct rule on the element beats the Poppins inherited from wrapper styles,
|
|
||||||
giving the design's "Geist text · Poppins numbers" hierarchy. */
|
|
||||||
.text-content-faint { font-family: var(--font-subtext); }
|
|
||||||
|
|
||||||
/* ── Marker cluster custom styling ────────────── */
|
/* ── Marker cluster custom styling ────────────── */
|
||||||
.marker-cluster-wrapper {
|
.marker-cluster-wrapper {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
@@ -570,7 +563,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.marker-cluster-custom span {
|
.marker-cluster-custom span {
|
||||||
font-family:var(--font-system);
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
|||||||
@@ -2,17 +2,6 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
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'
|
||||||
// Self-hosted Poppins (bundled, same-origin) so the app font can't be blocked by
|
|
||||||
// ad/tracker blockers the way the Google Fonts CDN can.
|
|
||||||
import '@fontsource/poppins/300.css'
|
|
||||||
import '@fontsource/poppins/400.css'
|
|
||||||
import '@fontsource/poppins/500.css'
|
|
||||||
import '@fontsource/poppins/600.css'
|
|
||||||
import '@fontsource/poppins/700.css'
|
|
||||||
// Geist Sans (self-hosted too) — used only for secondary "subtext" via --font-subtext.
|
|
||||||
import '@fontsource/geist-sans/400.css'
|
|
||||||
import '@fontsource/geist-sans/500.css'
|
|
||||||
import '@fontsource/geist-sans/600.css'
|
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { startConnectivityProbe } from './sync/connectivity'
|
import { startConnectivityProbe } from './sync/connectivity'
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Navbar lives outside .trek-dash so it keeps the app-wide font + button
|
{/* Navbar lives outside .trek-dash so it keeps the app-wide font + button
|
||||||
styling instead of inheriting the dashboard scope's font and the
|
styling instead of inheriting the dashboard scope's Geist font and the
|
||||||
`.trek-dash button` reset (which shifted the bell icon + menu items). */}
|
`.trek-dash button` reset (which shifted the bell icon + menu items). */}
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="trek-dash trek-dash-shell">
|
<div className="trek-dash trek-dash-shell">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown, Fingerprint } from 'lucide-react'
|
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown } from 'lucide-react'
|
||||||
import { useLogin } from './login/useLogin'
|
import { useLogin } from './login/useLogin'
|
||||||
|
|
||||||
export default function LoginPage(): React.ReactElement {
|
export default function LoginPage(): React.ReactElement {
|
||||||
@@ -15,13 +15,9 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||||
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
|
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
|
||||||
noRedirect, showRegisterOption, oidcOnly,
|
noRedirect, showRegisterOption, oidcOnly,
|
||||||
handleDemoLogin, handleSubmit, handlePasskeyLogin,
|
handleDemoLogin, handleSubmit,
|
||||||
} = useLogin()
|
} = useLogin()
|
||||||
|
|
||||||
const oidcButtonShown = !!(appConfig?.oidc_configured && appConfig?.oidc_login && !oidcOnly)
|
|
||||||
const passkeyAvailable = !!(appConfig?.passkey_login && appConfig?.passkey_configured && !oidcOnly
|
|
||||||
&& mode === 'login' && !mfaStep && !passwordChangeStep)
|
|
||||||
|
|
||||||
const inputBase: React.CSSProperties = {
|
const inputBase: React.CSSProperties = {
|
||||||
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
|
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
|
||||||
borderRadius: 12, fontSize: 14, fontFamily: 'inherit', outline: 'none',
|
borderRadius: 12, fontSize: 14, fontFamily: 'inherit', outline: 'none',
|
||||||
@@ -180,7 +176,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "var(--font-system)", position: 'relative' }}>
|
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
||||||
|
|
||||||
{/* Language dropdown */}
|
{/* Language dropdown */}
|
||||||
<div style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }}>
|
<div style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }}>
|
||||||
@@ -640,36 +636,6 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Passkey login button (instance toggle on + a usable RP ID resolves) */}
|
|
||||||
{passkeyAvailable && (
|
|
||||||
<>
|
|
||||||
{!oidcButtonShown && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
|
|
||||||
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
|
||||||
<span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span>
|
|
||||||
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button type="button" onClick={handlePasskeyLogin} disabled={isLoading}
|
|
||||||
style={{
|
|
||||||
marginTop: 12, width: '100%', padding: '12px',
|
|
||||||
background: 'white', color: '#374151',
|
|
||||||
border: '1px solid #d1d5db', borderRadius: 12,
|
|
||||||
fontSize: 14, fontWeight: 600, cursor: isLoading ? 'default' : 'pointer',
|
|
||||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
|
||||||
opacity: isLoading ? 0.7 : 1,
|
|
||||||
transition: 'background 180ms cubic-bezier(0.23,1,0.32,1), border-color 180ms cubic-bezier(0.23,1,0.32,1)',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' } }}
|
|
||||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
|
|
||||||
>
|
|
||||||
<Fingerprint size={16} />
|
|
||||||
{t('login.passkey.signIn')}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Demo login button */}
|
{/* Demo login button */}
|
||||||
{appConfig?.demo_mode && (
|
{appConfig?.demo_mode && (
|
||||||
<button onClick={handleDemoLogin} disabled={isLoading}
|
<button onClick={handleDemoLogin} disabled={isLoading}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function SharedTripPage() {
|
|||||||
const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35]
|
const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-secondary" style={{ minHeight: '100vh', fontFamily: "var(--font-system)" }}>
|
<div className="bg-surface-secondary" style={{ minHeight: '100vh', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', textAlign: 'center', position: 'relative' }}>
|
<div className="text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', textAlign: 'center', position: 'relative' }}>
|
||||||
{/* Cover image background */}
|
{/* Cover image background */}
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ vi.mock('../components/Files/FileManager', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/Budget/CostsPanel', () => ({
|
vi.mock('../components/Budget/BudgetPanel', () => ({
|
||||||
default: () => React.createElement('div', { 'data-testid': 'costs-panel' }),
|
default: () => React.createElement('div', { 'data-testid': 'budget-panel' }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/Packing/PackingListPanel', () => ({
|
vi.mock('../components/Packing/PackingListPanel', () => ({
|
||||||
@@ -436,8 +436,8 @@ describe('TripPlannerPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('FE-PAGE-PLANNER-012: Costs tab renders CostsPanel', () => {
|
describe('FE-PAGE-PLANNER-012: Budget tab renders BudgetPanel', () => {
|
||||||
it('shows CostsPanel after clicking the Costs tab with budget addon enabled', async () => {
|
it('shows BudgetPanel after clicking the Budget tab with budget addon enabled', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () =>
|
http.get('/api/addons', () =>
|
||||||
HttpResponse.json({ addons: [{ id: 'budget', type: 'budget' }] })
|
HttpResponse.json({ addons: [{ id: 'budget', type: 'budget' }] })
|
||||||
@@ -454,11 +454,11 @@ describe('TripPlannerPage', () => {
|
|||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
||||||
const costsTab = await screen.findByTitle('Costs');
|
const budgetTab = await screen.findByTitle('Budget');
|
||||||
fireEvent.click(costsTab);
|
fireEvent.click(budgetTab);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('costs-panel')).toBeInTheDocument();
|
expect(screen.getByTestId('budget-panel')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,14 +16,13 @@ import SlidingTabs from '../components/shared/SlidingTabs'
|
|||||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||||
import { TransportModal } from '../components/Planner/TransportModal'
|
import { TransportModal } from '../components/Planner/TransportModal'
|
||||||
import BookingImportModal from '../components/Planner/BookingImportModal'
|
|
||||||
// MemoriesPanel moved to Journey addon
|
// MemoriesPanel moved to Journey addon
|
||||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||||
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
||||||
import TodoListPanel from '../components/Todo/TodoListPanel'
|
import TodoListPanel from '../components/Todo/TodoListPanel'
|
||||||
import FileManager from '../components/Files/FileManager'
|
import FileManager from '../components/Files/FileManager'
|
||||||
import CostsPanel from '../components/Budget/CostsPanel'
|
import BudgetPanel from '../components/Budget/BudgetPanel'
|
||||||
import CollabPanel from '../components/Collab/CollabPanel'
|
import CollabPanel from '../components/Collab/CollabPanel'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
@@ -183,7 +182,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
|
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
|
||||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||||
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
|
||||||
bookingForAssignmentId, setBookingForAssignmentId,
|
bookingForAssignmentId, setBookingForAssignmentId,
|
||||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||||
transportModalDayId, setTransportModalDayId,
|
transportModalDayId, setTransportModalDayId,
|
||||||
@@ -630,8 +628,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
files={files}
|
files={files}
|
||||||
onAdd={() => { setEditingReservation(null); setShowReservationModal(true) }}
|
onAdd={() => { setEditingReservation(null); setShowReservationModal(true) }}
|
||||||
onImport={() => setShowBookingImport(true)}
|
|
||||||
bookingImportAvailable={bookingImportAvailable}
|
|
||||||
onEdit={(r) => { setEditingReservation(r); setShowReservationModal(true) }}
|
onEdit={(r) => { setEditingReservation(r); setShowReservationModal(true) }}
|
||||||
onDelete={handleDeleteReservation}
|
onDelete={handleDeleteReservation}
|
||||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||||
@@ -647,7 +643,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
|
|
||||||
{activeTab === 'finanzplan' && (
|
{activeTab === 'finanzplan' && (
|
||||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', width: '100%', paddingBottom: 'var(--bottom-nav-h)' }}>
|
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', width: '100%', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||||
<CostsPanel tripId={tripId} tripMembers={tripMembers} />
|
<BudgetPanel tripId={tripId} tripMembers={tripMembers} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -680,7 +676,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
||||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
||||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={!!deletePlaceId}
|
isOpen={!!deletePlaceId}
|
||||||
onClose={() => setDeletePlaceId(null)}
|
onClose={() => setDeletePlaceId(null)}
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
|||||||
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
||||||
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
||||||
envOverrideOidcOnly, oidcConfigured, requireMfa,
|
envOverrideOidcOnly, oidcConfigured, requireMfa,
|
||||||
passkeyLogin, setPasskeyLogin, passkeyConfigured,
|
|
||||||
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
|
|
||||||
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
||||||
mapsKey, setMapsKey, showKeys, savingKeys, validating, validation,
|
mapsKey, setMapsKey, showKeys, savingKeys, validating, validation,
|
||||||
setShowRotateJwtModal,
|
setShowRotateJwtModal,
|
||||||
@@ -121,71 +119,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Passkey (WebAuthn) login */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
|
||||||
<div className="px-6 py-4 border-b border-slate-100">
|
|
||||||
<h2 className="font-semibold text-slate-900">{t('admin.passkey.title')}</h2>
|
|
||||||
<p className="text-xs text-slate-400 mt-1">{t('admin.passkey.cardHint')}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-slate-700">{t('admin.passkey.login')}</p>
|
|
||||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.passkey.loginHint')}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggleAuthSetting('passkey_login', !passkeyLogin, setPasskeyLogin)}
|
|
||||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${passkeyLogin ? 'bg-content' : 'bg-edge'}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
|
||||||
style={{ transform: passkeyLogin ? 'translateX(20px)' : 'translateX(0)' }}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{passkeyLogin && !passkeyConfigured && (
|
|
||||||
<p className="flex items-start gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
|
||||||
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
|
|
||||||
{t('admin.passkey.notConfigured')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">{t('admin.passkey.rpId')}</label>
|
|
||||||
<p className="text-xs text-slate-400 mb-1.5">{t('admin.passkey.rpIdHint')}</p>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={webauthnRpId}
|
|
||||||
onChange={e => setWebauthnRpId(e.target.value)}
|
|
||||||
placeholder="trek.example.org"
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">{t('admin.passkey.origins')}</label>
|
|
||||||
<p className="text-xs text-slate-400 mb-1.5">{t('admin.passkey.originsHint')}</p>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={webauthnOrigins}
|
|
||||||
onChange={e => setWebauthnOrigins(e.target.value)}
|
|
||||||
placeholder="https://trek.example.org"
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSaveWebauthn}
|
|
||||||
disabled={savingWebauthn}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{savingWebauthn ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
|
||||||
{t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Require 2FA for all users */}
|
{/* Require 2FA for all users */}
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-slate-100">
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
import { adminApi } from '../../api/client'
|
import { adminApi } from '../../api/client'
|
||||||
import Modal from '../../components/shared/Modal'
|
import Modal from '../../components/shared/Modal'
|
||||||
import CustomSelect from '../../components/shared/CustomSelect'
|
import CustomSelect from '../../components/shared/CustomSelect'
|
||||||
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint } from 'lucide-react'
|
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||||
import type { TranslationFn } from '../../types'
|
import type { TranslationFn } from '../../types'
|
||||||
import type { useAdmin } from './useAdmin'
|
import type { useAdmin } from './useAdmin'
|
||||||
|
|
||||||
@@ -157,25 +157,6 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-3 border-t border-slate-100">
|
|
||||||
<p className="text-xs text-slate-400 mb-2">{t('admin.passkey.resetHint')}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
if (!editingUser) return
|
|
||||||
if (!confirm(t('admin.passkey.resetConfirm', { name: editingUser.username }))) return
|
|
||||||
try {
|
|
||||||
const r = await adminApi.resetUserPasskeys(editingUser.id)
|
|
||||||
toast.success(t('admin.passkey.resetDone', { count: r.deleted ?? 0 }))
|
|
||||||
} catch {
|
|
||||||
toast.error(t('common.error'))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Fingerprint size={14} /> {t('admin.passkey.reset')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -65,13 +65,6 @@ export function useAdmin() {
|
|||||||
const [oidcConfigured, setOidcConfigured] = useState<boolean>(false)
|
const [oidcConfigured, setOidcConfigured] = useState<boolean>(false)
|
||||||
const [requireMfa, setRequireMfa] = useState<boolean>(false)
|
const [requireMfa, setRequireMfa] = useState<boolean>(false)
|
||||||
|
|
||||||
// Passkey (WebAuthn) login
|
|
||||||
const [passkeyLogin, setPasskeyLogin] = useState<boolean>(false)
|
|
||||||
const [passkeyConfigured, setPasskeyConfigured] = useState<boolean>(false)
|
|
||||||
const [webauthnRpId, setWebauthnRpId] = useState<string>('')
|
|
||||||
const [webauthnOrigins, setWebauthnOrigins] = useState<string>('')
|
|
||||||
const [savingWebauthn, setSavingWebauthn] = useState<boolean>(false)
|
|
||||||
|
|
||||||
// Invite links
|
// Invite links
|
||||||
const [invites, setInvites] = useState<any[]>([])
|
const [invites, setInvites] = useState<any[]>([])
|
||||||
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(false)
|
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(false)
|
||||||
@@ -87,8 +80,6 @@ export function useAdmin() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.get('/auth/app-settings').then(r => {
|
apiClient.get('/auth/app-settings').then(r => {
|
||||||
setSmtpValues(r.data || {})
|
setSmtpValues(r.data || {})
|
||||||
if (r.data?.webauthn_rp_id) setWebauthnRpId(r.data.webauthn_rp_id)
|
|
||||||
if (r.data?.webauthn_origins) setWebauthnOrigins(r.data.webauthn_origins)
|
|
||||||
setSmtpLoaded(true)
|
setSmtpLoaded(true)
|
||||||
}).catch(() => setSmtpLoaded(true))
|
}).catch(() => setSmtpLoaded(true))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -150,8 +141,6 @@ export function useAdmin() {
|
|||||||
setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false)
|
setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false)
|
||||||
setOidcConfigured(config.oidc_configured ?? false)
|
setOidcConfigured(config.oidc_configured ?? false)
|
||||||
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
|
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
|
||||||
setPasskeyLogin(!!config.passkey_login)
|
|
||||||
setPasskeyConfigured(!!config.passkey_configured)
|
|
||||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -190,23 +179,6 @@ export function useAdmin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveWebauthn = async () => {
|
|
||||||
setSavingWebauthn(true)
|
|
||||||
try {
|
|
||||||
await authApi.updateAppSettings({
|
|
||||||
webauthn_rp_id: webauthnRpId.trim(),
|
|
||||||
webauthn_origins: webauthnOrigins.trim(),
|
|
||||||
})
|
|
||||||
// Re-read app-config so passkey_configured reflects the new RP ID.
|
|
||||||
await loadAppConfig()
|
|
||||||
toast.success(t('common.saved'))
|
|
||||||
} catch (err: unknown) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
||||||
} finally {
|
|
||||||
setSavingWebauthn(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleKey = (key) => {
|
const toggleKey = (key) => {
|
||||||
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
|
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
|
||||||
}
|
}
|
||||||
@@ -369,8 +341,6 @@ export function useAdmin() {
|
|||||||
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
||||||
envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured,
|
envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured,
|
||||||
requireMfa, setRequireMfa,
|
requireMfa, setRequireMfa,
|
||||||
passkeyLogin, setPasskeyLogin, passkeyConfigured,
|
|
||||||
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
|
|
||||||
invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
||||||
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
||||||
smtpValues, setSmtpValues, smtpLoaded,
|
smtpValues, setSmtpValues, smtpLoaded,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useNavigate, useLocation } from 'react-router-dom'
|
|||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useSettingsStore, hasStoredLanguage } from '../../store/settingsStore'
|
import { useSettingsStore, hasStoredLanguage } from '../../store/settingsStore'
|
||||||
import { useTranslation, detectBrowserLanguage } from '../../i18n'
|
import { useTranslation, detectBrowserLanguage } from '../../i18n'
|
||||||
import { startAuthentication } from '@simplewebauthn/browser'
|
|
||||||
import { authApi, configApi } from '../../api/client'
|
import { authApi, configApi } from '../../api/client'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
|
|
||||||
@@ -19,8 +18,6 @@ interface AppConfig {
|
|||||||
password_registration: boolean
|
password_registration: boolean
|
||||||
oidc_login: boolean
|
oidc_login: boolean
|
||||||
oidc_registration: boolean
|
oidc_registration: boolean
|
||||||
passkey_login?: boolean
|
|
||||||
passkey_configured?: boolean
|
|
||||||
env_override_oidc_only: boolean
|
env_override_oidc_only: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,28 +196,6 @@ export function useLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePasskeyLogin = async (): Promise<void> => {
|
|
||||||
setError('')
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
const options = await authApi.passkey.loginOptions()
|
|
||||||
const assertion = await startAuthentication({ optionsJSON: options })
|
|
||||||
await authApi.passkey.loginVerify(assertion)
|
|
||||||
await loadUser({ silent: true })
|
|
||||||
setShowTakeoff(true)
|
|
||||||
setTimeout(() => navigate(redirectTarget), 2600)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
// The user dismissing the native prompt isn't an error worth surfacing.
|
|
||||||
const name = (err as { name?: string })?.name
|
|
||||||
if (name === 'NotAllowedError' || name === 'AbortError') {
|
|
||||||
setIsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setError(getApiErrorMessage(err, t('login.passkey.failed')))
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -295,6 +270,6 @@ export function useLogin() {
|
|||||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||||
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
|
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
|
||||||
noRedirect, showRegisterOption, oidcOnly,
|
noRedirect, showRegisterOption, oidcOnly,
|
||||||
handleDemoLogin, handleSubmit, handlePasskeyLogin,
|
handleDemoLogin, handleSubmit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getCached, fetchPhoto } from '../../services/photoService'
|
|||||||
import { useToast } from '../../components/shared/Toast'
|
import { useToast } from '../../components/shared/Toast'
|
||||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi } from '../../api/client'
|
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../../api/client'
|
||||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||||
import { offlineDb } from '../../db/offlineDb'
|
import { offlineDb } from '../../db/offlineDb'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
@@ -86,7 +86,7 @@ export function useTripPlanner() {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise', 'bus'])
|
||||||
|
|
||||||
const TRIP_TABS = [
|
const TRIP_TABS = [
|
||||||
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
|
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
|
||||||
@@ -138,8 +138,6 @@ export function useTripPlanner() {
|
|||||||
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
|
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
|
||||||
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
|
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
|
||||||
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
||||||
const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
|
|
||||||
const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false)
|
|
||||||
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
|
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
|
||||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||||
@@ -165,10 +163,6 @@ export function useTripPlanner() {
|
|||||||
setFitKey(k => k + 1)
|
setFitKey(k => k + 1)
|
||||||
}, [trip, places])
|
}, [trip, places])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
healthApi.features().then(f => setBookingImportAvailable(f.bookingImport)).catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null
|
const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null
|
||||||
const [visibleConnections, setVisibleConnections] = useState<number[]>(() => {
|
const [visibleConnections, setVisibleConnections] = useState<number[]>(() => {
|
||||||
if (typeof window === 'undefined' || !connectionsStorageKey) return []
|
if (typeof window === 'undefined' || !connectionsStorageKey) return []
|
||||||
@@ -604,7 +598,7 @@ export function useTripPlanner() {
|
|||||||
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
|
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
|
||||||
const defaultZoom = settings.default_zoom || 10
|
const defaultZoom = settings.default_zoom || 10
|
||||||
|
|
||||||
const fontStyle = { fontFamily: "var(--font-system)" }
|
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }
|
||||||
|
|
||||||
// Splash screen — show for initial load + a brief moment for photos to start loading
|
// Splash screen — show for initial load + a brief moment for photos to start loading
|
||||||
const [splashDone, setSplashDone] = useState(false)
|
const [splashDone, setSplashDone] = useState(false)
|
||||||
@@ -630,7 +624,6 @@ export function useTripPlanner() {
|
|||||||
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
|
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
|
||||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||||
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
|
||||||
bookingForAssignmentId, setBookingForAssignmentId,
|
bookingForAssignmentId, setBookingForAssignmentId,
|
||||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||||
transportModalDayId, setTransportModalDayId,
|
transportModalDayId, setTransportModalDayId,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: "Poppins", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
font-family: "Geist", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
font-feature-settings: "ss01", "cv11";
|
font-feature-settings: "ss01", "cv11";
|
||||||
letter-spacing: -0.005em;
|
letter-spacing: -0.005em;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
|
|
||||||
export interface MergedItem {
|
export interface MergedItem {
|
||||||
type: 'place' | 'note' | 'transport'
|
type: 'place' | 'note' | 'transport'
|
||||||
|
|||||||
@@ -39,58 +39,6 @@ export function currencyDecimals(currency: string): number {
|
|||||||
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
|
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each currency formats in its own home convention (symbol position, grouping and
|
|
||||||
// decimal separators) regardless of the app language — so EUR is always "1.234,56 €"
|
|
||||||
// and USD always "$1,234.56". Intl derives all of that from the locale, so we map
|
|
||||||
// each supported currency to a representative locale (Latin-digit variants for the
|
|
||||||
// Arabic/Bengali ones to avoid non-Latin numerals).
|
|
||||||
const CURRENCY_LOCALE: Record<string, string> = {
|
|
||||||
EUR: 'de-DE', USD: 'en-US', GBP: 'en-GB', JPY: 'ja-JP', CHF: 'de-CH',
|
|
||||||
CZK: 'cs-CZ', PLN: 'pl-PL', SEK: 'sv-SE', NOK: 'nb-NO', DKK: 'da-DK',
|
|
||||||
TRY: 'tr-TR', THB: 'th-TH', AUD: 'en-AU', CAD: 'en-CA', NZD: 'en-NZ',
|
|
||||||
BRL: 'pt-BR', MXN: 'es-MX', INR: 'en-IN', IDR: 'id-ID', MYR: 'ms-MY',
|
|
||||||
PHP: 'en-PH', SGD: 'en-SG', KRW: 'ko-KR', CNY: 'zh-CN', HKD: 'en-HK',
|
|
||||||
TWD: 'zh-TW', ZAR: 'en-ZA', AED: 'en-AE', SAR: 'en-SA', ILS: 'he-IL',
|
|
||||||
EGP: 'en-EG', MAD: 'fr-MA', HUF: 'hu-HU', RON: 'ro-RO', BGN: 'bg-BG',
|
|
||||||
HRK: 'hr-HR', ISK: 'is-IS', RUB: 'ru-RU', UAH: 'uk-UA', BDT: 'en-BD',
|
|
||||||
LKR: 'en-LK', VND: 'vi-VN', CLP: 'es-CL', COP: 'es-CO', PEN: 'es-PE',
|
|
||||||
ARS: 'es-AR',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function currencyLocale(currency: string): string {
|
|
||||||
return CURRENCY_LOCALE[(currency || '').toUpperCase()] || 'en-US'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locale- and currency-correct money formatting via Intl: the symbol position,
|
|
||||||
* thousands/decimal separators and decimal count all follow the user's locale
|
|
||||||
* and the currency itself (e.g. de-DE EUR → "1.234,56 €", en-US USD → "$1,234.56",
|
|
||||||
* ja-JP JPY → "¥1,235"). Falls back to a "<number> CODE" suffix for unknown codes.
|
|
||||||
*/
|
|
||||||
export function formatMoney(
|
|
||||||
value: number,
|
|
||||||
currency: string,
|
|
||||||
locale: string,
|
|
||||||
opts?: { decimals?: number },
|
|
||||||
): string {
|
|
||||||
const cur = (currency || 'EUR').toUpperCase()
|
|
||||||
const decimals = opts?.decimals ?? currencyDecimals(cur)
|
|
||||||
// Format in the currency's home convention, not the app language, so the symbol
|
|
||||||
// position and separators are always correct for that currency. `locale` stays
|
|
||||||
// as a last-resort fallback for the error path.
|
|
||||||
const fmtLocale = currencyLocale(cur)
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat(fmtLocale, {
|
|
||||||
style: 'currency',
|
|
||||||
currency: cur,
|
|
||||||
minimumFractionDigits: decimals,
|
|
||||||
maximumFractionDigits: decimals,
|
|
||||||
}).format(value || 0)
|
|
||||||
} catch {
|
|
||||||
return `${(value || 0).toLocaleString(locale || fmtLocale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })} ${cur}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
|
export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
|
||||||
if (!dateStr) return null
|
if (!dateStr) return null
|
||||||
const opts: Intl.DateTimeFormatOptions = {
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export default defineConfig({
|
|||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
scope: '/',
|
scope: '/',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
|
orientation: 'any',
|
||||||
categories: ['travel', 'navigation'],
|
categories: ['travel', 'navigation'],
|
||||||
icons: [
|
icons: [
|
||||||
{ src: 'icons/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png' },
|
{ src: 'icons/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png' },
|
||||||
|
|||||||
+2
-4
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: mauriceboe/trek:dev
|
image: mauriceboe/trek:latest
|
||||||
container_name: trek
|
container_name: trek
|
||||||
read_only: true
|
read_only: true
|
||||||
security_opt:
|
security_opt:
|
||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
- SETUID
|
- SETUID
|
||||||
- SETGID
|
- SETGID
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:noexec,nosuid,size=128m
|
- /tmp:noexec,nosuid,size=64m
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
@@ -22,7 +22,6 @@ services:
|
|||||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||||
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||||
# - SESSION_DURATION=30d # How long users stay logged in (trek_session JWT + cookie maxAge). Accepts: 1h | 12h | 7d | 30d | 90d. Default: 24h
|
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||||
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
|
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
|
||||||
@@ -43,7 +42,6 @@ services:
|
|||||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
||||||
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
||||||
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
||||||
# - KITINERARY_EXTRACTOR_PATH= # Optional. Full path to kitinerary-extractor binary. Auto-detected from PATH and /usr/lib/*/libexec/kf6/ when unset.
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
|
|||||||
Generated
-277
@@ -26,10 +26,7 @@
|
|||||||
"name": "@trek/client",
|
"name": "@trek/client",
|
||||||
"version": "3.0.22",
|
"version": "3.0.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/geist-sans": "^5.2.5",
|
|
||||||
"@fontsource/poppins": "^5.2.7",
|
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@simplewebauthn/browser": "^13.1.2",
|
|
||||||
"@trek/shared": "*",
|
"@trek/shared": "*",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
@@ -2508,30 +2505,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource/geist-sans": {
|
|
||||||
"version": "5.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.2.5.tgz",
|
|
||||||
"integrity": "sha512-anllOHyJbElRs9fV15TeDRqAeb1IKm4bSknPl6ZMoyPTx1BBy7logudcUwpNjmQLkzn4Q0JGQLRCUKJYoyST6A==",
|
|
||||||
"license": "OFL-1.1",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fontsource/poppins": {
|
|
||||||
"version": "5.2.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.2.7.tgz",
|
|
||||||
"integrity": "sha512-6uQyPmseo4FgI97WIhA4yWRlNaoLk4vSDK/PyRwdqqZb5zAEuc+Kunt8JTMcsHYUEGYBtN15SNkMajMdqUSUmg==",
|
|
||||||
"license": "OFL-1.1",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@hexagon/base64": {
|
|
||||||
"version": "1.1.28",
|
|
||||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
|
||||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.14",
|
"version": "1.19.14",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3663,12 +3636,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@levischuck/tiny-cbor": {
|
|
||||||
"version": "0.2.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
|
||||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@lukeed/csprng": {
|
"node_modules/@lukeed/csprng": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -4503,174 +4470,6 @@
|
|||||||
"@noble/hashes": "^1.1.5"
|
"@noble/hashes": "^1.1.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@peculiar/asn1-android": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-iD3VskhVQnM4nE3PN9cBdPTR7JrqZy3FYk+uD2CeG6DUqKoANqaEfx0f7izPmW+Qm5JBM35ek+viLCmjy18ByQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/asn1-schema": "^2.7.0",
|
|
||||||
"asn1js": "^3.0.6",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@peculiar/asn1-cms": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/asn1-schema": "^2.7.0",
|
|
||||||
"@peculiar/asn1-x509": "^2.7.0",
|
|
||||||
"@peculiar/asn1-x509-attr": "^2.7.0",
|
|
||||||
"asn1js": "^3.0.6",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@peculiar/asn1-csr": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/asn1-schema": "^2.7.0",
|
|
||||||
"@peculiar/asn1-x509": "^2.7.0",
|
|
||||||
"asn1js": "^3.0.6",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@peculiar/asn1-ecc": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/asn1-schema": "^2.7.0",
|
|
||||||
"@peculiar/asn1-x509": "^2.7.0",
|
|
||||||
"asn1js": "^3.0.6",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@peculiar/asn1-pfx": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/asn1-cms": "^2.7.0",
|
|
||||||
"@peculiar/asn1-pkcs8": "^2.7.0",
|
|
||||||
"@peculiar/asn1-rsa": "^2.7.0",
|
|
||||||
"@peculiar/asn1-schema": "^2.7.0",
|
|
||||||
"asn1js": "^3.0.6",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@peculiar/asn1-pkcs8": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/asn1-schema": "^2.7.0",
|
|
||||||
"@peculiar/asn1-x509": "^2.7.0",
|
|
||||||
"asn1js": "^3.0.6",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@peculiar/asn1-pkcs9": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/asn1-cms": "^2.7.0",
|
|
||||||
"@peculiar/asn1-pfx": "^2.7.0",
|
|
||||||
"@peculiar/asn1-pkcs8": "^2.7.0",
|
|
||||||
"@peculiar/asn1-schema": "^2.7.0",
|
|
||||||
"@peculiar/asn1-x509": "^2.7.0",
|
|
||||||
"@peculiar/asn1-x509-attr": "^2.7.0",
|
|
||||||
"asn1js": "^3.0.6",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@peculiar/asn1-rsa": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/asn1-schema": "^2.7.0",
|
|
||||||
"@peculiar/asn1-x509": "^2.7.0",
|
|
||||||
"asn1js": "^3.0.6",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@peculiar/asn1-schema": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/utils": "^2.0.2",
|
|
||||||
"asn1js": "^3.0.6",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@peculiar/asn1-x509": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/asn1-schema": "^2.7.0",
|
|
||||||
"@peculiar/utils": "^2.0.2",
|
|
||||||
"asn1js": "^3.0.6",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@peculiar/asn1-x509-attr": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/asn1-schema": "^2.7.0",
|
|
||||||
"@peculiar/asn1-x509": "^2.7.0",
|
|
||||||
"asn1js": "^3.0.6",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@peculiar/utils": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@peculiar/x509": {
|
|
||||||
"version": "1.14.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
|
|
||||||
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@peculiar/asn1-cms": "^2.6.0",
|
|
||||||
"@peculiar/asn1-csr": "^2.6.0",
|
|
||||||
"@peculiar/asn1-ecc": "^2.6.0",
|
|
||||||
"@peculiar/asn1-pkcs9": "^2.6.0",
|
|
||||||
"@peculiar/asn1-rsa": "^2.6.0",
|
|
||||||
"@peculiar/asn1-schema": "^2.6.0",
|
|
||||||
"@peculiar/asn1-x509": "^2.6.0",
|
|
||||||
"pvtsutils": "^1.3.6",
|
|
||||||
"reflect-metadata": "^0.2.2",
|
|
||||||
"tslib": "^2.8.1",
|
|
||||||
"tsyringe": "^4.10.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5360,31 +5159,6 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@simplewebauthn/browser": {
|
|
||||||
"version": "13.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
|
||||||
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@simplewebauthn/server": {
|
|
||||||
"version": "13.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.1.tgz",
|
|
||||||
"integrity": "sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@hexagon/base64": "^1.1.27",
|
|
||||||
"@levischuck/tiny-cbor": "^0.2.2",
|
|
||||||
"@peculiar/asn1-android": "^2.6.0",
|
|
||||||
"@peculiar/asn1-ecc": "^2.6.1",
|
|
||||||
"@peculiar/asn1-rsa": "^2.6.1",
|
|
||||||
"@peculiar/asn1-schema": "^2.6.0",
|
|
||||||
"@peculiar/asn1-x509": "^2.6.1",
|
|
||||||
"@peculiar/x509": "^1.14.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@swc/core": {
|
"node_modules/@swc/core": {
|
||||||
"version": "1.15.40",
|
"version": "1.15.40",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6648,20 +6422,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/asn1js": {
|
|
||||||
"version": "3.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
|
||||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"pvtsutils": "^1.3.6",
|
|
||||||
"pvutils": "^1.1.5",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -12985,24 +12745,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pvtsutils": {
|
|
||||||
"version": "1.3.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
|
||||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pvutils": {
|
|
||||||
"version": "1.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
|
||||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/qrcode": {
|
"node_modules/qrcode": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -15683,24 +15425,6 @@
|
|||||||
"@esbuild/win32-x64": "0.28.0"
|
"@esbuild/win32-x64": "0.28.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tsyringe": {
|
|
||||||
"version": "4.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
|
||||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^1.9.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tsyringe/node_modules/tslib": {
|
|
||||||
"version": "1.14.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
|
||||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
|
||||||
"license": "0BSD"
|
|
||||||
},
|
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -17602,7 +17326,6 @@
|
|||||||
"@nestjs/common": "^11.1.24",
|
"@nestjs/common": "^11.1.24",
|
||||||
"@nestjs/core": "^11.1.24",
|
"@nestjs/core": "^11.1.24",
|
||||||
"@nestjs/platform-express": "^11.1.24",
|
"@nestjs/platform-express": "^11.1.24",
|
||||||
"@simplewebauthn/server": "^13.1.2",
|
|
||||||
"@trek/shared": "*",
|
"@trek/shared": "*",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ NODE_ENV=development # development = development mode; production = production m
|
|||||||
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||||
# DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference (default: en)
|
# DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference (default: en)
|
||||||
# SESSION_DURATION=30d # How long users stay logged in — sets the trek_session JWT exp + cookie maxAge. Accepts 1h, 12h, 7d, 30d, 90d. Default: 24h
|
|
||||||
# Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
# Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||||
# Note: browser/OS language is detected automatically first; this is the fallback when no match is found.
|
# Note: browser/OS language is detected automatically first; this is the fallback when no match is found.
|
||||||
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details
|
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"@nestjs/common": "^11.1.24",
|
"@nestjs/common": "^11.1.24",
|
||||||
"@nestjs/core": "^11.1.24",
|
"@nestjs/core": "^11.1.24",
|
||||||
"@nestjs/platform-express": "^11.1.24",
|
"@nestjs/platform-express": "^11.1.24",
|
||||||
"@simplewebauthn/server": "^13.1.2",
|
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
|||||||
@@ -107,32 +107,3 @@ if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
|
|||||||
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
|
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
|
||||||
}
|
}
|
||||||
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
|
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
|
||||||
|
|
||||||
// SESSION_DURATION controls how long a TREK session (the `trek_session` JWT
|
|
||||||
// cookie) stays valid before re-login is required. Accepts ms-style strings:
|
|
||||||
// '1h', '12h', '7d', '30d', '90d', etc. It applies to BOTH the JWT `exp` claim
|
|
||||||
// and the cookie `maxAge`, so the two never drift apart. Invalid values warn at
|
|
||||||
// startup and fall back to the default. Does not affect the short-lived MFA
|
|
||||||
// challenge token or MCP OAuth tokens — those keep their own TTL.
|
|
||||||
const DEFAULT_SESSION_DURATION = '24h';
|
|
||||||
const DURATION_UNITS_MS: Record<string, number> = {
|
|
||||||
ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000, y: 31_557_600_000,
|
|
||||||
};
|
|
||||||
function parseDurationMs(value: string): number | null {
|
|
||||||
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim());
|
|
||||||
if (!m) return null;
|
|
||||||
const n = parseFloat(m[1]);
|
|
||||||
if (!Number.isFinite(n) || n <= 0) return null;
|
|
||||||
return n * DURATION_UNITS_MS[(m[2] || 'ms').toLowerCase()];
|
|
||||||
}
|
|
||||||
const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION;
|
|
||||||
const parsedSessionMs = parseDurationMs(rawSessionDuration);
|
|
||||||
if (parsedSessionMs == null) {
|
|
||||||
console.warn(`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`);
|
|
||||||
}
|
|
||||||
/** Human-readable session length actually in effect (for logs/diagnostics). */
|
|
||||||
export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration;
|
|
||||||
/** Session length in milliseconds — used for the cookie `maxAge`. */
|
|
||||||
export const SESSION_DURATION_MS = parsedSessionMs ?? parseDurationMs(DEFAULT_SESSION_DURATION)!;
|
|
||||||
/** Session length in seconds — passed to `jwt.sign({ expiresIn })` (number = seconds). */
|
|
||||||
export const SESSION_DURATION_SECONDS = Math.floor(SESSION_DURATION_MS / 1000);
|
|
||||||
|
|||||||
@@ -2278,97 +2278,6 @@ function runMigrations(db: Database.Database): void {
|
|||||||
if (!err.message?.includes('no such table')) throw err;
|
if (!err.message?.includes('no such table')) throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Costs rework (budget → "Costs", Tricount/Splitwise style). Adds, additively
|
|
||||||
// and without touching existing rows:
|
|
||||||
// - per-expense currency + exchange_rate, so an expense can be entered in a
|
|
||||||
// foreign currency and converted to the trip base currency (NULL currency =
|
|
||||||
// base currency; rate 1.0). Closes the multi-currency request (#551).
|
|
||||||
// - budget_item_payers: several people can each have paid part of one expense
|
|
||||||
// (amounts in the expense currency), replacing the single paid_by_user_id.
|
|
||||||
// - budget_settlements: persisted "X paid Y" transfers so the settle-up
|
|
||||||
// history (with undo) is shared across all trip members.
|
|
||||||
// The equal-split participants stay in budget_item_members. The single legacy
|
|
||||||
// payer is backfilled into budget_item_payers as one payer covering the total.
|
|
||||||
() => {
|
|
||||||
try { db.exec('ALTER TABLE budget_items ADD COLUMN currency TEXT'); }
|
|
||||||
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
|
||||||
try { db.exec('ALTER TABLE budget_items ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 1'); }
|
|
||||||
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS budget_item_payers (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
amount REAL NOT NULL DEFAULT 0,
|
|
||||||
UNIQUE(budget_item_id, user_id)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_budget_item_payers_item ON budget_item_payers(budget_item_id)');
|
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS budget_settlements (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
|
||||||
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
amount REAL NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by_user_id INTEGER REFERENCES users(id)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_budget_settlements_trip ON budget_settlements(trip_id)');
|
|
||||||
|
|
||||||
// Backfill the legacy single payer: that person paid the full total of the
|
|
||||||
// expense, in the (base) currency the existing amount was already stored in.
|
|
||||||
try {
|
|
||||||
db.exec(`
|
|
||||||
INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount)
|
|
||||||
SELECT id, paid_by_user_id, total_price
|
|
||||||
FROM budget_items
|
|
||||||
WHERE paid_by_user_id IS NOT NULL
|
|
||||||
`);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (!err.message?.includes('no such column')) throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Rename the "Budget Planner" addon to "Costs" in the admin add-on list. This
|
|
||||||
// is a display rename only — the addon id, tables, permissions and MCP tools
|
|
||||||
// all stay 'budget'. Scoped to the default name so a customised one is kept.
|
|
||||||
() => {
|
|
||||||
db.prepare(
|
|
||||||
"UPDATE addons SET name = 'Costs', description = 'Track and split trip expenses' WHERE id = 'budget' AND name = 'Budget Planner'",
|
|
||||||
).run();
|
|
||||||
},
|
|
||||||
// WebAuthn / passkey support: per-user credentials + single-use login
|
|
||||||
// challenges. Additive (CREATE TABLE IF NOT EXISTS) so existing installs are
|
|
||||||
// untouched; both tables also live in schema.ts for fresh installs.
|
|
||||||
() => db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
credential_id TEXT NOT NULL UNIQUE,
|
|
||||||
public_key BLOB NOT NULL,
|
|
||||||
counter INTEGER NOT NULL DEFAULT 0,
|
|
||||||
transports TEXT,
|
|
||||||
device_type TEXT,
|
|
||||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
|
||||||
name TEXT,
|
|
||||||
aaguid TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_used_at DATETIME
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
|
|
||||||
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
challenge TEXT NOT NULL UNIQUE,
|
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
expires_at INTEGER NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);
|
|
||||||
`),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -42,32 +42,6 @@ function createTables(db: Database.Database): void {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
|
CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
credential_id TEXT NOT NULL UNIQUE,
|
|
||||||
public_key BLOB NOT NULL,
|
|
||||||
counter INTEGER NOT NULL DEFAULT 0,
|
|
||||||
transports TEXT,
|
|
||||||
device_type TEXT,
|
|
||||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
|
||||||
name TEXT,
|
|
||||||
aaguid TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_used_at DATETIME
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
challenge TEXT NOT NULL UNIQUE,
|
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
expires_at INTEGER NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function seedAddons(db: Database.Database): void {
|
|||||||
try {
|
try {
|
||||||
const defaultAddons = [
|
const defaultAddons = [
|
||||||
{ id: 'packing', name: 'Lists', description: 'Packing lists and to-do tasks for your trips', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
{ id: 'packing', name: 'Lists', description: 'Packing lists and to-do tasks for your trips', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
||||||
{ id: 'budget', name: 'Costs', description: 'Track and split trip expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
||||||
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
||||||
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
|
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
|
||||||
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ export function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
|||||||
if (method === 'POST' && pathNoQuery === '/api/auth/demo-login') return true;
|
if (method === 'POST' && pathNoQuery === '/api/auth/demo-login') return true;
|
||||||
if (method === 'GET' && pathNoQuery.startsWith('/api/auth/invite/')) return true;
|
if (method === 'GET' && pathNoQuery.startsWith('/api/auth/invite/')) return true;
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/verify-login') return true;
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/verify-login') return true;
|
||||||
// Unauthenticated passkey (primary) login ceremony.
|
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/passkey/login/options') return true;
|
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/passkey/login/verify') return true;
|
|
||||||
if (pathNoQuery.startsWith('/api/auth/oidc/')) return true;
|
if (pathNoQuery.startsWith('/api/auth/oidc/')) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -24,11 +21,6 @@ export function isMfaSetupExemptPath(method: string, pathNoQuery: string): boole
|
|||||||
if (method === 'GET' && pathNoQuery === '/api/auth/me') return true;
|
if (method === 'GET' && pathNoQuery === '/api/auth/me') return true;
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true;
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true;
|
||||||
// Allow enrolling a passkey as the second factor (a user-verified passkey
|
|
||||||
// satisfies require_mfa), so a fresh user under the policy isn't stuck.
|
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/passkey/register/options') return true;
|
|
||||||
if (method === 'POST' && pathNoQuery === '/api/auth/passkey/register/verify') return true;
|
|
||||||
if (method === 'GET' && pathNoQuery === '/api/auth/passkey/credentials') return true;
|
|
||||||
if ((method === 'GET' || method === 'PUT') && pathNoQuery === '/api/auth/app-settings') return true;
|
if ((method === 'GET' || method === 'PUT') && pathNoQuery === '/api/auth/app-settings') return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -89,12 +81,8 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A user-verified passkey is phishing-resistant and inherently two-factor, so
|
|
||||||
// owning at least one satisfies the require_mfa policy exactly like TOTP does.
|
|
||||||
// (All stored passkeys were registered with userVerification required.)
|
|
||||||
const mfaOk = row.mfa_enabled === 1 || row.mfa_enabled === true;
|
const mfaOk = row.mfa_enabled === 1 || row.mfa_enabled === true;
|
||||||
const passkeyOk = !!db.prepare('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1').get(userId);
|
if (mfaOk) {
|
||||||
if (mfaOk || passkeyOk) {
|
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,13 +60,6 @@ export class AdminController {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('users/:id/passkeys')
|
|
||||||
resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
|
||||||
const result = ok(this.admin.resetUserPasskeys(id));
|
|
||||||
writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
|
|
||||||
return { success: true, deleted: result.deleted };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stats / permissions / audit ──
|
// ── Stats / permissions / audit ──
|
||||||
@Get('stats')
|
@Get('stats')
|
||||||
stats() { return this.admin.getStats(); }
|
stats() { return this.admin.getStats(); }
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import * as svc from '../../services/adminService';
|
|||||||
import { getAdminUserDefaults, setAdminUserDefaults } from '../../services/settingsService';
|
import { getAdminUserDefaults, setAdminUserDefaults } from '../../services/settingsService';
|
||||||
import { invalidateMcpSessions } from '../../mcp';
|
import { invalidateMcpSessions } from '../../mcp';
|
||||||
import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService';
|
import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService';
|
||||||
import { adminResetPasskeys } from '../../services/passkeyService';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin Nest wrapper around the existing admin service (+ the settings,
|
* Thin Nest wrapper around the existing admin service (+ the settings,
|
||||||
@@ -18,7 +17,6 @@ export class AdminService {
|
|||||||
createUser(body: unknown) { return svc.createUser(body as Parameters<typeof svc.createUser>[0]); }
|
createUser(body: unknown) { return svc.createUser(body as Parameters<typeof svc.createUser>[0]); }
|
||||||
updateUser(id: string, body: unknown) { return svc.updateUser(id, body as Parameters<typeof svc.updateUser>[1]); }
|
updateUser(id: string, body: unknown) { return svc.updateUser(id, body as Parameters<typeof svc.updateUser>[1]); }
|
||||||
deleteUser(id: string, actingUserId: number) { return svc.deleteUser(id, actingUserId); }
|
deleteUser(id: string, actingUserId: number) { return svc.deleteUser(id, actingUserId); }
|
||||||
resetUserPasskeys(id: string) { return adminResetPasskeys(Number(id)); }
|
|
||||||
|
|
||||||
getStats() { return svc.getStats(); }
|
getStats() { return svc.getStats(); }
|
||||||
getPermissions() { return svc.getPermissions(); }
|
getPermissions() { return svc.getPermissions(); }
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import { JourneyModule } from './journey/journey.module';
|
|||||||
import { ShareModule } from './share/share.module';
|
import { ShareModule } from './share/share.module';
|
||||||
import { SettingsModule } from './settings/settings.module';
|
import { SettingsModule } from './settings/settings.module';
|
||||||
import { BackupModule } from './backup/backup.module';
|
import { BackupModule } from './backup/backup.module';
|
||||||
import { BookingImportModule } from './booking-import/booking-import.module';
|
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { OidcModule } from './oidc/oidc.module';
|
import { OidcModule } from './oidc/oidc.module';
|
||||||
import { OauthModule } from './oauth/oauth.module';
|
import { OauthModule } from './oauth/oauth.module';
|
||||||
@@ -44,7 +43,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
|
|||||||
* (weather, notifications, ...) get registered here as they are migrated.
|
* (weather, notifications, ...) get registered here as they are migrated.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
|
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
HealthService,
|
HealthService,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthPublicController } from './auth-public.controller';
|
import { AuthPublicController } from './auth-public.controller';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { PasskeyController } from './passkey.controller';
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RateLimitService } from './rate-limit.service';
|
import { RateLimitService } from './rate-limit.service';
|
||||||
|
|
||||||
@@ -12,7 +11,7 @@ import { RateLimitService } from './rate-limit.service';
|
|||||||
* sub-paths explicitly rather than claiming all of /api/auth.
|
* sub-paths explicitly rather than claiming all of /api/auth.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthPublicController, AuthController, PasskeyController],
|
controllers: [AuthPublicController, AuthController],
|
||||||
providers: [AuthService, RateLimitService],
|
providers: [AuthService, RateLimitService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { CanActivate, HttpException, Injectable } from '@nestjs/common';
|
|
||||||
import { resolveAuthToggles } from '../../services/authService';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-side enforcement of the instance-wide `passkey_login` toggle. Placed
|
|
||||||
* BEFORE the auth guard on every passkey ceremony route so a disabled feature
|
|
||||||
* returns 404 (not "auth required") and cannot be driven by direct API calls —
|
|
||||||
* hiding the button in the UI is not enough. Mirrors JourneyAddonGuard.
|
|
||||||
*
|
|
||||||
* The credential-management routes (list/rename/delete) are deliberately NOT
|
|
||||||
* gated by this guard so users can still clean up their passkeys after an admin
|
|
||||||
* turns the feature off.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class PasskeyEnabledGuard implements CanActivate {
|
|
||||||
canActivate(): boolean {
|
|
||||||
if (!resolveAuthToggles().passkey_login) {
|
|
||||||
throw new HttpException({ error: 'Passkey login is not enabled' }, 404);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Patch, Post, Req, Res, UseGuards } from '@nestjs/common';
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { RateLimitService } from './rate-limit.service';
|
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
|
||||||
import { PasskeyEnabledGuard } from './passkey-enabled.guard';
|
|
||||||
import { CurrentUser } from './current-user.decorator';
|
|
||||||
import { setAuthCookie } from '../../services/cookie';
|
|
||||||
import { writeAudit, getClientIp } from '../../services/auditLog';
|
|
||||||
import * as passkey from '../../services/passkeyService';
|
|
||||||
import type { User } from '../../types';
|
|
||||||
|
|
||||||
const WINDOW = 15 * 60 * 1000;
|
|
||||||
const LOGIN_MIN_LATENCY_MS = 350;
|
|
||||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* /api/auth/passkey — WebAuthn (passkey) registration, primary login and
|
|
||||||
* credential management.
|
|
||||||
*
|
|
||||||
* - register/* : authenticated, gated by the admin toggle + password re-auth.
|
|
||||||
* - login/* : UNauthenticated discoverable-credential login, gated by the
|
|
||||||
* admin toggle; mints the SAME session cookie as password login.
|
|
||||||
* - credentials : owner-scoped management — intentionally NOT toggle-gated so a
|
|
||||||
* user can always view/remove their passkeys.
|
|
||||||
*
|
|
||||||
* PasskeyEnabledGuard is listed first so a disabled feature 404s before auth.
|
|
||||||
*/
|
|
||||||
@Controller('api/auth/passkey')
|
|
||||||
export class PasskeyController {
|
|
||||||
constructor(private readonly rl: RateLimitService) {}
|
|
||||||
|
|
||||||
private limit(bucket: string, req: Request, max: number): void {
|
|
||||||
if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) {
|
|
||||||
throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Registration (authenticated) ──
|
|
||||||
@Post('register/options')
|
|
||||||
@HttpCode(200)
|
|
||||||
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
|
||||||
async registerOptions(@CurrentUser() user: User, @Body() body: { password?: string }, @Req() req: Request) {
|
|
||||||
this.limit('mfa', req, 5);
|
|
||||||
const result = await passkey.passkeyRegisterOptions(user.id, body?.password);
|
|
||||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
return result.options;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('register/verify')
|
|
||||||
@HttpCode(200)
|
|
||||||
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
|
||||||
async registerVerify(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
|
|
||||||
const result = await passkey.passkeyRegisterVerify(user.id, body as Parameters<typeof passkey.passkeyRegisterVerify>[1]);
|
|
||||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
|
|
||||||
return { success: true, credential: result.credential };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Authentication (public — primary login) ──
|
|
||||||
@Post('login/options')
|
|
||||||
@HttpCode(200)
|
|
||||||
@UseGuards(PasskeyEnabledGuard)
|
|
||||||
async loginOptions(@Req() req: Request) {
|
|
||||||
this.limit('login', req, 10);
|
|
||||||
const result = await passkey.passkeyLoginOptions();
|
|
||||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
return result.options;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('login/verify')
|
|
||||||
@HttpCode(200)
|
|
||||||
@UseGuards(PasskeyEnabledGuard)
|
|
||||||
async loginVerify(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
|
||||||
this.limit('login', req, 10);
|
|
||||||
const started = Date.now();
|
|
||||||
const result = await passkey.passkeyLoginVerify(body as Parameters<typeof passkey.passkeyLoginVerify>[0]);
|
|
||||||
if (result.auditAction) {
|
|
||||||
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) });
|
|
||||||
}
|
|
||||||
// Pad to the same floor as password login so timing can't distinguish a
|
|
||||||
// known credential from an unknown one.
|
|
||||||
const elapsed = Date.now() - started;
|
|
||||||
if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed);
|
|
||||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } });
|
|
||||||
setAuthCookie(res, result.token!, req);
|
|
||||||
return { token: result.token, user: result.user };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Management (authenticated, owner-scoped — NOT toggle-gated) ──
|
|
||||||
@Get('credentials')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
list(@CurrentUser() user: User) {
|
|
||||||
return { credentials: passkey.listPasskeys(user.id) };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch('credentials/:id')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { name?: unknown }) {
|
|
||||||
const result = passkey.renamePasskey(user.id, id, body?.name);
|
|
||||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('credentials/:id')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { password?: string }, @Req() req: Request) {
|
|
||||||
this.limit('login', req, 5);
|
|
||||||
const result = passkey.deletePasskey(user.id, id, body?.password);
|
|
||||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Param,
|
|
||||||
Headers,
|
|
||||||
HttpException,
|
|
||||||
UseGuards,
|
|
||||||
UseInterceptors,
|
|
||||||
UploadedFiles,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
|
||||||
import { memoryStorage } from 'multer';
|
|
||||||
import type { User } from '../../types';
|
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
|
||||||
import { BookingImportService } from './booking-import.service';
|
|
||||||
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse } from '@trek/shared';
|
|
||||||
|
|
||||||
const ACCEPTED_EXTS = new Set(['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']);
|
|
||||||
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
||||||
const MAX_FILES = 5;
|
|
||||||
|
|
||||||
const UPLOAD = {
|
|
||||||
storage: memoryStorage(),
|
|
||||||
limits: { fileSize: MAX_FILE_BYTES, files: MAX_FILES },
|
|
||||||
};
|
|
||||||
|
|
||||||
@Controller('api/trips/:tripId/reservations/import')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
export class BookingImportController {
|
|
||||||
constructor(private readonly bookingImport: BookingImportService) {}
|
|
||||||
|
|
||||||
private requireTrip(tripId: string, user: User) {
|
|
||||||
const trip = this.bookingImport.verifyTripAccess(tripId, user.id);
|
|
||||||
if (!trip) throw new HttpException({ error: 'Trip not found' }, 404);
|
|
||||||
return trip;
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireEdit(trip: ReturnType<BookingImportService['verifyTripAccess']>, user: User): void {
|
|
||||||
if (!this.bookingImport.canEdit(trip!, user)) {
|
|
||||||
throw new HttpException({ error: 'No permission' }, 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/trips/:tripId/reservations/import/booking
|
|
||||||
* Accepts up to 5 booking confirmation files (EML, PDF, PKPass, HTML, TXT).
|
|
||||||
* Returns a preview list without persisting anything.
|
|
||||||
*/
|
|
||||||
@Post('booking')
|
|
||||||
@UseInterceptors(FilesInterceptor('files', MAX_FILES, UPLOAD))
|
|
||||||
async preview(
|
|
||||||
@CurrentUser() user: User,
|
|
||||||
@Param('tripId') tripId: string,
|
|
||||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
|
||||||
) {
|
|
||||||
const trip = this.requireTrip(tripId, user);
|
|
||||||
this.requireEdit(trip, user);
|
|
||||||
|
|
||||||
if (!this.bookingImport.isAvailable()) {
|
|
||||||
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
throw new HttpException({ error: 'No files uploaded' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate extensions
|
|
||||||
for (const f of files) {
|
|
||||||
const ext = f.originalname.toLowerCase().slice(f.originalname.lastIndexOf('.'));
|
|
||||||
if (!ACCEPTED_EXTS.has(ext)) {
|
|
||||||
throw new HttpException({ error: `Unsupported file type: ${f.originalname}. Accepted: EML, PDF, PKPass, HTML, TXT` }, 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: BookingImportPreviewResponse = await this.bookingImport.preview(files);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/trips/:tripId/reservations/import/booking/confirm
|
|
||||||
* Persists the user-confirmed subset of parsed items.
|
|
||||||
*/
|
|
||||||
@Post('booking/confirm')
|
|
||||||
async confirm(
|
|
||||||
@CurrentUser() user: User,
|
|
||||||
@Param('tripId') tripId: string,
|
|
||||||
@Body() body: { items?: BookingImportPreviewItem[] },
|
|
||||||
@Headers('x-socket-id') socketId?: string,
|
|
||||||
): Promise<BookingImportConfirmResponse> {
|
|
||||||
const trip = this.requireTrip(tripId, user);
|
|
||||||
this.requireEdit(trip, user);
|
|
||||||
|
|
||||||
const items = body?.items;
|
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
|
||||||
throw new HttpException({ error: 'items must be a non-empty array' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.bookingImport.confirm(tripId, items, socketId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { BookingImportController } from './booking-import.controller';
|
|
||||||
import { BookingImportService } from './booking-import.service';
|
|
||||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
|
||||||
import { FeaturesController } from './features.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [BookingImportController, FeaturesController],
|
|
||||||
providers: [BookingImportService, KitineraryExtractorService],
|
|
||||||
})
|
|
||||||
export class BookingImportModule {}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { Injectable, HttpException } from '@nestjs/common';
|
|
||||||
import { broadcast } from '../../websocket';
|
|
||||||
import { checkPermission } from '../../services/permissions';
|
|
||||||
import { verifyTripAccess } from '../../services/tripAccess';
|
|
||||||
import { createReservation } from '../../services/reservationService';
|
|
||||||
import { createPlace } from '../../services/placeService';
|
|
||||||
import { searchNominatim } from '../../services/mapsService';
|
|
||||||
import { db } from '../../db/database';
|
|
||||||
import type { User } from '../../types';
|
|
||||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
|
||||||
import { mapReservations } from './kitinerary-mapper';
|
|
||||||
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, Reservation } from '@trek/shared';
|
|
||||||
import type { ParsedBookingItem } from './kitinerary.types';
|
|
||||||
|
|
||||||
function resolveDayId(tripId: string, iso: string | null | undefined): number | null {
|
|
||||||
if (!iso) return null;
|
|
||||||
const date = iso.slice(0, 10);
|
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
|
||||||
const row = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined;
|
|
||||||
return row?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BookingImportService {
|
|
||||||
constructor(private readonly extractor: KitineraryExtractorService) {}
|
|
||||||
|
|
||||||
isAvailable(): boolean {
|
|
||||||
return this.extractor.isAvailable();
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyTripAccess(tripId: string, userId: number) {
|
|
||||||
return verifyTripAccess(tripId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
canEdit(trip: NonNullable<ReturnType<typeof verifyTripAccess>>, user: User): boolean {
|
|
||||||
return checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse uploaded files through kitinerary-extractor and return a preview list.
|
|
||||||
* Does NOT persist anything.
|
|
||||||
*/
|
|
||||||
async preview(files: Express.Multer.File[]): Promise<BookingImportPreviewResponse> {
|
|
||||||
if (!this.extractor.isAvailable()) {
|
|
||||||
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allItems: ParsedBookingItem[] = [];
|
|
||||||
const allWarnings: string[] = [];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
let kiItems;
|
|
||||||
try {
|
|
||||||
kiItems = await this.extractor.extract(file.buffer, file.originalname);
|
|
||||||
} catch (err) {
|
|
||||||
allWarnings.push(`${file.originalname}: extraction failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kiItems.length === 0) {
|
|
||||||
allWarnings.push(`${file.originalname}: no reservations found`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { items, warnings } = mapReservations(kiItems, file.originalname);
|
|
||||||
allItems.push(...items);
|
|
||||||
allWarnings.push(...warnings);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items: allItems, warnings: allWarnings };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist a confirmed list of parsed items.
|
|
||||||
* Creates place rows for hotel/restaurant/event venues, then calls createReservation.
|
|
||||||
* Broadcasts reservation:created (and accommodation:created if applicable) per item.
|
|
||||||
*/
|
|
||||||
async confirm(
|
|
||||||
tripId: string,
|
|
||||||
items: BookingImportPreviewItem[],
|
|
||||||
socketId: string | undefined,
|
|
||||||
): Promise<BookingImportConfirmResponse> {
|
|
||||||
const created: Reservation[] = [];
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
try {
|
|
||||||
const { _venue, _accommodation, source: _src, ...reservationData } = item;
|
|
||||||
|
|
||||||
// Auto-create a place row for venue-based reservations
|
|
||||||
let placeId: number | undefined;
|
|
||||||
if (_venue?.name) {
|
|
||||||
// Geocode before creating so the broadcast carries the coordinates
|
|
||||||
let lat = _venue.lat;
|
|
||||||
let lng = _venue.lng;
|
|
||||||
if (lat == null && (_venue.address || _venue.name)) {
|
|
||||||
try {
|
|
||||||
const queries = [
|
|
||||||
_venue.address ? `${_venue.name} ${_venue.address}` : null,
|
|
||||||
_venue.address ?? null,
|
|
||||||
_venue.name,
|
|
||||||
].filter((q): q is string => !!q);
|
|
||||||
|
|
||||||
for (const q of queries) {
|
|
||||||
const results = await searchNominatim(q);
|
|
||||||
const hit = results[0];
|
|
||||||
if (hit?.lat != null && hit?.lng != null) {
|
|
||||||
lat = hit.lat;
|
|
||||||
lng = hit.lng;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// geocoding failure is non-fatal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const place = createPlace(tripId, {
|
|
||||||
name: _venue.name,
|
|
||||||
lat,
|
|
||||||
lng,
|
|
||||||
address: _venue.address,
|
|
||||||
website: _venue.website,
|
|
||||||
phone: _venue.phone,
|
|
||||||
});
|
|
||||||
placeId = (place as any).id;
|
|
||||||
broadcast(tripId, 'place:created', { place }, socketId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build create_accommodation for hotel reservations.
|
|
||||||
// start_day_id / end_day_id are resolved from check-in/out ISO dates so
|
|
||||||
// the accommodation row is actually inserted (createReservation gates on them).
|
|
||||||
let createAccommodation: { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_out?: string; confirmation?: string } | undefined;
|
|
||||||
if (item.type === 'hotel' && _accommodation) {
|
|
||||||
const startDayId = resolveDayId(tripId, _accommodation.check_in);
|
|
||||||
const endDayId = resolveDayId(tripId, _accommodation.check_out);
|
|
||||||
createAccommodation = {
|
|
||||||
place_id: placeId,
|
|
||||||
start_day_id: startDayId ?? undefined,
|
|
||||||
end_day_id: endDayId ?? undefined,
|
|
||||||
check_in: _accommodation.check_in,
|
|
||||||
check_out: _accommodation.check_out,
|
|
||||||
confirmation: _accommodation.confirmation,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
|
||||||
...reservationData,
|
|
||||||
place_id: placeId,
|
|
||||||
create_accommodation: createAccommodation,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
broadcast(tripId, 'reservation:created', { reservation }, socketId);
|
|
||||||
if (accommodationCreated) {
|
|
||||||
broadcast(tripId, 'accommodation:created', {}, socketId);
|
|
||||||
}
|
|
||||||
|
|
||||||
created.push(reservation);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[booking-import] Failed to create reservation "${item.title}":`, err instanceof Error ? err.message : err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { created };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
|
||||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
|
||||||
|
|
||||||
/** Exposes server feature flags consumed by the frontend to show/hide optional UI. */
|
|
||||||
@Controller('api/health')
|
|
||||||
export class FeaturesController {
|
|
||||||
constructor(private readonly extractor: KitineraryExtractorService) {}
|
|
||||||
|
|
||||||
@Get('features')
|
|
||||||
features() {
|
|
||||||
return {
|
|
||||||
bookingImport: this.extractor.isAvailable(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
||||||
import { execFile } from 'node:child_process';
|
|
||||||
import { existsSync, readdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join, extname } from 'node:path';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { execSync } from 'node:child_process';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
import type { KiReservation } from './kitinerary.types';
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
const TIMEOUT_MS = 30_000;
|
|
||||||
const MAX_BUFFER = 5 * 1024 * 1024;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class KitineraryExtractorService implements OnModuleInit {
|
|
||||||
private binaryPath: string | null = null;
|
|
||||||
|
|
||||||
onModuleInit() {
|
|
||||||
this.binaryPath = this.findBinary();
|
|
||||||
if (this.binaryPath) {
|
|
||||||
console.log(`[KItinerary] extractor found at: ${this.binaryPath}`);
|
|
||||||
} else {
|
|
||||||
console.info('[KItinerary] extractor not found — booking import feature disabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isAvailable(): boolean {
|
|
||||||
return this.binaryPath !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async extract(buffer: Buffer, fileName: string): Promise<KiReservation[]> {
|
|
||||||
if (!this.binaryPath) {
|
|
||||||
throw new Error('kitinerary-extractor is not available on this system');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ext = extname(fileName).toLowerCase();
|
|
||||||
const tmpFile = join(tmpdir(), `trek-ki-${randomUUID()}${ext}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
writeFileSync(tmpFile, buffer);
|
|
||||||
|
|
||||||
const { stdout, stderr } = await execFileAsync(this.binaryPath, [tmpFile], {
|
|
||||||
timeout: TIMEOUT_MS,
|
|
||||||
maxBuffer: MAX_BUFFER,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (stderr?.trim()) {
|
|
||||||
// Filter expected noise: currency-symbol ambiguity warnings and vendor
|
|
||||||
// extractor script errors are normal (every matching script is tried;
|
|
||||||
// most won't match the current document).
|
|
||||||
const unexpected = stderr
|
|
||||||
.split('\n')
|
|
||||||
.filter(l => l.trim())
|
|
||||||
.filter(l => !l.includes('Ambig') && !l.includes('JS ERROR') && !l.includes('Invalid result type from script'));
|
|
||||||
if (unexpected.length) {
|
|
||||||
console.warn(`[KItinerary] stderr for "${fileName}":`, unexpected.join('\n'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = stdout.trim();
|
|
||||||
if (!text) return [];
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
console.warn(`[KItinerary] non-JSON output for "${fileName}"`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(parsed)) return parsed as KiReservation[];
|
|
||||||
if (typeof parsed === 'object' && parsed !== null) return [parsed as KiReservation];
|
|
||||||
return [];
|
|
||||||
} finally {
|
|
||||||
try { unlinkSync(tmpFile); } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private findBinary(): string | null {
|
|
||||||
const envPath = process.env.KITINERARY_EXTRACTOR_PATH;
|
|
||||||
if (envPath) {
|
|
||||||
if (existsSync(envPath)) return envPath;
|
|
||||||
console.warn(`[KItinerary] KITINERARY_EXTRACTOR_PATH="${envPath}" not found`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debian/Ubuntu: /usr/lib/<triplet>/libexec/kf6/kitinerary-extractor
|
|
||||||
try {
|
|
||||||
for (const dir of readdirSync('/usr/lib')) {
|
|
||||||
const candidate = join('/usr/lib', dir, 'libexec', 'kf6', 'kitinerary-extractor');
|
|
||||||
if (existsSync(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
} catch { /* not a Debian system */ }
|
|
||||||
|
|
||||||
// Fallback: binary in PATH
|
|
||||||
try {
|
|
||||||
execSync('kitinerary-extractor --version', { stdio: 'pipe', timeout: 3000 });
|
|
||||||
return 'kitinerary-extractor';
|
|
||||||
} catch { /* not in PATH */ }
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
import { findByIata } from '../../services/airportService';
|
|
||||||
import type {
|
|
||||||
KiReservation, KiFlight, KiTrainTrip, KiBusTrip, KiBoatTrip,
|
|
||||||
KiLodgingBusiness, KiFoodEstablishment, KiRentalCar, KiEvent,
|
|
||||||
KiGeo, KiAddress, KiDateTimeish, ParsedBookingItem, ParsedEndpoint, ParsedVenue,
|
|
||||||
} from './kitinerary.types';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Extract a plain ISO string from either a string or a KDE QDateTime object. */
|
|
||||||
function toIsoString(dt: KiDateTimeish): string | null {
|
|
||||||
if (!dt) return null;
|
|
||||||
if (typeof dt === 'string') return dt || null;
|
|
||||||
if (typeof dt === 'object' && dt['@type'] === 'QDateTime') return dt['@value'] || null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitIso(dt: KiDateTimeish): { date: string | null; time: string | null } {
|
|
||||||
const iso = toIsoString(dt);
|
|
||||||
if (!iso) return { date: null, time: null };
|
|
||||||
return { date: iso.slice(0, 10) || null, time: iso.length > 10 ? iso.slice(11, 16) || null : null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAddress(address: string | KiAddress | undefined): string | null {
|
|
||||||
if (!address) return null;
|
|
||||||
if (typeof address === 'string') return address || null;
|
|
||||||
const joined = [address.streetAddress, address.addressLocality, address.postalCode, address.addressCountry].filter(Boolean).join(', ');
|
|
||||||
return joined || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function coords(geo: KiGeo | undefined): { lat: number; lng: number } | null {
|
|
||||||
if (!geo || geo.latitude == null || geo.longitude == null) return null;
|
|
||||||
return { lat: Number(geo.latitude), lng: Number(geo.longitude) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Type mappers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function mapFlight(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
|
||||||
const f = r.reservationFor as KiFlight | undefined;
|
|
||||||
if (!f) return null;
|
|
||||||
|
|
||||||
const depIata = f.departureAirport?.iataCode?.toUpperCase() ?? null;
|
|
||||||
const arrIata = f.arrivalAirport?.iataCode?.toUpperCase() ?? null;
|
|
||||||
const depAp = depIata ? findByIata(depIata) : null;
|
|
||||||
const arrAp = arrIata ? findByIata(arrIata) : null;
|
|
||||||
|
|
||||||
const depLabel = depAp ? (depAp.city ? `${depAp.city} (${depAp.iata})` : depAp.name) : (f.departureAirport?.name ?? depIata ?? 'Unknown');
|
|
||||||
const arrLabel = arrAp ? (arrAp.city ? `${arrAp.city} (${arrAp.iata})` : arrAp.name) : (f.arrivalAirport?.name ?? arrIata ?? 'Unknown');
|
|
||||||
|
|
||||||
const airline = f.airline?.name ?? f.airline?.iataCode ?? '';
|
|
||||||
const flightNum = f.flightNumber ?? '';
|
|
||||||
const title = [airline, flightNum].filter(Boolean).join(' ') || `Flight ${depLabel} → ${arrLabel}`;
|
|
||||||
|
|
||||||
const { date: depDate, time: depTime } = splitIso(f.departureTime);
|
|
||||||
const { date: arrDate, time: arrTime } = splitIso(f.arrivalTime);
|
|
||||||
|
|
||||||
const endpoints: ParsedEndpoint[] = [];
|
|
||||||
if (depAp) {
|
|
||||||
endpoints.push({ role: 'from', sequence: 0, name: depLabel, code: depAp.iata, lat: depAp.lat, lng: depAp.lng, timezone: depAp.tz, local_time: depTime, local_date: depDate });
|
|
||||||
} else {
|
|
||||||
const c = coords(f.departureAirport?.geo);
|
|
||||||
if (c) endpoints.push({ role: 'from', sequence: 0, name: depLabel, code: depIata, lat: c.lat, lng: c.lng, timezone: null, local_time: depTime, local_date: depDate });
|
|
||||||
}
|
|
||||||
if (arrAp) {
|
|
||||||
endpoints.push({ role: 'to', sequence: 1, name: arrLabel, code: arrAp.iata, lat: arrAp.lat, lng: arrAp.lng, timezone: arrAp.tz, local_time: arrTime, local_date: arrDate });
|
|
||||||
} else {
|
|
||||||
const c = coords(f.arrivalAirport?.geo);
|
|
||||||
if (c) endpoints.push({ role: 'to', sequence: 1, name: arrLabel, code: arrIata, lat: c.lat, lng: c.lng, timezone: null, local_time: arrTime, local_date: arrDate });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'flight',
|
|
||||||
title,
|
|
||||||
reservation_time: toIsoString(f.departureTime),
|
|
||||||
reservation_end_time: toIsoString(f.arrivalTime),
|
|
||||||
confirmation_number: r.reservationNumber ?? null,
|
|
||||||
metadata: {
|
|
||||||
...(airline ? { airline } : {}),
|
|
||||||
...(flightNum ? { flight_number: flightNum } : {}),
|
|
||||||
...(depIata ? { departure_airport: depIata } : {}),
|
|
||||||
...(arrIata ? { arrival_airport: arrIata } : {}),
|
|
||||||
},
|
|
||||||
endpoints,
|
|
||||||
needs_review: endpoints.length < 2,
|
|
||||||
source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapTrain(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
|
||||||
const t = r.reservationFor as KiTrainTrip | undefined;
|
|
||||||
if (!t) return null;
|
|
||||||
|
|
||||||
const depName = t.departureStation?.name ?? 'Unknown';
|
|
||||||
const arrName = t.arrivalStation?.name ?? 'Unknown';
|
|
||||||
const trainId = t.trainNumber ?? t.trainName ?? '';
|
|
||||||
const title = trainId ? `${trainId} (${depName} → ${arrName})` : `Train ${depName} → ${arrName}`;
|
|
||||||
|
|
||||||
const { date: depDate, time: depTime } = splitIso(t.departureTime);
|
|
||||||
const { date: arrDate, time: arrTime } = splitIso(t.arrivalTime);
|
|
||||||
|
|
||||||
const endpoints: ParsedEndpoint[] = [];
|
|
||||||
const dc = coords(t.departureStation?.geo);
|
|
||||||
const ac = coords(t.arrivalStation?.geo);
|
|
||||||
if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate });
|
|
||||||
if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate });
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'train',
|
|
||||||
title,
|
|
||||||
reservation_time: toIsoString(t.departureTime),
|
|
||||||
reservation_end_time: toIsoString(t.arrivalTime),
|
|
||||||
confirmation_number: r.reservationNumber ?? null,
|
|
||||||
metadata: trainId ? { train_number: trainId } : undefined,
|
|
||||||
endpoints,
|
|
||||||
needs_review: endpoints.length < 2,
|
|
||||||
source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapBus(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
|
||||||
const b = r.reservationFor as KiBusTrip | undefined;
|
|
||||||
if (!b) return null;
|
|
||||||
|
|
||||||
const depName = b.departureBusStop?.name ?? 'Unknown';
|
|
||||||
const arrName = b.arrivalBusStop?.name ?? 'Unknown';
|
|
||||||
const busId = b.busNumber ?? b.busName ?? '';
|
|
||||||
const title = busId ? `${busId} (${depName} → ${arrName})` : `Bus ${depName} → ${arrName}`;
|
|
||||||
|
|
||||||
const { date: depDate, time: depTime } = splitIso(b.departureTime);
|
|
||||||
const { date: arrDate, time: arrTime } = splitIso(b.arrivalTime);
|
|
||||||
|
|
||||||
const endpoints: ParsedEndpoint[] = [];
|
|
||||||
const dc = coords(b.departureBusStop?.geo);
|
|
||||||
const ac = coords(b.arrivalBusStop?.geo);
|
|
||||||
if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate });
|
|
||||||
if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate });
|
|
||||||
|
|
||||||
return { type: 'train', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, needs_review: endpoints.length < 2, source };
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapBoat(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
|
||||||
const b = r.reservationFor as KiBoatTrip | undefined;
|
|
||||||
if (!b) return null;
|
|
||||||
|
|
||||||
const depName = b.departureBoatTerminal?.name ?? 'Unknown';
|
|
||||||
const arrName = b.arrivalBoatTerminal?.name ?? 'Unknown';
|
|
||||||
const title = (b as any).name ?? `Cruise ${depName} → ${arrName}`;
|
|
||||||
|
|
||||||
const { date: depDate, time: depTime } = splitIso(b.departureTime);
|
|
||||||
const { date: arrDate, time: arrTime } = splitIso(b.arrivalTime);
|
|
||||||
|
|
||||||
const endpoints: ParsedEndpoint[] = [];
|
|
||||||
const dc = coords(b.departureBoatTerminal?.geo);
|
|
||||||
const ac = coords(b.arrivalBoatTerminal?.geo);
|
|
||||||
if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate });
|
|
||||||
if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate });
|
|
||||||
|
|
||||||
return { type: 'cruise', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, source };
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapLodging(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
|
||||||
const l = r.reservationFor as KiLodgingBusiness | undefined;
|
|
||||||
if (!l?.name) return null;
|
|
||||||
|
|
||||||
const c = coords(l.geo);
|
|
||||||
const venue: ParsedVenue = { name: l.name, ...(c ?? {}), address: formatAddress(l.address) ?? undefined, website: l.url ?? undefined, phone: l.telephone ?? undefined };
|
|
||||||
|
|
||||||
const { date: checkInDate, time: checkInTime } = splitIso(r.checkinTime);
|
|
||||||
const { date: checkOutDate, time: checkOutTime } = splitIso(r.checkoutTime);
|
|
||||||
const checkIn = checkInDate ? `${checkInDate}${checkInTime ? `T${checkInTime}` : ''}` : undefined;
|
|
||||||
const checkOut = checkOutDate ? `${checkOutDate}${checkOutTime ? `T${checkOutTime}` : ''}` : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'hotel',
|
|
||||||
title: l.name,
|
|
||||||
confirmation_number: r.reservationNumber ?? null,
|
|
||||||
location: formatAddress(l.address),
|
|
||||||
_venue: venue,
|
|
||||||
_accommodation: { check_in: checkIn, check_out: checkOut, confirmation: r.reservationNumber ?? undefined },
|
|
||||||
metadata: { ...(checkInTime ? { check_in_time: checkInTime } : {}), ...(checkOutTime ? { check_out_time: checkOutTime } : {}) },
|
|
||||||
source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapFood(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
|
||||||
const f = r.reservationFor as KiFoodEstablishment | undefined;
|
|
||||||
if (!f?.name) return null;
|
|
||||||
|
|
||||||
const c = coords(f.geo);
|
|
||||||
const venue: ParsedVenue = { name: f.name, ...(c ?? {}), address: formatAddress(f.address) ?? undefined, website: f.url ?? undefined, phone: f.telephone ?? undefined };
|
|
||||||
|
|
||||||
return { type: 'restaurant', title: f.name, reservation_time: toIsoString(r.startTime), reservation_end_time: toIsoString(r.endTime), confirmation_number: r.reservationNumber ?? null, location: formatAddress(f.address), _venue: venue, source };
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapRentalCar(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
|
||||||
const car = r.reservationFor as KiRentalCar | undefined;
|
|
||||||
const company = car?.rentalCompany?.name ?? '';
|
|
||||||
const carName = car?.name ?? [car?.make, car?.model].filter(Boolean).join(' ') ?? '';
|
|
||||||
const title = [company, carName].filter(Boolean).join(' — ') || 'Rental Car';
|
|
||||||
|
|
||||||
const pickup = r.pickupLocation as KiReservation['pickupLocation'];
|
|
||||||
const pc = coords(pickup?.geo);
|
|
||||||
const venue: ParsedVenue | undefined = pickup?.name ? { name: pickup.name, ...(pc ?? {}), address: formatAddress(pickup.address) ?? undefined } : undefined;
|
|
||||||
|
|
||||||
return { type: 'car', title, reservation_time: toIsoString(r.pickupTime), reservation_end_time: toIsoString(r.dropoffTime), confirmation_number: r.reservationNumber ?? null, ...(venue ? { _venue: venue } : {}), source };
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
|
||||||
const e = r.reservationFor as KiEvent | undefined;
|
|
||||||
if (!e?.name) return null;
|
|
||||||
|
|
||||||
const loc = e.location;
|
|
||||||
const c = coords(loc?.geo);
|
|
||||||
const venue: ParsedVenue | undefined = loc?.name ? { name: loc.name, ...(c ?? {}), address: formatAddress(loc.address) ?? undefined } : undefined;
|
|
||||||
|
|
||||||
return { type: 'event', title: e.name, reservation_time: toIsoString(e.startDate), reservation_end_time: toIsoString(e.endDate), confirmation_number: r.reservationNumber ?? null, location: loc ? (formatAddress(loc.address) ?? loc.name ?? null) : null, ...(venue ? { _venue: venue } : {}), source };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function mapReservations(kiItems: KiReservation[], fileName: string): { items: ParsedBookingItem[]; warnings: string[] } {
|
|
||||||
const items: ParsedBookingItem[] = [];
|
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < kiItems.length; i++) {
|
|
||||||
const r = kiItems[i];
|
|
||||||
const source = { fileName, index: i };
|
|
||||||
let item: ParsedBookingItem | null = null;
|
|
||||||
|
|
||||||
switch (r['@type']) {
|
|
||||||
case 'FlightReservation': item = mapFlight(r, source); break;
|
|
||||||
case 'TrainReservation': item = mapTrain(r, source); break;
|
|
||||||
case 'BusReservation': item = mapBus(r, source); break;
|
|
||||||
case 'BoatReservation': item = mapBoat(r, source); break;
|
|
||||||
case 'LodgingReservation': item = mapLodging(r, source); break;
|
|
||||||
case 'FoodEstablishmentReservation': item = mapFood(r, source); break;
|
|
||||||
case 'RentalCarReservation': item = mapRentalCar(r, source); break;
|
|
||||||
case 'EventReservation':
|
|
||||||
case 'TouristAttractionVisit': item = mapEvent(r, source); break;
|
|
||||||
default:
|
|
||||||
warnings.push(`Unknown type "${r['@type']}" in ${fileName}[${i}] — skipped`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item) items.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items, warnings };
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
/** KItinerary JSON-LD output types (schema.org subset) */
|
|
||||||
|
|
||||||
/** KDE's custom date/time wrapper — used when timezone info is present */
|
|
||||||
export interface KiDateTime {
|
|
||||||
'@type': 'QDateTime';
|
|
||||||
'@value': string; // ISO 8601 local time (KDE serializes as @value)
|
|
||||||
timezone?: string; // IANA timezone id
|
|
||||||
}
|
|
||||||
|
|
||||||
export type KiDateTimeish = string | KiDateTime | null | undefined;
|
|
||||||
|
|
||||||
export interface KiGeo {
|
|
||||||
'@type'?: string;
|
|
||||||
latitude?: number;
|
|
||||||
longitude?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiAddress {
|
|
||||||
'@type'?: string;
|
|
||||||
streetAddress?: string;
|
|
||||||
addressLocality?: string;
|
|
||||||
postalCode?: string;
|
|
||||||
addressCountry?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiAirport {
|
|
||||||
'@type'?: string;
|
|
||||||
name?: string;
|
|
||||||
iataCode?: string;
|
|
||||||
geo?: KiGeo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiStation {
|
|
||||||
'@type'?: string;
|
|
||||||
name?: string;
|
|
||||||
geo?: KiGeo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiBusStop {
|
|
||||||
'@type'?: string;
|
|
||||||
name?: string;
|
|
||||||
geo?: KiGeo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiFlight {
|
|
||||||
'@type'?: string;
|
|
||||||
flightNumber?: string;
|
|
||||||
airline?: { name?: string; iataCode?: string };
|
|
||||||
departureAirport?: KiAirport;
|
|
||||||
arrivalAirport?: KiAirport;
|
|
||||||
departureTime?: KiDateTimeish;
|
|
||||||
arrivalTime?: KiDateTimeish;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiTrainTrip {
|
|
||||||
'@type'?: string;
|
|
||||||
trainNumber?: string;
|
|
||||||
trainName?: string;
|
|
||||||
departureStation?: KiStation;
|
|
||||||
arrivalStation?: KiStation;
|
|
||||||
departureTime?: KiDateTimeish;
|
|
||||||
arrivalTime?: KiDateTimeish;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiBusTrip {
|
|
||||||
'@type'?: string;
|
|
||||||
busNumber?: string;
|
|
||||||
busName?: string;
|
|
||||||
departureBusStop?: KiBusStop;
|
|
||||||
arrivalBusStop?: KiBusStop;
|
|
||||||
departureTime?: KiDateTimeish;
|
|
||||||
arrivalTime?: KiDateTimeish;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiBoatTrip {
|
|
||||||
'@type'?: string;
|
|
||||||
name?: string;
|
|
||||||
departureBoatTerminal?: KiStation;
|
|
||||||
arrivalBoatTerminal?: KiStation;
|
|
||||||
departureTime?: KiDateTimeish;
|
|
||||||
arrivalTime?: KiDateTimeish;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiLodgingBusiness {
|
|
||||||
'@type'?: string;
|
|
||||||
name?: string;
|
|
||||||
address?: string | KiAddress;
|
|
||||||
geo?: KiGeo;
|
|
||||||
telephone?: string;
|
|
||||||
url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiFoodEstablishment {
|
|
||||||
'@type'?: string;
|
|
||||||
name?: string;
|
|
||||||
address?: string | KiAddress;
|
|
||||||
geo?: KiGeo;
|
|
||||||
telephone?: string;
|
|
||||||
url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiRentalCar {
|
|
||||||
'@type'?: string;
|
|
||||||
name?: string;
|
|
||||||
model?: string;
|
|
||||||
make?: string;
|
|
||||||
rentalCompany?: { name?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiEventVenue {
|
|
||||||
'@type'?: string;
|
|
||||||
name?: string;
|
|
||||||
address?: string | KiAddress;
|
|
||||||
geo?: KiGeo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KiEvent {
|
|
||||||
'@type'?: string;
|
|
||||||
name?: string;
|
|
||||||
startDate?: KiDateTimeish;
|
|
||||||
endDate?: KiDateTimeish;
|
|
||||||
location?: KiEventVenue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single output node from kitinerary-extractor's JSON array */
|
|
||||||
export interface KiReservation {
|
|
||||||
'@type': string;
|
|
||||||
reservationNumber?: string;
|
|
||||||
checkinTime?: KiDateTimeish;
|
|
||||||
checkoutTime?: KiDateTimeish;
|
|
||||||
pickupTime?: KiDateTimeish;
|
|
||||||
dropoffTime?: KiDateTimeish;
|
|
||||||
startTime?: KiDateTimeish;
|
|
||||||
endTime?: KiDateTimeish;
|
|
||||||
reservationFor?: Record<string, unknown>;
|
|
||||||
pickupLocation?: KiEventVenue;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Endpoint row shape (matches reservation_endpoints table) */
|
|
||||||
export interface ParsedEndpoint {
|
|
||||||
role: 'from' | 'to' | 'stop';
|
|
||||||
sequence: number;
|
|
||||||
name: string;
|
|
||||||
code: string | null;
|
|
||||||
lat: number;
|
|
||||||
lng: number;
|
|
||||||
timezone: string | null;
|
|
||||||
local_time: string | null;
|
|
||||||
local_date: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Venue used to auto-create a places row on confirm */
|
|
||||||
export interface ParsedVenue {
|
|
||||||
name: string;
|
|
||||||
lat?: number;
|
|
||||||
lng?: number;
|
|
||||||
address?: string;
|
|
||||||
website?: string;
|
|
||||||
phone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hotel accommodation side-effect data */
|
|
||||||
export interface ParsedAccommodation {
|
|
||||||
check_in?: string;
|
|
||||||
check_out?: string;
|
|
||||||
confirmation?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parsed reservation preview item — sent to the frontend and passed back on confirm.
|
|
||||||
* Carries everything createReservation() needs plus _venue / _accommodation for
|
|
||||||
* server-side side effects, and source for the preview UI.
|
|
||||||
*/
|
|
||||||
export interface ParsedBookingItem {
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
reservation_time?: string | null;
|
|
||||||
reservation_end_time?: string | null;
|
|
||||||
confirmation_number?: string | null;
|
|
||||||
location?: string | null;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
endpoints?: ParsedEndpoint[];
|
|
||||||
needs_review?: boolean;
|
|
||||||
_venue?: ParsedVenue;
|
|
||||||
_accommodation?: ParsedAccommodation;
|
|
||||||
source: { fileName: string; index: number };
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { User } from '../../types';
|
import type { User } from '../../types';
|
||||||
@@ -58,56 +57,9 @@ export class BudgetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('settlement')
|
@Get('settlement')
|
||||||
settlement(
|
settlement(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||||
@CurrentUser() user: User,
|
|
||||||
@Param('tripId') tripId: string,
|
|
||||||
@Query('base') base?: string,
|
|
||||||
) {
|
|
||||||
const trip = this.requireTrip(tripId, user);
|
|
||||||
return this.budget.settlement(tripId, base, (trip as { currency?: string }).currency || 'EUR');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('settlements')
|
|
||||||
listSettlements(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
|
||||||
this.requireTrip(tripId, user);
|
this.requireTrip(tripId, user);
|
||||||
return { settlements: this.budget.listSettlements(tripId) };
|
return this.budget.settlement(tripId);
|
||||||
}
|
|
||||||
|
|
||||||
@Post('settlements')
|
|
||||||
createSettlement(
|
|
||||||
@CurrentUser() user: User,
|
|
||||||
@Param('tripId') tripId: string,
|
|
||||||
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number },
|
|
||||||
@Headers('x-socket-id') socketId?: string,
|
|
||||||
) {
|
|
||||||
const trip = this.requireTrip(tripId, user);
|
|
||||||
this.requireEdit(trip, user);
|
|
||||||
if (body.from_user_id == null || body.to_user_id == null || body.amount == null) {
|
|
||||||
throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400);
|
|
||||||
}
|
|
||||||
const settlement = this.budget.createSettlement(
|
|
||||||
tripId,
|
|
||||||
{ from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount },
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
this.budget.broadcast(tripId, 'budget:settlement-created', { settlement }, socketId);
|
|
||||||
return { settlement };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('settlements/:settlementId')
|
|
||||||
deleteSettlement(
|
|
||||||
@CurrentUser() user: User,
|
|
||||||
@Param('tripId') tripId: string,
|
|
||||||
@Param('settlementId') settlementId: string,
|
|
||||||
@Headers('x-socket-id') socketId?: string,
|
|
||||||
) {
|
|
||||||
const trip = this.requireTrip(tripId, user);
|
|
||||||
this.requireEdit(trip, user);
|
|
||||||
if (!this.budget.deleteSettlement(settlementId, tripId)) {
|
|
||||||
throw new HttpException({ error: 'Settlement not found' }, 404);
|
|
||||||
}
|
|
||||||
this.budget.broadcast(tripId, 'budget:settlement-deleted', { settlementId: Number(settlementId) }, socketId);
|
|
||||||
return { success: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -197,27 +149,6 @@ export class BudgetController {
|
|||||||
return { members: result.members, item: result.item };
|
return { members: result.members, item: result.item };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/payers')
|
|
||||||
setPayers(
|
|
||||||
@CurrentUser() user: User,
|
|
||||||
@Param('tripId') tripId: string,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body('payers') payers: unknown,
|
|
||||||
@Headers('x-socket-id') socketId?: string,
|
|
||||||
) {
|
|
||||||
const trip = this.requireTrip(tripId, user);
|
|
||||||
this.requireEdit(trip, user);
|
|
||||||
if (!Array.isArray(payers)) {
|
|
||||||
throw new HttpException({ error: 'payers must be an array' }, 400);
|
|
||||||
}
|
|
||||||
const item = this.budget.setPayers(id, tripId, payers as { user_id: number; amount: number }[]);
|
|
||||||
if (!item) {
|
|
||||||
throw new HttpException({ error: 'Budget item not found' }, 404);
|
|
||||||
}
|
|
||||||
this.budget.broadcast(tripId, 'budget:updated', { item }, socketId);
|
|
||||||
return { item };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put(':id/members/:userId/paid')
|
@Put(':id/members/:userId/paid')
|
||||||
toggleMemberPaid(
|
toggleMemberPaid(
|
||||||
@CurrentUser() user: User,
|
@CurrentUser() user: User,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { broadcast } from '../../websocket';
|
|||||||
import { checkPermission } from '../../services/permissions';
|
import { checkPermission } from '../../services/permissions';
|
||||||
import type { User } from '../../types';
|
import type { User } from '../../types';
|
||||||
import * as svc from '../../services/budgetService';
|
import * as svc from '../../services/budgetService';
|
||||||
import { getRates } from '../../services/exchangeRateService';
|
|
||||||
|
|
||||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||||
|
|
||||||
@@ -35,10 +34,8 @@ export class BudgetService {
|
|||||||
return svc.getPerPersonSummary(tripId);
|
return svc.getPerPersonSummary(tripId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async settlement(tripId: string, base: string | undefined, tripCurrency: string) {
|
settlement(tripId: string) {
|
||||||
const effectiveBase = (base || tripCurrency || 'EUR').toUpperCase();
|
return svc.calculateSettlement(tripId);
|
||||||
const rates = await getRates(effectiveBase);
|
|
||||||
return svc.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
create(tripId: string, data: Parameters<typeof svc.createBudgetItem>[1]) {
|
create(tripId: string, data: Parameters<typeof svc.createBudgetItem>[1]) {
|
||||||
@@ -61,22 +58,6 @@ export class BudgetService {
|
|||||||
return svc.toggleMemberPaid(id, userId, paid);
|
return svc.toggleMemberPaid(id, userId, paid);
|
||||||
}
|
}
|
||||||
|
|
||||||
setPayers(id: string, tripId: string, payers: { user_id: number; amount: number }[]) {
|
|
||||||
return svc.setItemPayers(id, tripId, payers);
|
|
||||||
}
|
|
||||||
|
|
||||||
listSettlements(tripId: string) {
|
|
||||||
return svc.listSettlements(tripId);
|
|
||||||
}
|
|
||||||
|
|
||||||
createSettlement(tripId: string, data: { from_user_id: number; to_user_id: number; amount: number }, userId: number) {
|
|
||||||
return svc.createSettlement(tripId, data, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteSettlement(id: string, tripId: string): boolean {
|
|
||||||
return svc.deleteSettlement(id, tripId);
|
|
||||||
}
|
|
||||||
|
|
||||||
reorderItems(tripId: string, orderedIds: number[]): void {
|
reorderItems(tripId: string, orderedIds: number[]): void {
|
||||||
svc.reorderBudgetItems(tripId, orderedIds);
|
svc.reorderBudgetItems(tripId, orderedIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export class JourneyController {
|
|||||||
// ── Share Link ──────────────────────────────────────────────────────────
|
// ── Share Link ──────────────────────────────────────────────────────────
|
||||||
@Get(':id/share-link')
|
@Get(':id/share-link')
|
||||||
getShareLink(@CurrentUser() user: User, @Param('id') id: string) {
|
getShareLink(@CurrentUser() user: User, @Param('id') id: string) {
|
||||||
return { link: this.journey.getJourneyShareLink(Number(id), user.id) };
|
return { link: this.journey.getJourneyShareLink(Number(id)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/share-link')
|
@Post(':id/share-link')
|
||||||
|
|||||||
@@ -61,13 +61,7 @@ export class JourneyService {
|
|||||||
removeContributor(id: number, userId: number, targetUserId: number) { return svc.removeContributor(id, userId, targetUserId); }
|
removeContributor(id: number, userId: number, targetUserId: number) { return svc.removeContributor(id, userId, targetUserId); }
|
||||||
|
|
||||||
// Share links
|
// Share links
|
||||||
// Authorization: only someone with access to the journey may read its public
|
getJourneyShareLink(id: number) { return share.getJourneyShareLink(id); }
|
||||||
// share token — same access model as create/delete here and the
|
|
||||||
// get_journey_share_link MCP tool.
|
|
||||||
getJourneyShareLink(id: number, userId: number) {
|
|
||||||
if (!svc.canAccessJourney(id, userId)) return null;
|
|
||||||
return share.getJourneyShareLink(id);
|
|
||||||
}
|
|
||||||
createOrUpdateJourneyShareLink(id: number, userId: number, data: Parameters<typeof share.createOrUpdateJourneyShareLink>[2]) { return share.createOrUpdateJourneyShareLink(id, userId, data); }
|
createOrUpdateJourneyShareLink(id: number, userId: number, data: Parameters<typeof share.createOrUpdateJourneyShareLink>[2]) { return share.createOrUpdateJourneyShareLink(id, userId, data); }
|
||||||
deleteJourneyShareLink(id: number, userId: number) { return share.deleteJourneyShareLink(id, userId); }
|
deleteJourneyShareLink(id: number, userId: number) { return share.deleteJourneyShareLink(id, userId); }
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { authenticator } from 'otplib';
|
|||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { randomBytes, createHash } from 'crypto';
|
import { randomBytes, createHash } from 'crypto';
|
||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config';
|
import { JWT_SECRET } from '../config';
|
||||||
import { validatePassword } from './passwordPolicy';
|
import { validatePassword } from './passwordPolicy';
|
||||||
import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
|
import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
|
||||||
import { getAllPermissions } from './permissions';
|
import { getAllPermissions } from './permissions';
|
||||||
@@ -21,7 +21,6 @@ import { verifyJwtAndLoadUser } from '../middleware/auth';
|
|||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||||
import { avatarUrl } from './avatarUrl';
|
import { avatarUrl } from './avatarUrl';
|
||||||
import { isPasskeyConfigured } from './webauthnConfig';
|
|
||||||
|
|
||||||
export { avatarUrl };
|
export { avatarUrl };
|
||||||
|
|
||||||
@@ -52,7 +51,6 @@ const ADMIN_SETTINGS_KEYS = [
|
|||||||
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
|
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
|
||||||
'notify_trip_reminder',
|
'notify_trip_reminder',
|
||||||
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
||||||
'passkey_login', 'webauthn_rp_id', 'webauthn_origins',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||||
@@ -130,17 +128,10 @@ export function resolveAuthToggles(): {
|
|||||||
password_registration: boolean;
|
password_registration: boolean;
|
||||||
oidc_login: boolean;
|
oidc_login: boolean;
|
||||||
oidc_registration: boolean;
|
oidc_registration: boolean;
|
||||||
passkey_login: boolean;
|
|
||||||
} {
|
} {
|
||||||
const get = (key: string) =>
|
const get = (key: string) =>
|
||||||
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value ?? null;
|
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value ?? null;
|
||||||
|
|
||||||
// Passkey login is independent of the password/OIDC "new keys" probe, so it
|
|
||||||
// must be resolved OUTSIDE the branch below — otherwise on a fresh install
|
|
||||||
// that never touched the password/OIDC toggles it would silently read false
|
|
||||||
// even after an admin enabled it. Default OFF (opt-in).
|
|
||||||
const passkey_login = get('passkey_login') === 'true';
|
|
||||||
|
|
||||||
const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration']
|
const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration']
|
||||||
.some(k => get(k) !== null);
|
.some(k => get(k) !== null);
|
||||||
|
|
||||||
@@ -150,7 +141,6 @@ export function resolveAuthToggles(): {
|
|||||||
password_registration: get('password_registration') !== 'false',
|
password_registration: get('password_registration') !== 'false',
|
||||||
oidc_login: get('oidc_login') !== 'false',
|
oidc_login: get('oidc_login') !== 'false',
|
||||||
oidc_registration: get('oidc_registration') !== 'false',
|
oidc_registration: get('oidc_registration') !== 'false',
|
||||||
passkey_login,
|
|
||||||
};
|
};
|
||||||
if (process.env.OIDC_ONLY?.toLowerCase() === 'true') {
|
if (process.env.OIDC_ONLY?.toLowerCase() === 'true') {
|
||||||
result.password_login = false;
|
result.password_login = false;
|
||||||
@@ -173,7 +163,6 @@ export function resolveAuthToggles(): {
|
|||||||
password_registration: !oidcOnly && allowReg,
|
password_registration: !oidcOnly && allowReg,
|
||||||
oidc_login: true,
|
oidc_login: true,
|
||||||
oidc_registration: allowReg,
|
oidc_registration: allowReg,
|
||||||
passkey_login,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +177,7 @@ export function generateToken(user: { id: number | bigint; password_version?: nu
|
|||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{ id: user.id, pv },
|
{ id: user.id, pv },
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' }
|
{ expiresIn: '24h', algorithm: 'HS256' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,12 +299,6 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
|||||||
password_registration: isDemo ? false : toggles.password_registration,
|
password_registration: isDemo ? false : toggles.password_registration,
|
||||||
oidc_login: toggles.oidc_login,
|
oidc_login: toggles.oidc_login,
|
||||||
oidc_registration: isDemo ? false : toggles.oidc_registration,
|
oidc_registration: isDemo ? false : toggles.oidc_registration,
|
||||||
// Passkey login: the instance toggle + whether a usable RP ID resolves for
|
|
||||||
// this deployment. The login page shows the passkey button only when both
|
|
||||||
// are true. `passkey_configured` stays a pure boolean — it never leaks the
|
|
||||||
// resolved RP ID / origin / APP_URL on this unauthenticated endpoint.
|
|
||||||
passkey_login: toggles.passkey_login,
|
|
||||||
passkey_configured: isPasskeyConfigured(),
|
|
||||||
env_override_oidc_only: process.env.OIDC_ONLY === 'true',
|
env_override_oidc_only: process.env.OIDC_ONLY === 'true',
|
||||||
has_users: userCount > 0,
|
has_users: userCount > 0,
|
||||||
setup_complete: setupComplete,
|
setup_complete: setupComplete,
|
||||||
@@ -829,12 +812,9 @@ export function updateAppSettings(
|
|||||||
const { require_mfa } = body;
|
const { require_mfa } = body;
|
||||||
if (require_mfa === true || require_mfa === 'true') {
|
if (require_mfa === true || require_mfa === 'true') {
|
||||||
const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined;
|
const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined;
|
||||||
// A user-verified passkey satisfies the MFA policy, so an admin who secured
|
if (!(adminMfa?.mfa_enabled === 1)) {
|
||||||
// their own account with a passkey may enable it too (not only TOTP).
|
|
||||||
const adminHasPasskey = !!db.prepare('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1').get(userId);
|
|
||||||
if (!(adminMfa?.mfa_enabled === 1) && !adminHasPasskey) {
|
|
||||||
return {
|
return {
|
||||||
error: 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.',
|
error: 'Enable two-factor authentication on your own account before requiring it for all users.',
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../types';
|
import { BudgetItem, BudgetItemMember } from '../types';
|
||||||
import { avatarUrl } from './avatarUrl';
|
import { avatarUrl } from './avatarUrl';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -19,30 +19,6 @@ function loadItemMembers(itemId: number | string) {
|
|||||||
return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) }));
|
return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadItemPayers(itemId: number | string) {
|
|
||||||
const rows = db.prepare(`
|
|
||||||
SELECT bp.user_id, bp.amount, u.username, u.avatar
|
|
||||||
FROM budget_item_payers bp
|
|
||||||
JOIN users u ON bp.user_id = u.id
|
|
||||||
WHERE bp.budget_item_id = ?
|
|
||||||
`).all(itemId) as BudgetItemPayer[];
|
|
||||||
return rows.map(p => ({ ...p, avatar_url: avatarUrl(p) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replace the payer rows of an item and keep total_price = sum of payer amounts. */
|
|
||||||
function writeItemPayers(itemId: number | string, payers: { user_id: number; amount: number }[]) {
|
|
||||||
db.prepare('DELETE FROM budget_item_payers WHERE budget_item_id = ?').run(itemId);
|
|
||||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)');
|
|
||||||
let total = 0;
|
|
||||||
for (const p of payers) {
|
|
||||||
if (!(p.amount > 0)) continue;
|
|
||||||
insert.run(itemId, p.user_id, p.amount);
|
|
||||||
total += p.amount;
|
|
||||||
}
|
|
||||||
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(total, itemId);
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CRUD
|
// CRUD
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -74,45 +50,20 @@ export function listBudgetItems(tripId: string | number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const payersByItem: Record<number, (BudgetItemPayer & { avatar_url: string | null })[]> = {};
|
items.forEach(item => { item.members = membersByItem[item.id] || []; });
|
||||||
if (itemIds.length > 0) {
|
|
||||||
const allPayers = db.prepare(`
|
|
||||||
SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar
|
|
||||||
FROM budget_item_payers bp
|
|
||||||
JOIN users u ON bp.user_id = u.id
|
|
||||||
WHERE bp.budget_item_id IN (${itemIds.map(() => '?').join(',')})
|
|
||||||
`).all(...itemIds) as (BudgetItemPayer & { budget_item_id: number })[];
|
|
||||||
|
|
||||||
for (const p of allPayers) {
|
|
||||||
if (!payersByItem[p.budget_item_id]) payersByItem[p.budget_item_id] = [];
|
|
||||||
payersByItem[p.budget_item_id].push({
|
|
||||||
user_id: p.user_id, amount: p.amount, username: p.username, avatar_url: avatarUrl(p),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items.forEach(item => {
|
|
||||||
item.members = membersByItem[item.id] || [];
|
|
||||||
item.payers = payersByItem[item.id] || [];
|
|
||||||
});
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBudgetItem(
|
export function createBudgetItem(
|
||||||
tripId: string | number,
|
tripId: string | number,
|
||||||
data: {
|
data: { category?: string; name: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
|
||||||
category?: string; name: string; total_price?: number;
|
|
||||||
currency?: string | null; exchange_rate?: number;
|
|
||||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
|
||||||
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
const maxOrder = db.prepare(
|
const maxOrder = db.prepare(
|
||||||
'SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?'
|
'SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?'
|
||||||
).get(tripId) as { max: number | null };
|
).get(tripId) as { max: number | null };
|
||||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||||
|
|
||||||
const cat = data.category || 'other';
|
const cat = data.category || 'Other';
|
||||||
|
|
||||||
// Ensure category has a sort_order entry
|
// Ensure category has a sort_order entry
|
||||||
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, cat);
|
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, cat);
|
||||||
@@ -122,37 +73,22 @@ export function createBudgetItem(
|
|||||||
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, cat, catOrder);
|
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, cat, catOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
// total_price is derived from explicit payers when given; otherwise the caller
|
|
||||||
// value (planning entries, or a bill no one has paid yet).
|
|
||||||
const payerTotal = (data.payers || []).reduce((a, p) => a + (p.amount > 0 ? p.amount : 0), 0);
|
|
||||||
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
|
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
).run(
|
).run(
|
||||||
tripId,
|
tripId,
|
||||||
cat,
|
cat,
|
||||||
data.name,
|
data.name,
|
||||||
total,
|
data.total_price || 0,
|
||||||
data.currency || null,
|
data.persons != null ? data.persons : null,
|
||||||
data.exchange_rate != null ? data.exchange_rate : 1,
|
|
||||||
data.member_ids ? data.member_ids.length : (data.persons != null ? data.persons : null),
|
|
||||||
data.days !== undefined && data.days !== null ? data.days : null,
|
data.days !== undefined && data.days !== null ? data.days : null,
|
||||||
data.note || null,
|
data.note || null,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
data.expense_date || null,
|
data.expense_date || null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemId = result.lastInsertRowid as number;
|
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] };
|
||||||
if (data.payers && data.payers.length > 0) writeItemPayers(itemId, data.payers);
|
item.members = [];
|
||||||
if (data.member_ids && data.member_ids.length > 0) {
|
|
||||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
|
|
||||||
for (const uid of data.member_ids) insert.run(itemId, uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId) as BudgetItem;
|
|
||||||
item.members = loadItemMembers(itemId);
|
|
||||||
item.payers = loadItemPayers(itemId);
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,12 +106,7 @@ export function linkBudgetItemToReservation(
|
|||||||
export function updateBudgetItem(
|
export function updateBudgetItem(
|
||||||
id: string | number,
|
id: string | number,
|
||||||
tripId: string | number,
|
tripId: string | number,
|
||||||
data: {
|
data: { category?: string; name?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null },
|
||||||
category?: string; name?: string; total_price?: number;
|
|
||||||
currency?: string | null; exchange_rate?: number;
|
|
||||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
|
||||||
persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
@@ -185,8 +116,6 @@ export function updateBudgetItem(
|
|||||||
category = COALESCE(?, category),
|
category = COALESCE(?, category),
|
||||||
name = COALESCE(?, name),
|
name = COALESCE(?, name),
|
||||||
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
|
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
|
||||||
currency = CASE WHEN ? THEN ? ELSE currency END,
|
|
||||||
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END,
|
|
||||||
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
|
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
|
||||||
days = CASE WHEN ? THEN ? ELSE days END,
|
days = CASE WHEN ? THEN ? ELSE days END,
|
||||||
note = CASE WHEN ? THEN ? ELSE note END,
|
note = CASE WHEN ? THEN ? ELSE note END,
|
||||||
@@ -197,8 +126,6 @@ export function updateBudgetItem(
|
|||||||
data.category || null,
|
data.category || null,
|
||||||
data.name || null,
|
data.name || null,
|
||||||
data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0,
|
data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0,
|
||||||
data.currency !== undefined ? 1 : 0, data.currency !== undefined ? (data.currency || null) : null,
|
|
||||||
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
|
|
||||||
data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
|
data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
|
||||||
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
|
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
|
||||||
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
|
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
|
||||||
@@ -207,15 +134,6 @@ export function updateBudgetItem(
|
|||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Optional inline payer/member replacement (the edit modal saves all at once).
|
|
||||||
if (data.payers !== undefined) writeItemPayers(id, data.payers);
|
|
||||||
if (data.member_ids !== undefined) {
|
|
||||||
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
|
||||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
|
|
||||||
for (const uid of data.member_ids) insert.run(id, uid);
|
|
||||||
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.member_ids.length || null, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If category changed, update category order table
|
// If category changed, update category order table
|
||||||
if (data.category) {
|
if (data.category) {
|
||||||
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, data.category);
|
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, data.category);
|
||||||
@@ -226,23 +144,8 @@ export function updateBudgetItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
|
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] };
|
||||||
updated.members = loadItemMembers(id);
|
updated.members = loadItemMembers(id);
|
||||||
updated.payers = loadItemPayers(id);
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Payers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function setItemPayers(id: string | number, tripId: string | number, payers: { user_id: number; amount: number }[]) {
|
|
||||||
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
|
||||||
if (!item) return null;
|
|
||||||
writeItemPayers(id, payers);
|
|
||||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
|
|
||||||
updated.members = loadItemMembers(id);
|
|
||||||
updated.payers = loadItemPayers(id);
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,65 +220,37 @@ export function getPerPersonSummary(tripId: string | number) {
|
|||||||
// Settlement calculation (greedy debt matching)
|
// Settlement calculation (greedy debt matching)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function calculateSettlement(
|
export function calculateSettlement(tripId: string | number) {
|
||||||
tripId: string | number,
|
|
||||||
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string } = {},
|
|
||||||
) {
|
|
||||||
const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
|
|
||||||
const tripCurrency = (opts.tripCurrency || base).toUpperCase();
|
|
||||||
const rates = opts.rates ?? null;
|
|
||||||
// Amount in some currency → base. Pre-rework rows store currency = NULL, which
|
|
||||||
// means "the trip's own currency". rates[X] = units of X per 1 base.
|
|
||||||
const toBase = (amount: number, itemCurrency: string | null | undefined): number => {
|
|
||||||
const cur = (itemCurrency || tripCurrency).toUpperCase();
|
|
||||||
if (cur === base || !rates) return amount;
|
|
||||||
const r = rates[cur];
|
|
||||||
return r && r > 0 ? amount / r : amount;
|
|
||||||
};
|
|
||||||
|
|
||||||
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
|
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
|
||||||
const allMembers = db.prepare(`
|
const allMembers = db.prepare(`
|
||||||
SELECT bm.budget_item_id, bm.user_id, u.username, u.avatar
|
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
|
||||||
FROM budget_item_members bm
|
FROM budget_item_members bm
|
||||||
JOIN users u ON bm.user_id = u.id
|
JOIN users u ON bm.user_id = u.id
|
||||||
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||||
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
|
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
|
||||||
const allPayers = db.prepare(`
|
|
||||||
SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar
|
|
||||||
FROM budget_item_payers bp
|
|
||||||
JOIN users u ON bp.user_id = u.id
|
|
||||||
WHERE bp.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
|
||||||
`).all(tripId) as (BudgetItemPayer & { budget_item_id: number })[];
|
|
||||||
|
|
||||||
// Net balance per user, in the requested base currency: positive = is owed
|
// Calculate net balance per user: positive = is owed money, negative = owes money
|
||||||
// money, negative = owes money. Each expense's amounts are converted from their
|
|
||||||
// own currency to the base with live rates, so mixed-currency trips net correctly.
|
|
||||||
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
|
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
|
||||||
const ensure = (id: number, src: { username?: string; avatar?: string | null }) => {
|
|
||||||
if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), balance: 0 };
|
|
||||||
return balances[id];
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
||||||
const payers = allPayers.filter(p => p.budget_item_id === item.id);
|
if (members.length === 0) continue;
|
||||||
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
|
|
||||||
|
|
||||||
const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency), 0);
|
const payers = members.filter(m => m.paid);
|
||||||
const sharePerMember = paidBase / members.length;
|
if (payers.length === 0) continue; // no one marked as paid
|
||||||
|
|
||||||
// Payers are credited what they actually paid (converted to base)…
|
const sharePerMember = item.total_price / members.length;
|
||||||
for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency);
|
const paidPerPayer = item.total_price / payers.length;
|
||||||
// …and every split participant owes an equal share of the base total.
|
|
||||||
for (const m of members) ensure(m.user_id, m).balance -= sharePerMember;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
|
for (const m of members) {
|
||||||
// the receiver's credit shrinks, so the corresponding flow disappears.
|
if (!balances[m.user_id]) {
|
||||||
const settlements = listSettlements(tripId);
|
balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
|
||||||
for (const s of settlements) {
|
}
|
||||||
if (balances[s.from_user_id]) balances[s.from_user_id].balance += s.amount;
|
// Everyone owes their share
|
||||||
if (balances[s.to_user_id]) balances[s.to_user_id].balance -= s.amount;
|
balances[m.user_id].balance -= sharePerMember;
|
||||||
|
// Payers get credited what they paid
|
||||||
|
if (m.paid) balances[m.user_id].balance += paidPerPayer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate optimized payment flows (greedy algorithm)
|
// Calculate optimized payment flows (greedy algorithm)
|
||||||
@@ -408,52 +283,9 @@ export function calculateSettlement(
|
|||||||
return {
|
return {
|
||||||
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
|
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
|
||||||
flows,
|
flows,
|
||||||
settlements,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Settlements (persisted settle-up transfers — history + undo)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function listSettlements(tripId: string | number) {
|
|
||||||
const rows = db.prepare(`
|
|
||||||
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.created_at, s.created_by_user_id,
|
|
||||||
fu.username AS from_username, fu.avatar AS from_avatar,
|
|
||||||
tu.username AS to_username, tu.avatar AS to_avatar
|
|
||||||
FROM budget_settlements s
|
|
||||||
JOIN users fu ON s.from_user_id = fu.id
|
|
||||||
JOIN users tu ON s.to_user_id = tu.id
|
|
||||||
WHERE s.trip_id = ?
|
|
||||||
ORDER BY s.created_at DESC, s.id DESC
|
|
||||||
`).all(tripId) as any[];
|
|
||||||
return rows.map(r => ({
|
|
||||||
id: r.id, trip_id: r.trip_id,
|
|
||||||
from_user_id: r.from_user_id, to_user_id: r.to_user_id,
|
|
||||||
amount: r.amount, created_at: r.created_at, created_by_user_id: r.created_by_user_id,
|
|
||||||
from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }),
|
|
||||||
to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSettlement(
|
|
||||||
tripId: string | number,
|
|
||||||
data: { from_user_id: number; to_user_id: number; amount: number },
|
|
||||||
createdByUserId?: number,
|
|
||||||
) {
|
|
||||||
const result = db.prepare(
|
|
||||||
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, created_by_user_id) VALUES (?, ?, ?, ?, ?)'
|
|
||||||
).run(tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, createdByUserId ?? null);
|
|
||||||
return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteSettlement(id: string | number, tripId: string | number): boolean {
|
|
||||||
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
|
||||||
if (!row) return false;
|
|
||||||
db.prepare('DELETE FROM budget_settlements WHERE id = ?').run(id);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Reorder
|
// Reorder
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { SESSION_DURATION_MS } from '../config';
|
|
||||||
|
|
||||||
const COOKIE_NAME = 'trek_session';
|
const COOKIE_NAME = 'trek_session';
|
||||||
|
|
||||||
@@ -33,7 +32,7 @@ function buildOptions(clear: boolean, secure: boolean) {
|
|||||||
secure,
|
secure,
|
||||||
sameSite: 'lax' as const,
|
sameSite: 'lax' as const,
|
||||||
path: '/',
|
path: '/',
|
||||||
...(clear ? {} : { maxAge: SESSION_DURATION_MS }), // matches the JWT expiry (SESSION_DURATION)
|
...(clear ? {} : { maxAge: 24 * 60 * 60 * 1000 }), // 24h — matches JWT expiry
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* Live exchange rates for the Costs/Budget money conversion.
|
|
||||||
*
|
|
||||||
* Fetches from exchangerate-api.com (no key, already CSP-allowlisted for the
|
|
||||||
* dashboard widget) and caches per base currency in-memory for a few hours so a
|
|
||||||
* settlement request never hammers the upstream. Rates are "units of X per 1
|
|
||||||
* base", so an amount in currency C converts to base as `amount / rates[C]`.
|
|
||||||
*
|
|
||||||
* Everything degrades gracefully: if the fetch fails (offline, upstream down),
|
|
||||||
* callers get `null`/identity conversion and amounts are treated as already in
|
|
||||||
* the base currency rather than throwing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TTL_MS = 6 * 60 * 60 * 1000; // 6h
|
|
||||||
const cache = new Map<string, { rates: Record<string, number>; ts: number }>();
|
|
||||||
const inflight = new Map<string, Promise<Record<string, number> | null>>();
|
|
||||||
|
|
||||||
async function fetchRates(base: string): Promise<Record<string, number> | null> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(base)}`);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const data = (await res.json()) as { rates?: Record<string, number> };
|
|
||||||
return data.rates && typeof data.rates === 'object' ? data.rates : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rates map for `base` (cached). Returns null if unavailable. */
|
|
||||||
export async function getRates(base: string): Promise<Record<string, number> | null> {
|
|
||||||
const key = (base || 'EUR').toUpperCase();
|
|
||||||
const hit = cache.get(key);
|
|
||||||
const now = Date.now();
|
|
||||||
if (hit && now - hit.ts < TTL_MS) return hit.rates;
|
|
||||||
|
|
||||||
// Coalesce concurrent fetches for the same base.
|
|
||||||
let p = inflight.get(key);
|
|
||||||
if (!p) {
|
|
||||||
p = fetchRates(key).then(rates => {
|
|
||||||
if (rates) cache.set(key, { rates, ts: Date.now() });
|
|
||||||
inflight.delete(key);
|
|
||||||
return rates;
|
|
||||||
});
|
|
||||||
inflight.set(key, p);
|
|
||||||
}
|
|
||||||
const rates = await p;
|
|
||||||
// On failure fall back to the last cached value if we have one.
|
|
||||||
if (!rates && hit) return hit.rates;
|
|
||||||
return rates;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert `amount` from `from` currency into `base` using a rates map obtained
|
|
||||||
* from getRates(base). Identity when same currency or the rate is missing.
|
|
||||||
*/
|
|
||||||
export function convertWithRates(
|
|
||||||
amount: number,
|
|
||||||
from: string | null | undefined,
|
|
||||||
base: string,
|
|
||||||
rates: Record<string, number> | null,
|
|
||||||
): number {
|
|
||||||
const fromCur = (from || base).toUpperCase();
|
|
||||||
const baseCur = base.toUpperCase();
|
|
||||||
if (fromCur === baseCur || !rates) return amount;
|
|
||||||
const r = rates[fromCur];
|
|
||||||
if (!r || r <= 0) return amount;
|
|
||||||
return amount / r;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config';
|
import { JWT_SECRET } from '../config';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import { decrypt_api_key } from './apiKeyCrypto';
|
import { decrypt_api_key } from './apiKeyCrypto';
|
||||||
import { resolveAuthToggles } from './authService';
|
import { resolveAuthToggles } from './authService';
|
||||||
@@ -200,7 +200,7 @@ export function frontendUrl(path: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function generateToken(user: { id: number }): string {
|
export function generateToken(user: { id: number }): string {
|
||||||
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' });
|
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,364 +0,0 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
|
||||||
import {
|
|
||||||
generateRegistrationOptions,
|
|
||||||
verifyRegistrationResponse,
|
|
||||||
generateAuthenticationOptions,
|
|
||||||
verifyAuthenticationResponse,
|
|
||||||
type AuthenticatorTransportFuture,
|
|
||||||
} from '@simplewebauthn/server';
|
|
||||||
import { db } from '../db/database';
|
|
||||||
import { resolveWebauthnConfig } from './webauthnConfig';
|
|
||||||
import { generateToken, stripUserForClient, avatarUrl } from './authService';
|
|
||||||
import type { User } from '../types';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Short single-use challenge lifetime — a ceremony is a few seconds of user
|
|
||||||
// interaction. Kept tight so a stray row can't be replayed and the table can't
|
|
||||||
// accumulate. Mirrors the spirit of the OIDC state TTL.
|
|
||||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
// Pinned COSE algorithms: EdDSA (-8), ES256 (-7), RS256 (-257). We never want a
|
|
||||||
// future library default to silently widen what we accept.
|
|
||||||
const SUPPORTED_ALGORITHM_IDS = [-8, -7, -257];
|
|
||||||
|
|
||||||
const NOT_CONFIGURED = { error: 'Passkey login is not configured for this server.', status: 400 } as const;
|
|
||||||
// One generic message for every authentication failure so the endpoint can't be
|
|
||||||
// used to tell "no such credential" apart from "bad signature" (CWE-203).
|
|
||||||
const AUTH_FAILED = { error: 'Authentication failed', status: 401 } as const;
|
|
||||||
|
|
||||||
interface CredentialRow {
|
|
||||||
id: number;
|
|
||||||
user_id: number;
|
|
||||||
credential_id: string;
|
|
||||||
public_key: Buffer;
|
|
||||||
counter: number;
|
|
||||||
transports: string | null;
|
|
||||||
device_type: string | null;
|
|
||||||
backed_up: number;
|
|
||||||
name: string | null;
|
|
||||||
aaguid: string | null;
|
|
||||||
created_at: string;
|
|
||||||
last_used_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Challenge store (DB-backed, single-use, TTL'd)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function purgeExpiredChallenges(now: number): void {
|
|
||||||
db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ?').run(now);
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', now: number): void {
|
|
||||||
db.prepare('INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) VALUES (?, ?, ?, ?)')
|
|
||||||
.run(challenge, userId, type, now + CHALLENGE_TTL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomically claim a challenge by its EXACT bytes + type. This is a single
|
|
||||||
* DELETE ... RETURNING statement that runs BEFORE any async verification, so a
|
|
||||||
* concurrent double-submit of the same assertion can never spend one challenge
|
|
||||||
* twice (the replay window a SELECT→await→DELETE ordering would open).
|
|
||||||
*/
|
|
||||||
function claimChallenge(challenge: string, type: 'registration' | 'authentication', now: number): { user_id: number | null } | null {
|
|
||||||
const row = db.prepare(
|
|
||||||
'DELETE FROM webauthn_challenges WHERE challenge = ? AND type = ? AND expires_at > ? RETURNING user_id',
|
|
||||||
).get(challenge, type, now) as { user_id: number | null } | undefined;
|
|
||||||
return row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decode the challenge the authenticator echoed back inside clientDataJSON. */
|
|
||||||
function challengeFromResponse(resp: unknown): string | null {
|
|
||||||
try {
|
|
||||||
const cdj = (resp as { response?: { clientDataJSON?: unknown } })?.response?.clientDataJSON;
|
|
||||||
if (typeof cdj !== 'string') return null;
|
|
||||||
const parsed = JSON.parse(Buffer.from(cdj, 'base64url').toString('utf8')) as { challenge?: unknown };
|
|
||||||
return typeof parsed.challenge === 'string' ? parsed.challenge : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTransports(raw: string | null): AuthenticatorTransportFuture[] | undefined {
|
|
||||||
if (!raw) return undefined;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? (parsed as AuthenticatorTransportFuture[]) : undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeName(raw: unknown): string | null {
|
|
||||||
if (typeof raw !== 'string') return null;
|
|
||||||
const trimmed = raw.trim().slice(0, 60);
|
|
||||||
return trimmed || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultCredentialName(deviceType: string | undefined): string {
|
|
||||||
return deviceType === 'multiDevice' ? 'Passkey (synced)' : 'Passkey';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Registration (authenticated — from Settings, password re-auth required)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function passkeyRegisterOptions(
|
|
||||||
userId: number,
|
|
||||||
password: string | undefined,
|
|
||||||
): Promise<{ error?: string; status?: number; options?: Awaited<ReturnType<typeof generateRegistrationOptions>> }> {
|
|
||||||
const cfg = resolveWebauthnConfig();
|
|
||||||
if (!cfg) return { ...NOT_CONFIGURED };
|
|
||||||
|
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as User | undefined;
|
|
||||||
if (!user) return { error: 'User not found', status: 404 };
|
|
||||||
|
|
||||||
// Re-authentication: a hijacked session must not be able to silently plant an
|
|
||||||
// attacker-controlled passkey. Require the current password (parity with the
|
|
||||||
// change-password / disable-MFA step-up).
|
|
||||||
if (!password || !user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
|
|
||||||
return { error: 'Incorrect password', status: 401 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = db.prepare('SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?')
|
|
||||||
.all(userId) as { credential_id: string; transports: string | null }[];
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
purgeExpiredChallenges(now);
|
|
||||||
|
|
||||||
const options = await generateRegistrationOptions({
|
|
||||||
rpName: cfg.rpName,
|
|
||||||
rpID: cfg.rpID,
|
|
||||||
userName: user.email,
|
|
||||||
userDisplayName: user.username,
|
|
||||||
userID: new TextEncoder().encode(String(user.id)),
|
|
||||||
attestationType: 'none',
|
|
||||||
// Stop the same authenticator from enrolling twice on this account.
|
|
||||||
excludeCredentials: existing.map((c) => ({ id: c.credential_id, transports: parseTransports(c.transports) })),
|
|
||||||
authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
|
|
||||||
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
|
|
||||||
});
|
|
||||||
|
|
||||||
storeChallenge(options.challenge, userId, 'registration', now);
|
|
||||||
return { options };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function passkeyRegisterVerify(
|
|
||||||
userId: number,
|
|
||||||
body: { attestationResponse?: unknown; name?: unknown },
|
|
||||||
): Promise<{ error?: string; status?: number; success?: boolean; credential?: unknown }> {
|
|
||||||
const cfg = resolveWebauthnConfig();
|
|
||||||
if (!cfg) return { ...NOT_CONFIGURED };
|
|
||||||
|
|
||||||
const resp = body?.attestationResponse;
|
|
||||||
if (!resp) return { error: 'Invalid registration response', status: 400 };
|
|
||||||
|
|
||||||
const challenge = challengeFromResponse(resp);
|
|
||||||
if (!challenge) return { error: 'Invalid registration response', status: 400 };
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const claimed = claimChallenge(challenge, 'registration', now);
|
|
||||||
if (!claimed || claimed.user_id !== userId) {
|
|
||||||
return { error: 'Registration challenge expired. Please try again.', status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
let verification;
|
|
||||||
try {
|
|
||||||
verification = await verifyRegistrationResponse({
|
|
||||||
response: resp as Parameters<typeof verifyRegistrationResponse>[0]['response'],
|
|
||||||
expectedChallenge: challenge,
|
|
||||||
expectedOrigin: cfg.origins,
|
|
||||||
expectedRPID: cfg.rpID,
|
|
||||||
requireUserVerification: true,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return { error: 'Could not register this passkey.', status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verification.verified || !verification.registrationInfo) {
|
|
||||||
return { error: 'Could not register this passkey.', status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist ONLY the values the verifier vouches for — never anything parsed
|
|
||||||
// from the raw client payload.
|
|
||||||
const { credential, credentialDeviceType, credentialBackedUp, aaguid } = verification.registrationInfo;
|
|
||||||
|
|
||||||
if (db.prepare('SELECT id FROM webauthn_credentials WHERE credential_id = ?').get(credential.id)) {
|
|
||||||
return { error: 'This passkey is already registered.', status: 409 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = sanitizeName(body?.name) || defaultCredentialName(credentialDeviceType);
|
|
||||||
try {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO webauthn_credentials
|
|
||||||
(user_id, credential_id, public_key, counter, transports, device_type, backed_up, name, aaguid, last_used_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
|
|
||||||
).run(
|
|
||||||
userId,
|
|
||||||
credential.id,
|
|
||||||
Buffer.from(credential.publicKey),
|
|
||||||
credential.counter ?? 0,
|
|
||||||
credential.transports ? JSON.stringify(credential.transports) : null,
|
|
||||||
credentialDeviceType ?? null,
|
|
||||||
credentialBackedUp ? 1 : 0,
|
|
||||||
name,
|
|
||||||
aaguid ?? null,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return { error: 'Could not register this passkey.', status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = db.prepare(
|
|
||||||
'SELECT id, name, device_type, backed_up, created_at, last_used_at FROM webauthn_credentials WHERE credential_id = ?',
|
|
||||||
).get(credential.id) as { backed_up: number } & Record<string, unknown>;
|
|
||||||
return { success: true, credential: { ...created, backed_up: created.backed_up === 1 } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Authentication (public — primary, discoverable-credential login)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function passkeyLoginOptions(): Promise<{
|
|
||||||
error?: string;
|
|
||||||
status?: number;
|
|
||||||
options?: Awaited<ReturnType<typeof generateAuthenticationOptions>>;
|
|
||||||
}> {
|
|
||||||
const cfg = resolveWebauthnConfig();
|
|
||||||
if (!cfg) return { ...NOT_CONFIGURED };
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
purgeExpiredChallenges(now);
|
|
||||||
|
|
||||||
const options = await generateAuthenticationOptions({
|
|
||||||
rpID: cfg.rpID,
|
|
||||||
userVerification: 'required',
|
|
||||||
// Empty allowCredentials → discoverable flow. The server never echoes which
|
|
||||||
// accounts have passkeys, so the endpoint can't be used to enumerate users.
|
|
||||||
});
|
|
||||||
|
|
||||||
storeChallenge(options.challenge, null, 'authentication', now);
|
|
||||||
return { options };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function passkeyLoginVerify(body: { assertionResponse?: unknown }): Promise<{
|
|
||||||
error?: string;
|
|
||||||
status?: number;
|
|
||||||
token?: string;
|
|
||||||
user?: Record<string, unknown>;
|
|
||||||
auditUserId?: number | null;
|
|
||||||
auditAction?: string;
|
|
||||||
}> {
|
|
||||||
const cfg = resolveWebauthnConfig();
|
|
||||||
if (!cfg) return { ...NOT_CONFIGURED };
|
|
||||||
|
|
||||||
const resp = body?.assertionResponse;
|
|
||||||
if (!resp) return { ...AUTH_FAILED };
|
|
||||||
|
|
||||||
const challenge = challengeFromResponse(resp);
|
|
||||||
if (!challenge) return { ...AUTH_FAILED };
|
|
||||||
|
|
||||||
// Claim the challenge (single-use) BEFORE looking anything up or verifying.
|
|
||||||
const now = Date.now();
|
|
||||||
if (!claimChallenge(challenge, 'authentication', now)) return { ...AUTH_FAILED };
|
|
||||||
|
|
||||||
const credId = (resp as { id?: unknown; rawId?: unknown }).id ?? (resp as { rawId?: unknown }).rawId;
|
|
||||||
if (typeof credId !== 'string') return { ...AUTH_FAILED };
|
|
||||||
|
|
||||||
const cred = db.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ?').get(credId) as CredentialRow | undefined;
|
|
||||||
if (!cred) return { ...AUTH_FAILED };
|
|
||||||
|
|
||||||
let verification;
|
|
||||||
try {
|
|
||||||
verification = await verifyAuthenticationResponse({
|
|
||||||
response: resp as Parameters<typeof verifyAuthenticationResponse>[0]['response'],
|
|
||||||
expectedChallenge: challenge,
|
|
||||||
expectedOrigin: cfg.origins,
|
|
||||||
expectedRPID: cfg.rpID,
|
|
||||||
requireUserVerification: true,
|
|
||||||
credential: {
|
|
||||||
id: cred.credential_id,
|
|
||||||
publicKey: new Uint8Array(cred.public_key),
|
|
||||||
counter: cred.counter,
|
|
||||||
transports: parseTransports(cred.transports),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return { ...AUTH_FAILED };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verification.verified) return { ...AUTH_FAILED };
|
|
||||||
|
|
||||||
const { newCounter } = verification.authenticationInfo;
|
|
||||||
// Clone detection only makes sense for authenticators that actually increment.
|
|
||||||
// Synced passkeys legitimately report a counter that stays 0 — never treat
|
|
||||||
// that as a clone. A regression from a previously NON-ZERO counter rejects
|
|
||||||
// THIS assertion (and is audited) but does not disable the credential.
|
|
||||||
if (cred.counter > 0 && newCounter <= cred.counter) {
|
|
||||||
return { ...AUTH_FAILED, auditUserId: cred.user_id, auditAction: 'user.passkey_clone_suspected' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(cred.user_id) as User | undefined;
|
|
||||||
if (!user) return { ...AUTH_FAILED };
|
|
||||||
|
|
||||||
// Persist the new counter + last-used and bump login bookkeeping atomically.
|
|
||||||
db.transaction(() => {
|
|
||||||
db.prepare('UPDATE webauthn_credentials SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?').run(newCounter, cred.id);
|
|
||||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
|
||||||
})();
|
|
||||||
|
|
||||||
// A user-verified passkey is phishing-resistant and inherently two-factor
|
|
||||||
// (device possession + biometric/PIN), so it mints the real session directly
|
|
||||||
// — the SAME path as password and OIDC login (no new token shape).
|
|
||||||
const token = generateToken(user);
|
|
||||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
|
||||||
return { token, user: { ...userSafe, avatar_url: avatarUrl(user) }, auditUserId: Number(user.id) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Management (authenticated, owner-scoped)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function listPasskeys(userId: number): Array<Record<string, unknown>> {
|
|
||||||
const rows = db.prepare(
|
|
||||||
'SELECT id, name, device_type, backed_up, created_at, last_used_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC',
|
|
||||||
).all(userId) as Array<{ backed_up: number } & Record<string, unknown>>;
|
|
||||||
return rows.map((r) => ({ ...r, backed_up: r.backed_up === 1 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renamePasskey(userId: number, id: string, name: unknown): { error?: string; status?: number; success?: boolean } {
|
|
||||||
const cleanName = sanitizeName(name);
|
|
||||||
if (!cleanName) return { error: 'Name is required', status: 400 };
|
|
||||||
// Ownership enforced in SQL (404 on miss, never a 403 that leaks existence).
|
|
||||||
const result = db.prepare('UPDATE webauthn_credentials SET name = ? WHERE id = ? AND user_id = ?').run(cleanName, Number(id), userId);
|
|
||||||
if (result.changes === 0) return { error: 'Passkey not found', status: 404 };
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deletePasskey(
|
|
||||||
userId: number,
|
|
||||||
id: string,
|
|
||||||
password: string | undefined,
|
|
||||||
): { error?: string; status?: number; success?: boolean } {
|
|
||||||
// Re-auth before removing a credential (a hijacked session must not be able to
|
|
||||||
// strip the victim's passkeys). Deleting is always allowed because every
|
|
||||||
// account keeps a usable password as recovery fallback — losing all passkeys
|
|
||||||
// can never lock anyone out.
|
|
||||||
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(userId) as { password_hash: string } | undefined;
|
|
||||||
if (!user || !user.password_hash || !password || !bcrypt.compareSync(password, user.password_hash)) {
|
|
||||||
return { error: 'Incorrect password', status: 401 };
|
|
||||||
}
|
|
||||||
const result = db.prepare('DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?').run(Number(id), userId);
|
|
||||||
if (result.changes === 0) return { error: 'Passkey not found', status: 404 };
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Admin: clear all of a user's passkeys (e.g. on suspected compromise). */
|
|
||||||
export function adminResetPasskeys(targetUserId: number): { error?: string; status?: number; success?: boolean; deleted?: number; email?: string } {
|
|
||||||
const target = db.prepare('SELECT id, email FROM users WHERE id = ?').get(targetUserId) as { id: number; email: string } | undefined;
|
|
||||||
if (!target) return { error: 'User not found', status: 404 };
|
|
||||||
const result = db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(targetUserId);
|
|
||||||
return { success: true, deleted: result.changes, email: target.email };
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { db } from '../db/database';
|
|
||||||
import { getAppUrl } from './notifications';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the WebAuthn Relying Party ID + allowed origins for this deployment.
|
|
||||||
*
|
|
||||||
* SECURITY: the RP ID and the allowed origins are derived ONLY from server-side
|
|
||||||
* configuration — the `webauthn_rp_id` / `webauthn_origins` admin settings (or
|
|
||||||
* the matching env vars), falling back to APP_URL. They are NEVER taken from the
|
|
||||||
* request `Host` / `X-Forwarded-Host` header: a forged forwarded host would
|
|
||||||
* otherwise let an attacker bind credentials to a domain they control, or brick
|
|
||||||
* every enrolled user. This mirrors how OIDC derives its redirect URI from
|
|
||||||
* APP_URL (oidc.controller.ts) rather than from request input.
|
|
||||||
*
|
|
||||||
* Returns null when no usable RP ID can be resolved (bare IP host, or nothing
|
|
||||||
* configured) — the feature then reports itself as "not configured" and stays
|
|
||||||
* disabled so nobody can enrol a credential bound to the wrong origin.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function getSetting(key: string): string | null {
|
|
||||||
const raw = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
|
||||||
const trimmed = raw?.trim();
|
|
||||||
return trimmed ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hostOf(url: string): string | null {
|
|
||||||
try {
|
|
||||||
return new URL(url).hostname || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** WebAuthn RP IDs must be registrable domains — never bare IP literals. */
|
|
||||||
function isIpHost(host: string): boolean {
|
|
||||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; // IPv4
|
|
||||||
if (host.includes(':')) return true; // IPv6 (hostname keeps the colons)
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WebauthnConfig {
|
|
||||||
rpID: string;
|
|
||||||
rpName: string;
|
|
||||||
/** Exact allowed origins (scheme + host + port). One in prod; localhost dev adds the Vite/API ports. */
|
|
||||||
origins: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveWebauthnConfig(): WebauthnConfig | null {
|
|
||||||
// 1. Explicit operator config always wins.
|
|
||||||
const explicitRpId = (process.env.WEBAUTHN_RP_ID || getSetting('webauthn_rp_id'))?.trim() || null;
|
|
||||||
const explicitOrigins = (process.env.WEBAUTHN_ORIGINS || getSetting('webauthn_origins') || '')
|
|
||||||
.split(',')
|
|
||||||
.map((o) => o.trim().replace(/\/+$/, ''))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const appUrl = getAppUrl();
|
|
||||||
const appHost = hostOf(appUrl);
|
|
||||||
|
|
||||||
// 2. Derive the RP ID from APP_URL when not explicitly set.
|
|
||||||
let rpID = explicitRpId;
|
|
||||||
if (!rpID && appHost && !isIpHost(appHost)) {
|
|
||||||
rpID = appHost; // a real domain, or "localhost"
|
|
||||||
}
|
|
||||||
if (!rpID) return null; // bare IP / unresolved → WebAuthn cannot be used here
|
|
||||||
|
|
||||||
// 3. Resolve the allowed origins. Explicit list wins verbatim (operator's
|
|
||||||
// responsibility). Otherwise derive a SINGLE origin from APP_URL — we never
|
|
||||||
// silently union dev localhost origins into a production allow-list.
|
|
||||||
let origins = explicitOrigins;
|
|
||||||
if (origins.length === 0) {
|
|
||||||
if (appHost) origins = [appUrl.replace(/\/+$/, '')];
|
|
||||||
if (rpID === 'localhost') {
|
|
||||||
// Dev: the browser origin is the Vite dev server (:5173), not the API port.
|
|
||||||
origins = Array.from(new Set([...origins, 'http://localhost:5173', 'http://localhost:3001']));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (origins.length === 0) return null;
|
|
||||||
|
|
||||||
return { rpID, rpName: 'TREK', origins };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when a usable RP ID resolves for this deployment (exposed as a pure boolean on app-config). */
|
|
||||||
export function isPasskeyConfigured(): boolean {
|
|
||||||
return resolveWebauthnConfig() !== null;
|
|
||||||
}
|
|
||||||
@@ -122,18 +122,13 @@ export interface BudgetItem {
|
|||||||
category: string;
|
category: string;
|
||||||
name: string;
|
name: string;
|
||||||
total_price: number;
|
total_price: number;
|
||||||
currency?: string | null;
|
|
||||||
exchange_rate?: number;
|
|
||||||
persons?: number | null;
|
persons?: number | null;
|
||||||
days?: number | null;
|
days?: number | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
reservation_id?: number | null;
|
reservation_id?: number | null;
|
||||||
paid_by_user_id?: number | null;
|
|
||||||
expense_date?: string | null;
|
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
members?: BudgetItemMember[];
|
members?: BudgetItemMember[];
|
||||||
payers?: BudgetItemPayer[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BudgetItemMember {
|
export interface BudgetItemMember {
|
||||||
@@ -145,15 +140,6 @@ export interface BudgetItemMember {
|
|||||||
budget_item_id?: number;
|
budget_item_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BudgetItemPayer {
|
|
||||||
user_id: number;
|
|
||||||
amount: number;
|
|
||||||
username?: string;
|
|
||||||
avatar_url?: string | null;
|
|
||||||
avatar?: string | null;
|
|
||||||
budget_item_id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReservationEndpoint {
|
export interface ReservationEndpoint {
|
||||||
id: number;
|
id: number;
|
||||||
reservation_id: number;
|
reservation_id: number;
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const DEFAULT_CATEGORIES = [
|
|||||||
|
|
||||||
const DEFAULT_ADDONS = [
|
const DEFAULT_ADDONS = [
|
||||||
{ id: 'packing', name: 'Packing List', description: 'Pack your bags', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
{ id: 'packing', name: 'Packing List', description: 'Pack your bags', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
||||||
{ id: 'budget', name: 'Costs', description: 'Track and split trip expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
||||||
{ id: 'documents', name: 'Documents', description: 'Manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
{ id: 'documents', name: 'Documents', description: 'Manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
||||||
{ id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 },
|
{ id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 },
|
||||||
{ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
{ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||||
@@ -262,7 +262,4 @@ export const TEST_CONFIG = {
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
SESSION_DURATION: '24h',
|
|
||||||
SESSION_DURATION_MS: 86400000,
|
|
||||||
SESSION_DURATION_SECONDS: 86400,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
SESSION_DURATION: '24h',
|
|
||||||
SESSION_DURATION_MS: 86400000,
|
|
||||||
SESSION_DURATION_SECONDS: 86400,
|
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
SESSION_DURATION: '24h',
|
|
||||||
SESSION_DURATION_MS: 86400000,
|
|
||||||
SESSION_DURATION_SECONDS: 86400,
|
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
SESSION_DURATION: '24h',
|
|
||||||
SESSION_DURATION_MS: 86400000,
|
|
||||||
SESSION_DURATION_SECONDS: 86400,
|
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
SESSION_DURATION: '24h',
|
|
||||||
SESSION_DURATION_MS: 86400000,
|
|
||||||
SESSION_DURATION_SECONDS: 86400,
|
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
SESSION_DURATION: '24h',
|
|
||||||
SESSION_DURATION_MS: 86400000,
|
|
||||||
SESSION_DURATION_SECONDS: 86400,
|
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user