mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7fa676199 | |||
| 1547258c0c | |||
| a1ad512064 | |||
| 25324108cb | |||
| 9f5d2f6488 | |||
| 40253d2fdf | |||
| 910631c1ff | |||
| 5b41cab898 | |||
| bf969ee80d | |||
| 2d413c99cf | |||
| 58c7bd831a | |||
| 8d1e7dded0 | |||
| 127a92c8f5 | |||
| b25eb18ea4 | |||
| 8410d7c4a5 |
@@ -34,4 +34,5 @@ jobs:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
only-severities: critical,high
|
||||
only-fixed: true
|
||||
exit-code: true
|
||||
|
||||
+15
-2
@@ -1,3 +1,10 @@
|
||||
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
|
||||
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
|
||||
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
|
||||
# The binary and its runtime behaviour are identical to the apt package.
|
||||
FROM golang:1.25-alpine AS gosu-build
|
||||
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
|
||||
|
||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS shared-builder
|
||||
WORKDIR /app
|
||||
@@ -44,7 +51,7 @@ COPY server/package.json ./server/
|
||||
# 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 && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then \
|
||||
@@ -60,6 +67,9 @@ RUN apt-get update && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
|
||||
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
||||
|
||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
||||
# Prevent Qt from probing for a display in headless containers.
|
||||
ENV QT_QPA_PLATFORM=offscreen
|
||||
@@ -95,5 +105,8 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
# Preflight: if the app code is missing, a volume was almost certainly mounted
|
||||
# over /app (it hides the image's node_modules + dist). Fail with actionable
|
||||
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
|
||||
# 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", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; 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"]
|
||||
|
||||
@@ -51,10 +51,10 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
@@ -90,7 +91,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### 🧳 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))
|
||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
@@ -108,6 +109,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Invite links** — one-time or reusable links with expiry
|
||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **2FA** — TOTP + backup codes
|
||||
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
|
||||
</td>
|
||||
@@ -128,13 +130,13 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
|
||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
||||
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
|
||||
- **Documents** — file attachments on trips, places, and reservations
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
|
||||
</td>
|
||||
@@ -156,8 +158,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
</td>
|
||||
@@ -191,9 +194,9 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -202,7 +205,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
</div>
|
||||
|
||||
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
|
||||
<br />
|
||||
|
||||
@@ -263,7 +266,7 @@ Then:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -311,6 +314,9 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
|
||||
|
||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
|
||||
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||
@@ -397,12 +403,12 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `DEFAULT_LANGUAGE` | 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` | `en` |
|
||||
| `DEFAULT_LANGUAGE` | 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`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
|
||||
@@ -20,14 +20,14 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isDayInAccommodationRange, getAccommodationAnchors } from '../../utils/dayOrder'
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
||||
import { RouteConnector } from './DayPlanSidebarRouteConnector'
|
||||
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
|
||||
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
||||
@@ -152,6 +152,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
||||
const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({})
|
||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||
const legsAbortRef = useRef<AbortController | null>(null)
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
@@ -379,12 +381,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
||||
useEffect(() => {
|
||||
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
|
||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); setHotelLegs({}); return }
|
||||
const merged = mergedItemsMap[selectedDayId] || []
|
||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||
let cur: { id: number; lat: number; lng: number }[] = []
|
||||
for (const it of merged) {
|
||||
@@ -392,7 +390,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||
} else if (it.type === 'transport') {
|
||||
const r = it.data
|
||||
const from = epLoc(r, 'from'), to = epLoc(r, 'to')
|
||||
const { from, to } = getTransportRouteEndpoints(r, selectedDayId)
|
||||
if (from || to) {
|
||||
// Located transport: route to its departure point, break the run (the
|
||||
// flight/train itself isn't driven), and let its arrival start the next.
|
||||
@@ -408,7 +406,33 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}
|
||||
}
|
||||
if (cur.length >= 2) runs.push(cur)
|
||||
if (runs.length === 0) { setRouteLegs({}); return }
|
||||
|
||||
// Hotel bookend legs: the drive from the day's accommodation to the first
|
||||
// located place (morning) and from the last place back to it (evening). Only
|
||||
// when the "optimize from accommodation" setting is on and the day has a hotel,
|
||||
// mirroring the range logic the optimizer itself uses (getAccommodationAnchors).
|
||||
const day = days.find(d => d.id === selectedDayId)
|
||||
const dayAccs = day && optimizeFromAccommodation !== false
|
||||
? accommodations.filter(a => a.place_lat != null && a.place_lng != null && isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||
: []
|
||||
const checkOut = day ? dayAccs.find(a => a.end_day_id === day.id) : undefined
|
||||
const checkIn = day ? dayAccs.find(a => a.start_day_id === day.id) : undefined
|
||||
const transfer = !!(checkOut && checkIn && checkOut !== checkIn)
|
||||
const startHotel = transfer ? checkOut : dayAccs[0]
|
||||
const endHotel = transfer ? checkIn : dayAccs[0]
|
||||
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
|
||||
const placePts: { lat: number; lng: number }[] = []
|
||||
for (const it of merged) {
|
||||
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||
placePts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
|
||||
}
|
||||
}
|
||||
const firstPlace = placePts[0]
|
||||
const lastPlace = placePts[placePts.length - 1]
|
||||
const wantTop = !!(startHotel && firstPlace)
|
||||
const wantBottom = !!(endHotel && lastPlace)
|
||||
|
||||
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
|
||||
|
||||
const controller = new AbortController()
|
||||
legsAbortRef.current = controller
|
||||
@@ -422,9 +446,27 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
}
|
||||
}
|
||||
if (!controller.signal.aborted) setRouteLegs(map)
|
||||
|
||||
// One extra cached OSRM call per bookend; shares RouteCalculator's cache.
|
||||
const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }): Promise<RouteSegment | undefined> => {
|
||||
try {
|
||||
const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: routeProfile })
|
||||
return r.legs[0]
|
||||
} catch { return undefined }
|
||||
}
|
||||
const hotel: { top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } } = {}
|
||||
if (wantTop) {
|
||||
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstPlace.lat, lng: firstPlace.lng })
|
||||
if (seg) hotel.top = { seg, name: hotelName(startHotel!) }
|
||||
}
|
||||
if (wantBottom) {
|
||||
const seg = await legBetween({ lat: lastPlace.lat, lng: lastPlace.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number })
|
||||
if (seg) hotel.bottom = { seg, name: hotelName(endHotel!) }
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) }
|
||||
})()
|
||||
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap])
|
||||
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation])
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
@@ -938,6 +980,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setRouteInfo,
|
||||
routeLegs,
|
||||
setRouteLegs,
|
||||
hotelLegs,
|
||||
setHotelLegs,
|
||||
legsAbortRef,
|
||||
draggingId,
|
||||
setDraggingId,
|
||||
@@ -1085,6 +1129,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
setRouteInfo,
|
||||
routeLegs,
|
||||
setRouteLegs,
|
||||
hotelLegs,
|
||||
setHotelLegs,
|
||||
legsAbortRef,
|
||||
draggingId,
|
||||
setDraggingId,
|
||||
@@ -1427,6 +1473,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||
}}
|
||||
>
|
||||
{isSelected && hotelLegs.top && (
|
||||
<HotelRouteConnector seg={hotelLegs.top.seg} name={hotelLegs.top.name} profile={routeProfile} placement="top" />
|
||||
)}
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||
@@ -2057,6 +2106,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
)
|
||||
})
|
||||
)}
|
||||
{isSelected && hotelLegs.bottom && (
|
||||
<HotelRouteConnector seg={hotelLegs.bottom.seg} name={hotelLegs.bottom.name} profile={routeProfile} placement="bottom" />
|
||||
)}
|
||||
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||
<div
|
||||
style={{ minHeight: 12, padding: '2px 8px' }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Car, Footprints } from 'lucide-react'
|
||||
import { Car, Footprints, Hotel } from 'lucide-react'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||
@@ -19,3 +19,60 @@ export function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: '
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The hotel's bookend legs for a day: a two-line connector naming the day's
|
||||
* accommodation with the drive to/from it. Rendered above the first place (the
|
||||
* morning departure from the hotel) and below the last place (the evening return),
|
||||
* when the "optimize from accommodation" setting is on and the day has a hotel.
|
||||
*/
|
||||
export function HotelRouteConnector({
|
||||
seg,
|
||||
profile,
|
||||
name,
|
||||
placement,
|
||||
}: {
|
||||
seg: RouteSegment
|
||||
profile: 'driving' | 'walking'
|
||||
name: string
|
||||
placement: 'top' | 'bottom'
|
||||
}) {
|
||||
const driving = profile === 'driving'
|
||||
const Icon = driving ? Car : Footprints
|
||||
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
|
||||
const hotelRow = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '0 14px', minWidth: 0 }}>
|
||||
<Hotel size={12} strokeWidth={1.8} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
const travelRow = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||
<div style={line} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<Icon size={11} strokeWidth={2} />
|
||||
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span>{seg.distanceText}</span>
|
||||
</div>
|
||||
<div style={line} />
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: placement === 'top' ? '2px 0 6px' : '6px 0 2px' }}>
|
||||
{placement === 'top' ? (
|
||||
<>
|
||||
{hotelRow}
|
||||
{travelRow}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{travelRow}
|
||||
{hotelRow}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -253,6 +253,101 @@ describe('PlaceFormModal', () => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
// ── Autocomplete suggestion click (#1192) ─────────────────────────────────────
|
||||
// Selecting a dropdown suggestion does a second `details` lookup which is fragile
|
||||
// (details kill-switch, an overloaded OSM Overpass mirror, upstream errors). When
|
||||
// it yields no usable place the modal must fall back to the reliable text search
|
||||
// instead of dead-ending on "Place search failed".
|
||||
|
||||
async function openSuggestion(user: ReturnType<typeof userEvent.setup>) {
|
||||
const searchInput = screen.getByPlaceholderText('Search places...');
|
||||
await user.type(searchInput, 'Eiffel');
|
||||
// Debounced autocomplete (300ms) then the dropdown renders the suggestion.
|
||||
return screen.findByText('Paris, France');
|
||||
}
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021b: suggestion click falls back to search when details fails', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'nominatim',
|
||||
}),
|
||||
),
|
||||
// details rejects (e.g. proxy 504 from a hung Overpass mirror)
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({
|
||||
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
||||
source: 'openstreetmap',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const suggestion = await openSuggestion(user);
|
||||
await user.click(suggestion);
|
||||
|
||||
// Form is populated from the search fallback, and no error toast is shown.
|
||||
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('2.2945')).toBeInTheDocument();
|
||||
expect(addToast).not.toHaveBeenCalledWith(expect.anything(), 'error', expect.anything());
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'nominatim',
|
||||
}),
|
||||
),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({
|
||||
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
||||
source: 'openstreetmap',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const suggestion = await openSuggestion(user);
|
||||
await user.click(suggestion);
|
||||
|
||||
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021d: suggestion click shows error only when the fallback also finds nothing', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'nominatim',
|
||||
}),
|
||||
),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'openstreetmap' })),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const suggestion = await openSuggestion(user);
|
||||
await user.click(suggestion);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith('Place search failed.', 'error', undefined);
|
||||
});
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
||||
// hasMapsKey is false by default in beforeEach
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
|
||||
@@ -249,15 +249,34 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
||||
setIsSearchingMaps(true)
|
||||
try {
|
||||
const result = await mapsApi.details(suggestion.placeId, language)
|
||||
if (result.place) {
|
||||
handleSelectMapsResult(result.place)
|
||||
// The details lookup is a fragile second hop — it can fail when the
|
||||
// details kill-switch is off, when the OSM Overpass mirror is overloaded,
|
||||
// or on any upstream error. Treat a missing/coordinate-less place as a
|
||||
// miss and fall back to the reliable text-search path the search button
|
||||
// uses (its results already carry coordinates), so dropdown items stay
|
||||
// clickable instead of dead-ending on "Place search failed". (#1192)
|
||||
let place: Record<string, unknown> | null = null
|
||||
try {
|
||||
const result = await mapsApi.details(suggestion.placeId, language)
|
||||
if (result.place && result.place.lat != null && result.place.lng != null) {
|
||||
place = result.place
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
}
|
||||
if (!place) {
|
||||
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||
const search = await mapsApi.search(query, language)
|
||||
place = search.places?.[0] ?? null
|
||||
}
|
||||
if (place) {
|
||||
handleSelectMapsResult(place)
|
||||
} else {
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
console.error('Place suggestion lookup failed:', err)
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
|
||||
@@ -647,5 +647,43 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.queryByText('Participants')).toBeNull();
|
||||
});
|
||||
|
||||
// ── Scroll / overflow (issue #1195) ──────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-046: content area is a bounded flex scroll region', () => {
|
||||
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||||
const p = buildPlace({ id: 200, description: longText, notes: longText } as any);
|
||||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const scroll = screen.getByTestId('inspector-scroll') as HTMLElement;
|
||||
expect(scroll.style.overflowY).toBe('auto');
|
||||
expect(scroll.style.minHeight).toBe('0px');
|
||||
// flex must allow the region to shrink/grow within the capped card
|
||||
expect(scroll.style.flex).not.toBe('');
|
||||
expect(scroll.style.flex).not.toBe('0 0 auto');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-047: long unbroken description wraps instead of clipping horizontally', () => {
|
||||
const longWord = 'https://example.com/' + 'a'.repeat(300);
|
||||
const p = buildPlace({ id: 201, description: longWord } as any);
|
||||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const descDiv = container.querySelector('.collab-note-md') as HTMLElement;
|
||||
expect(descDiv).toBeTruthy();
|
||||
expect(descDiv.style.overflowWrap).toBe('anywhere');
|
||||
expect(descDiv.style.wordBreak).toBe('break-word');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-048: description/notes do not shrink so the card scrolls instead of clipping', () => {
|
||||
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||||
const p = buildPlace({ id: 202, description: longText, notes: longText } as any);
|
||||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const notes = Array.from(container.querySelectorAll('.collab-note-md')) as HTMLElement[];
|
||||
// Both description and notes containers must keep their natural height
|
||||
// (flex-shrink: 0) — otherwise they compress inside the flex column and
|
||||
// overflow:hidden clips the text with no scroll (issue #1195).
|
||||
expect(notes.length).toBe(2);
|
||||
for (const el of notes) {
|
||||
expect(el.style.flexShrink).toBe('0');
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function PlaceInspector({
|
||||
locale={locale} timeFormat={timeFormat} onClose={onClose} />
|
||||
|
||||
{/* Content — scrollable */}
|
||||
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div data-testid="inspector-scroll" style={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
|
||||
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
||||
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||
@@ -253,14 +253,14 @@ export default function PlaceInspector({
|
||||
|
||||
{/* Description / Summary */}
|
||||
{(place.description || googleDetails?.summary) && (
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{place.notes && (
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
@@ -279,7 +279,7 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', flexShrink: 0 }}>
|
||||
{selectedDayId && (
|
||||
assignmentInDay ? (
|
||||
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||
@@ -497,7 +497,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
|
||||
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
|
||||
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
{/* Avatar with open/closed ring + tag */}
|
||||
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
||||
<div style={{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||
export default function ToggleSwitch({ on, onToggle, label }: { on: boolean; onToggle: () => void; label?: string }) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle}
|
||||
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label}
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||
|
||||
@@ -288,4 +288,26 @@ describe('TripFormModal', () => {
|
||||
await user.click(submitBtn.closest('button')!);
|
||||
await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-029: clearing the day count leaves the field empty (no snap to 1)', () => {
|
||||
render(<TripFormModal {...defaultProps} trip={null} />);
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
expect(dayInput).toBeInTheDocument();
|
||||
expect(dayInput.value).toBe('7');
|
||||
fireEvent.change(dayInput, { target: { value: '' } });
|
||||
expect(dayInput.value).toBe('');
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-030: empty day count blocks submit with an error', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn();
|
||||
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
|
||||
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'No-date Trip');
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
fireEvent.change(dayInput, { target: { value: '' } });
|
||||
const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!;
|
||||
await user.click(submitBtn.closest('button')!);
|
||||
await screen.findByText('Number of days is required');
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
reminder_days: 0 as number,
|
||||
day_count: 7,
|
||||
day_count: 7 as number | '',
|
||||
})
|
||||
const [customReminder, setCustomReminder] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -100,6 +100,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
||||
setError(t('dashboard.endDateError')); return
|
||||
}
|
||||
if (!formData.start_date && !formData.end_date) {
|
||||
const dc = Number(formData.day_count)
|
||||
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > 365) {
|
||||
setError(t('dashboard.dayCountRequired')); return
|
||||
}
|
||||
}
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await onSave({
|
||||
@@ -108,7 +114,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
start_date: formData.start_date || null,
|
||||
end_date: formData.end_date || null,
|
||||
reminder_days: formData.reminder_days,
|
||||
...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}),
|
||||
...(!formData.start_date && !formData.end_date ? { day_count: Number(formData.day_count) } : {}),
|
||||
})
|
||||
const createdTrip = result ? result.trip : undefined
|
||||
// Add selected members for newly created trips
|
||||
@@ -320,7 +326,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
{t('dashboard.dayCount')}
|
||||
</label>
|
||||
<input type="number" min={1} max={365} value={formData.day_count}
|
||||
onChange={e => update('day_count', Math.max(1, Math.min(365, Number(e.target.value) || 1)))}
|
||||
onChange={e => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') { update('day_count', ''); return }
|
||||
const n = Math.floor(Number(raw))
|
||||
if (Number.isFinite(n)) update('day_count', Math.min(365, Math.max(1, n)))
|
||||
}}
|
||||
className={inputCls} />
|
||||
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult } from '../types'
|
||||
|
||||
@@ -53,12 +54,6 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
return pos != null
|
||||
})
|
||||
|
||||
// The departure/arrival coordinate of a transport, if its endpoints carry one.
|
||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
|
||||
// Build a unified list of places + transports sorted by effective position.
|
||||
type Entry =
|
||||
| { kind: 'place'; lat: number; lng: number; pos: number }
|
||||
@@ -67,12 +62,15 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
||||
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
||||
})),
|
||||
...dayTransports.map(r => ({
|
||||
kind: 'transport' as const,
|
||||
from: epLoc(r, 'from'),
|
||||
to: epLoc(r, 'to'),
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
})),
|
||||
...dayTransports.map(r => {
|
||||
const { from, to } = getTransportRouteEndpoints(r, dayId)
|
||||
return {
|
||||
kind: 'transport' as const,
|
||||
from,
|
||||
to,
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
}
|
||||
}),
|
||||
].sort((a, b) => a.pos - b.pos)
|
||||
|
||||
// Group located places into driving runs.
|
||||
|
||||
@@ -103,6 +103,38 @@ describe('LoginPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-LOGIN-007: Remember me sends remember_me to the API', () => {
|
||||
it('renders an off toggle and forwards remember_me: true when toggled on', async () => {
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/auth/login', async ({ request }) => {
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' } });
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggle = screen.getByRole('button', { name: /remember me/i });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
|
||||
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toEqual(expect.objectContaining({ remember_me: true }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => {
|
||||
it('shows a Register button to switch to registration mode', async () => {
|
||||
// Default appConfig has allow_registration: true, has_users: true
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
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 { useLogin } from './login/useLogin'
|
||||
import ToggleSwitch from '../components/Settings/ToggleSwitch'
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const { t, language } = useTranslation()
|
||||
@@ -9,7 +10,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
const {
|
||||
navigate,
|
||||
mode, setMode,
|
||||
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
|
||||
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
|
||||
isLoading, error, setError, appConfig, inviteToken,
|
||||
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||
@@ -572,7 +573,16 @@ export default function LoginPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
{mode === 'login' && (
|
||||
<div style={{ textAlign: 'right', marginTop: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ToggleSwitch on={rememberMe} onToggle={() => setRememberMe(!rememberMe)} label={t('login.rememberMe')} />
|
||||
<span
|
||||
onClick={() => setRememberMe(!rememberMe)}
|
||||
style={{ cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500, userSelect: 'none' }}
|
||||
>
|
||||
{t('login.rememberMe')}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
||||
|
||||
@@ -37,6 +37,7 @@ export function useLogin() {
|
||||
const [username, setUsername] = useState<string>('')
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const [rememberMe, setRememberMe] = useState<boolean>(false)
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
@@ -242,7 +243,7 @@ export function useLogin() {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
|
||||
const mfaResult = await completeMfaLogin(mfaToken, mfaCode, rememberMe)
|
||||
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
|
||||
setSavedLoginPassword(password)
|
||||
setPasswordChangeStep(true)
|
||||
@@ -258,7 +259,7 @@ export function useLogin() {
|
||||
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
|
||||
await register(username, email, password, inviteToken || undefined)
|
||||
} else {
|
||||
const result = await login(email, password)
|
||||
const result = await login(email, password, rememberMe)
|
||||
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
|
||||
setMfaToken(result.mfa_token)
|
||||
setMfaStep(true)
|
||||
@@ -289,7 +290,7 @@ export function useLogin() {
|
||||
return {
|
||||
navigate,
|
||||
mode, setMode,
|
||||
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
|
||||
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
|
||||
isLoading, error, setError, appConfig, inviteToken,
|
||||
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||
|
||||
@@ -39,8 +39,8 @@ interface AuthState {
|
||||
placesAutocompleteEnabled: boolean
|
||||
placesDetailsEnabled: boolean
|
||||
|
||||
login: (email: string, password: string) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||
login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string, rememberMe?: boolean) => Promise<AuthResponse>
|
||||
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
|
||||
logout: () => Promise<void>
|
||||
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
|
||||
@@ -99,11 +99,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
placesAutocompleteEnabled: true,
|
||||
placesDetailsEnabled: true,
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
login: async (email: string, password: string, rememberMe?: boolean) => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
|
||||
const data = await authApi.login({ email, password, remember_me: rememberMe }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
|
||||
if (data.mfa_required && data.mfa_token) {
|
||||
set({ isLoading: false, error: null })
|
||||
return { mfa_required: true as const, mfa_token: data.mfa_token }
|
||||
@@ -128,11 +128,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
completeMfaLogin: async (mfaToken: string, code: string) => {
|
||||
completeMfaLogin: async (mfaToken: string, code: string, rememberMe?: boolean) => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
|
||||
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, ''), remember_me: rememberMe })
|
||||
set({
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||
import { parseTimeToMinutes, getSpanPhase, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||
|
||||
describe('parseTimeToMinutes', () => {
|
||||
it('parses HH:MM string', () => {
|
||||
@@ -34,6 +34,38 @@ describe('getSpanPhase', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTransportRouteEndpoints', () => {
|
||||
const pickup = { role: 'from', lat: 48.1, lng: 11.5 }
|
||||
const dropoff = { role: 'to', lat: 52.5, lng: 13.4 }
|
||||
// A car rental spanning day 1 (pickup) through day 3 (drop-off).
|
||||
const rental = { day_id: 1, end_day_id: 3, endpoints: [pickup, dropoff] }
|
||||
|
||||
it('routes to the pickup only on the start day of a multi-day rental', () => {
|
||||
expect(getTransportRouteEndpoints(rental, 1)).toEqual({ from: { lat: 48.1, lng: 11.5 }, to: null })
|
||||
})
|
||||
|
||||
it('routes from the drop-off only on the end day', () => {
|
||||
expect(getTransportRouteEndpoints(rental, 3)).toEqual({ from: null, to: { lat: 52.5, lng: 13.4 } })
|
||||
})
|
||||
|
||||
it('adds no waypoints on the days in between (regression for #1210)', () => {
|
||||
expect(getTransportRouteEndpoints(rental, 2)).toEqual({ from: null, to: null })
|
||||
})
|
||||
|
||||
it('uses both endpoints for a single-day transport', () => {
|
||||
const sameDay = { day_id: 1, end_day_id: 1, endpoints: [pickup, dropoff] }
|
||||
expect(getTransportRouteEndpoints(sameDay, 1)).toEqual({
|
||||
from: { lat: 48.1, lng: 11.5 },
|
||||
to: { lat: 52.5, lng: 13.4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns nulls when the endpoints carry no coordinates', () => {
|
||||
const noCoords = { day_id: 1, end_day_id: 1, endpoints: [{ role: 'from' }, { role: 'to' }] }
|
||||
expect(getTransportRouteEndpoints(noCoords, 1)).toEqual({ from: null, to: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDisplayTimeForDay', () => {
|
||||
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
||||
|
||||
|
||||
@@ -29,6 +29,33 @@ export function getSpanPhase(
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
/**
|
||||
* The route waypoints a transport contributes on a given day, respecting multi-day spans.
|
||||
* A car rental (or any reservation whose span covers several days) is only routed to on its
|
||||
* pickup day (the departure endpoint) and from on its drop-off day (the arrival endpoint) — on
|
||||
* the days in between you simply hold the vehicle, so it adds no waypoints and must not pull the
|
||||
* route to those points. Single-day transports contribute both endpoints.
|
||||
*/
|
||||
export function getTransportRouteEndpoints(
|
||||
r: any,
|
||||
dayId: number
|
||||
): { from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null } {
|
||||
const ep = (role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
switch (getSpanPhase(r, dayId)) {
|
||||
case 'start':
|
||||
return { from: ep('from'), to: null }
|
||||
case 'end':
|
||||
return { from: null, to: ep('to') }
|
||||
case 'middle':
|
||||
return { from: null, to: null }
|
||||
default:
|
||||
return { from: ep('from'), to: ep('to') }
|
||||
}
|
||||
}
|
||||
|
||||
export function getDisplayTimeForDay(
|
||||
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
||||
dayId: number
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 321 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 792 KiB After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 2.1 MiB |
Generated
+109
-105
@@ -15231,9 +15231,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -15247,9 +15247,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -15263,9 +15263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -15279,9 +15279,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -15295,9 +15295,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -15311,9 +15311,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -15327,9 +15327,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -15343,9 +15343,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -15359,9 +15359,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -15375,9 +15375,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -15391,9 +15391,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -15407,9 +15407,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -15423,9 +15423,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -15439,9 +15439,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -15455,9 +15455,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -15471,9 +15471,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -15487,9 +15487,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -15503,9 +15503,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -15519,9 +15519,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -15535,9 +15535,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -15551,9 +15551,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -15567,9 +15567,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -15583,9 +15583,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -15599,9 +15599,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -15615,9 +15615,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -15631,7 +15631,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"version": "0.28.1",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -15642,10 +15642,12 @@
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="
|
||||
},
|
||||
"node_modules/tsx/node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"version": "0.28.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -15655,33 +15657,35 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
|
||||
@@ -136,3 +136,21 @@ export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATI
|
||||
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);
|
||||
|
||||
// SESSION_DURATION_REMEMBER is the session length used when the user ticks
|
||||
// "Remember me" on the login form: a longer-lived JWT `exp` claim plus a
|
||||
// persistent `trek_session` cookie `maxAge`. An unticked login keeps
|
||||
// SESSION_DURATION and a browser-session cookie (no `maxAge`). Same ms-style
|
||||
// format and fallback behavior as SESSION_DURATION.
|
||||
const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
|
||||
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
|
||||
const parsedRememberMs = parseDurationMs(rawRememberDuration);
|
||||
if (parsedRememberMs == null) {
|
||||
console.warn(`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`);
|
||||
}
|
||||
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
|
||||
export const SESSION_DURATION_REMEMBER = parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
|
||||
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
|
||||
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
|
||||
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
|
||||
export const SESSION_DURATION_REMEMBER_SECONDS = Math.floor(SESSION_DURATION_REMEMBER_MS / 1000);
|
||||
|
||||
@@ -87,7 +87,7 @@ export class AuthPublicController {
|
||||
if (result.mfa_required) {
|
||||
return { mfa_required: true, mfa_token: result.mfa_token };
|
||||
}
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
this.auth.setAuthCookie(res, result.token!, req, result.remember);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export class AuthPublicController {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
this.auth.setAuthCookie(res, result.token!, req, result.remember);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { User } from '../../types';
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
// Cookie
|
||||
setAuthCookie(res: Response, token: string, req: Request) { setAuthCookie(res, token, req); }
|
||||
setAuthCookie(res: Response, token: string, req: Request, remember?: boolean) { setAuthCookie(res, token, req, remember); }
|
||||
clearAuthCookie(res: Response, req: Request) { clearAuthCookie(res, req); }
|
||||
|
||||
// Reset-email delivery (canonical app URL, never request headers)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { authenticator } from 'otplib';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { db } from '../db/database';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../config';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
|
||||
import { getAllPermissions } from './permissions';
|
||||
@@ -181,14 +181,17 @@ export function isOidcOnlyMode(): boolean {
|
||||
return !resolveAuthToggles().password_login;
|
||||
}
|
||||
|
||||
export function generateToken(user: { id: number | bigint; password_version?: number }) {
|
||||
export function generateToken(user: { id: number | bigint; password_version?: number }, rememberMe = false) {
|
||||
const pv = typeof user.password_version === 'number'
|
||||
? user.password_version
|
||||
: ((db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0);
|
||||
// "Remember me" extends the JWT lifetime to match the persistent cookie maxAge;
|
||||
// the cookie service decides session-vs-persistent off the same flag.
|
||||
const expiresIn = rememberMe ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS;
|
||||
return jwt.sign(
|
||||
{ id: user.id, pv },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' }
|
||||
{ expiresIn, algorithm: 'HS256' }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,6 +446,7 @@ export function registerUser(body: {
|
||||
export function loginUser(body: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
remember_me?: boolean;
|
||||
}): {
|
||||
error?: string;
|
||||
status?: number;
|
||||
@@ -450,6 +454,7 @@ export function loginUser(body: {
|
||||
user?: Record<string, unknown>;
|
||||
mfa_required?: boolean;
|
||||
mfa_token?: string;
|
||||
remember?: boolean;
|
||||
auditUserId?: number | null;
|
||||
auditAction?: string;
|
||||
auditDetails?: Record<string, unknown>;
|
||||
@@ -458,7 +463,8 @@ export function loginUser(body: {
|
||||
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
|
||||
}
|
||||
|
||||
const { email, password } = body;
|
||||
const { email, password, remember_me } = body;
|
||||
const remember = remember_me === true;
|
||||
if (!email || !password) {
|
||||
return { error: 'Email and password are required', status: 400 };
|
||||
}
|
||||
@@ -500,12 +506,13 @@ export function loginUser(body: {
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
const token = generateToken(user);
|
||||
const token = generateToken(user, remember);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
token,
|
||||
user: { ...userSafe, avatar_url: avatarUrl(user) },
|
||||
remember,
|
||||
auditUserId: Number(user.id),
|
||||
auditAction: 'user.login',
|
||||
auditDetails: { email },
|
||||
@@ -1066,14 +1073,17 @@ export function disableMfa(
|
||||
export function verifyMfaLogin(body: {
|
||||
mfa_token?: string;
|
||||
code?: string;
|
||||
remember_me?: boolean;
|
||||
}): {
|
||||
error?: string;
|
||||
status?: number;
|
||||
token?: string;
|
||||
user?: Record<string, unknown>;
|
||||
remember?: boolean;
|
||||
auditUserId?: number;
|
||||
} {
|
||||
const { mfa_token, code } = body;
|
||||
const { mfa_token, code, remember_me } = body;
|
||||
const remember = remember_me === true;
|
||||
if (!mfa_token || !code) {
|
||||
return { error: 'Verification token and code are required', status: 400 };
|
||||
}
|
||||
@@ -1104,11 +1114,12 @@ export function verifyMfaLogin(body: {
|
||||
);
|
||||
}
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
const sessionToken = generateToken(user);
|
||||
const sessionToken = generateToken(user, remember);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
return {
|
||||
token: sessionToken,
|
||||
user: { ...userSafe, avatar_url: avatarUrl(user) },
|
||||
remember,
|
||||
auditUserId: Number(user.id),
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -155,6 +155,17 @@ export async function createBackup(): Promise<BackupInfo> {
|
||||
archive.file(dbPath, { name: 'travel.db' });
|
||||
}
|
||||
|
||||
// Bundle the at-rest encryption key so the backup is self-contained: the
|
||||
// DB stores secrets (API keys, MFA, SMTP/OIDC) encrypted with this key, so
|
||||
// a restore onto a different install would otherwise be unable to decrypt
|
||||
// them. NOTE: this makes the backup file as sensitive as the key itself —
|
||||
// store/transfer it securely. Skipped when ENCRYPTION_KEY is provided via
|
||||
// env, since in that case the file is not the source of truth.
|
||||
const encKeyPath = path.join(dataDir, '.encryption_key');
|
||||
if (!process.env.ENCRYPTION_KEY && fs.existsSync(encKeyPath)) {
|
||||
archive.file(encKeyPath, { name: '.encryption_key' });
|
||||
}
|
||||
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
// Exclude the place-photo and trek-memory caches: both are re-derivable
|
||||
// (re-fetched on demand, keyed on stable ids) and would otherwise dominate
|
||||
@@ -252,6 +263,16 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
}
|
||||
fs.copyFileSync(extractedDb, dbDest);
|
||||
|
||||
// Restore the bundled at-rest encryption key (if the archive carries one)
|
||||
// so the restored DB's encrypted secrets can be decrypted. Only the file
|
||||
// is swapped here; the in-memory key was read at startup, so a restart is
|
||||
// required for it to take effect (and an explicit ENCRYPTION_KEY env var
|
||||
// still overrides the file).
|
||||
const extractedEncKey = path.join(extractDir, '.encryption_key');
|
||||
if (fs.existsSync(extractedEncKey)) {
|
||||
fs.copyFileSync(extractedEncKey, path.join(dataDir, '.encryption_key'));
|
||||
}
|
||||
|
||||
const extractedUploads = path.join(extractDir, 'uploads');
|
||||
if (fs.existsSync(extractedUploads)) {
|
||||
for (const sub of fs.readdirSync(uploadsDir)) {
|
||||
@@ -262,7 +283,12 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
|
||||
// Copy into the real directory behind uploadsDir. In Docker, uploadsDir
|
||||
// (/app/server/uploads) is a symlink to the mounted /app/uploads volume;
|
||||
// cpSync(dereference:false) would otherwise try to overwrite the symlink
|
||||
// node with a directory and throw ERR_FS_CP_DIR_TO_NON_DIR. realpathSync
|
||||
// is a no-op when uploadsDir is a plain directory (dev/non-Docker).
|
||||
fs.cpSync(extractedUploads, fs.realpathSync(uploadsDir), { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
// Reopening the DB must always run (even if the copy above threw) so the
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { SESSION_DURATION_MS } from '../config';
|
||||
import { SESSION_DURATION_MS, SESSION_DURATION_REMEMBER_MS } from '../config';
|
||||
|
||||
const COOKIE_NAME = 'trek_session';
|
||||
|
||||
/**
|
||||
* Controls the cookie lifetime for a login:
|
||||
* - `undefined` → persistent `maxAge: SESSION_DURATION_MS` (the historical
|
||||
* default, used by register/demo and anything that doesn't opt in).
|
||||
* - `true` → persistent `maxAge: SESSION_DURATION_REMEMBER_MS` ("Remember me").
|
||||
* - `false` → no `maxAge` — a browser-session cookie cleared on browser close.
|
||||
*/
|
||||
export type RememberOption = boolean | undefined;
|
||||
|
||||
/**
|
||||
* Decide whether the session cookie should carry the `Secure` flag.
|
||||
*
|
||||
@@ -18,27 +27,35 @@ const COOKIE_NAME = 'trek_session';
|
||||
* on the outermost hop, the cookie is `Secure`. `COOKIE_SECURE=false`
|
||||
* remains the explicit escape hatch for plain-HTTP LAN testing.
|
||||
*/
|
||||
export function cookieOptions(clear = false, req?: Request) {
|
||||
export function cookieOptions(clear = false, req?: Request, remember?: RememberOption) {
|
||||
if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') {
|
||||
return buildOptions(clear, false);
|
||||
return buildOptions(clear, false, remember);
|
||||
}
|
||||
const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true';
|
||||
const requestSecure = req?.secure === true;
|
||||
return buildOptions(clear, envSecure || requestSecure);
|
||||
return buildOptions(clear, envSecure || requestSecure, remember);
|
||||
}
|
||||
|
||||
function buildOptions(clear: boolean, secure: boolean) {
|
||||
function resolveMaxAge(remember: RememberOption): { maxAge: number } | Record<string, never> {
|
||||
// false → session cookie (omit maxAge); true → the longer "remember me"
|
||||
// window; undefined → the historical default. Each maxAge matches the JWT exp.
|
||||
if (remember === false) return {};
|
||||
if (remember === true) return { maxAge: SESSION_DURATION_REMEMBER_MS };
|
||||
return { maxAge: SESSION_DURATION_MS };
|
||||
}
|
||||
|
||||
function buildOptions(clear: boolean, secure: boolean, remember?: RememberOption) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
...(clear ? {} : { maxAge: SESSION_DURATION_MS }), // matches the JWT expiry (SESSION_DURATION)
|
||||
...(clear ? {} : resolveMaxAge(remember)),
|
||||
};
|
||||
}
|
||||
|
||||
export function setAuthCookie(res: Response, token: string, req?: Request): void {
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions(false, req));
|
||||
export function setAuthCookie(res: Response, token: string, req?: Request, remember?: RememberOption): void {
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions(false, req, remember));
|
||||
}
|
||||
|
||||
export function clearAuthCookie(res: Response, req?: Request): void {
|
||||
|
||||
@@ -16,11 +16,11 @@ function isAlwaysBlocked(ip: string): boolean {
|
||||
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
||||
|
||||
// Loopback
|
||||
if (addr.startsWith("127.") || addr === '::1') return true;
|
||||
if (addr.startsWith('127.') || addr === '::1') return true;
|
||||
// Unspecified
|
||||
if (addr.startsWith("0.")) return true;
|
||||
if (addr.startsWith('0.')) return true;
|
||||
// Link-local / cloud metadata
|
||||
if (addr.startsWith("169.254.") || /^fe80:/i.test(addr)) return true;
|
||||
if (addr.startsWith('169.254.') || /^fe80:/i.test(addr)) return true;
|
||||
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
|
||||
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
|
||||
|
||||
@@ -32,9 +32,9 @@ function isPrivateNetwork(ip: string): boolean {
|
||||
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
||||
|
||||
// RFC-1918 private ranges
|
||||
if (addr.startsWith("10.")) return true;
|
||||
if (addr.startsWith('10.')) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
|
||||
if (addr.startsWith("192.168.")) return true;
|
||||
if (addr.startsWith('192.168.')) return true;
|
||||
// CGNAT / Tailscale shared address space (100.64.0.0/10)
|
||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
|
||||
// IPv6 ULA (fc00::/7)
|
||||
@@ -71,8 +71,9 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
try {
|
||||
const result = await dns.lookup(hostname);
|
||||
resolvedIp = result.address;
|
||||
} catch {
|
||||
return { allowed: false, isPrivate: false, error: 'Could not resolve hostname' };
|
||||
} catch (error_) {
|
||||
const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown';
|
||||
return { allowed: false, isPrivate: false, error: `Could not resolve hostname (${code})` };
|
||||
}
|
||||
|
||||
if (isAlwaysBlocked(resolvedIp)) {
|
||||
@@ -90,7 +91,8 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
allowed: false,
|
||||
isPrivate: true,
|
||||
resolvedIp,
|
||||
error: 'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
|
||||
error:
|
||||
'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
|
||||
};
|
||||
}
|
||||
return { allowed: true, isPrivate: true, resolvedIp };
|
||||
@@ -187,7 +189,7 @@ export async function safeFetchFollow(
|
||||
// (2xx/4xx/5xx, or a 3xx with no Location) is the final response.
|
||||
const status = typeof response.status === 'number' ? response.status : 0;
|
||||
const isRedirectStatus = status >= 300 && status < 400;
|
||||
const location = isRedirectStatus ? response.headers?.get('location') ?? null : null;
|
||||
const location = isRedirectStatus ? (response.headers?.get('location') ?? null) : null;
|
||||
if (!location) {
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -98,6 +98,28 @@ describe('Auth e2e (real auth guard + real cookie service + temp SQLite)', () =>
|
||||
expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it('POST /login with remember_me sets a persistent cookie (Max-Age present)', async () => {
|
||||
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 }, remember: true });
|
||||
const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw', remember_me: true });
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
const cookie = setCookie.find((c) => c.startsWith('trek_session='))!;
|
||||
expect(cookie).toMatch(/Max-Age=\d+/i);
|
||||
// 30d default — well above the 24h (86400s) non-remember window.
|
||||
const maxAge = Number(/Max-Age=(\d+)/i.exec(cookie)?.[1]);
|
||||
expect(maxAge).toBeGreaterThan(86_400);
|
||||
}, 10000);
|
||||
|
||||
it('POST /login without remember_me sets a session cookie (no Max-Age)', async () => {
|
||||
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 }, remember: false });
|
||||
const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw' });
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
const cookie = setCookie.find((c) => c.startsWith('trek_session='))!;
|
||||
expect(cookie).not.toMatch(/Max-Age/i);
|
||||
expect(cookie).not.toMatch(/Expires/i);
|
||||
}, 10000);
|
||||
|
||||
it('POST /logout clears the session cookie', async () => {
|
||||
const res = await request(server).post('/api/auth/logout');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -82,9 +82,10 @@ describe('AuthPublicController', () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const mfa = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial<AuthService>), rl());
|
||||
expect(await mfa.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
|
||||
const ok = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
const ok = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user, remember: true }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(await ok.login({}, req, res)).toEqual({ token: 'tk', user });
|
||||
expect(setAuthCookie).toHaveBeenCalled();
|
||||
// The "remember me" flag from the service rides through to the cookie service.
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req, true);
|
||||
const bad = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial<AuthService>), rl());
|
||||
expect(await thrownAsync(() => bad.login({}, req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } });
|
||||
}, 10000);
|
||||
|
||||
@@ -19,6 +19,9 @@ const fsMock = vi.hoisted(() => ({
|
||||
rmSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
cpSync: vi.fn(),
|
||||
// Identity by default: when uploadsDir is a plain directory, realpathSync
|
||||
// returns it unchanged. Tests that exercise the symlink case override this.
|
||||
realpathSync: vi.fn((p: string) => p),
|
||||
}));
|
||||
|
||||
const archiverInstanceMock = vi.hoisted(() => ({
|
||||
@@ -479,6 +482,71 @@ describe('BACKUP-036 createBackup', () => {
|
||||
// The re-derivable caches must not be archived verbatim.
|
||||
expect(archiverInstanceMock.directory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BACKUP-036f — bundles .encryption_key when present and ENCRYPTION_KEY env is unset', async () => {
|
||||
const prevEnvKey = process.env.ENCRYPTION_KEY;
|
||||
delete process.env.ENCRYPTION_KEY;
|
||||
try {
|
||||
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('.encryption_key'));
|
||||
fsMock.mkdirSync.mockReturnValue(undefined);
|
||||
|
||||
const writableEvents: Record<string, Function> = {};
|
||||
const fakeWriteStream = {
|
||||
on: vi.fn((event: string, cb: Function) => {
|
||||
writableEvents[event] = cb;
|
||||
}),
|
||||
};
|
||||
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
|
||||
|
||||
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
|
||||
archiverInstanceMock.pipe.mockReturnValue(undefined);
|
||||
archiverInstanceMock.finalize.mockImplementation(() => {
|
||||
if (writableEvents['close']) writableEvents['close']();
|
||||
});
|
||||
archiverMock.mockReturnValue(archiverInstanceMock);
|
||||
|
||||
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
|
||||
|
||||
await createBackup();
|
||||
|
||||
expect(archiverInstanceMock.file).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.encryption_key'),
|
||||
{ name: '.encryption_key' },
|
||||
);
|
||||
} finally {
|
||||
process.env.ENCRYPTION_KEY = prevEnvKey;
|
||||
}
|
||||
});
|
||||
|
||||
it('BACKUP-036g — does NOT bundle .encryption_key when ENCRYPTION_KEY env is set', async () => {
|
||||
// setup.ts sets process.env.ENCRYPTION_KEY, so the env is the source of truth.
|
||||
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('.encryption_key'));
|
||||
fsMock.mkdirSync.mockReturnValue(undefined);
|
||||
|
||||
const writableEvents: Record<string, Function> = {};
|
||||
const fakeWriteStream = {
|
||||
on: vi.fn((event: string, cb: Function) => {
|
||||
writableEvents[event] = cb;
|
||||
}),
|
||||
};
|
||||
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
|
||||
|
||||
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
|
||||
archiverInstanceMock.pipe.mockReturnValue(undefined);
|
||||
archiverInstanceMock.finalize.mockImplementation(() => {
|
||||
if (writableEvents['close']) writableEvents['close']();
|
||||
});
|
||||
archiverMock.mockReturnValue(archiverInstanceMock);
|
||||
|
||||
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
|
||||
|
||||
await createBackup();
|
||||
|
||||
expect(archiverInstanceMock.file).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('.encryption_key'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -856,6 +924,53 @@ describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => {
|
||||
|
||||
expect(dbMock.reinitialize).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BACKUP-045d — restores bundled .encryption_key when the archive carries one', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
setupAllTablesPresent();
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
if (String(p).endsWith('travel.db')) return true;
|
||||
if (String(p).endsWith('.encryption_key')) return true; // extracted key present
|
||||
if (String(p).includes('uploads')) return false;
|
||||
return true;
|
||||
});
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
fsMock.copyFileSync.mockReturnValue(undefined);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
|
||||
const result = await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
// Key copied from the extract dir into the live data dir.
|
||||
expect(fsMock.copyFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.encryption_key'),
|
||||
expect.stringContaining('.encryption_key'),
|
||||
);
|
||||
});
|
||||
|
||||
it('BACKUP-045e — skips key restore when the archive has no .encryption_key', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
setupAllTablesPresent();
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
if (String(p).endsWith('travel.db')) return true;
|
||||
if (String(p).endsWith('.encryption_key')) return false; // no key in archive
|
||||
if (String(p).includes('uploads')) return false;
|
||||
return true;
|
||||
});
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
fsMock.copyFileSync.mockReturnValue(undefined);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
|
||||
const result = await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(fsMock.copyFileSync).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('.encryption_key'),
|
||||
expect.stringContaining('.encryption_key'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
|
||||
@@ -912,6 +1027,64 @@ describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
|
||||
{ recursive: true, force: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('BACKUP-046b — copies into the symlink target, not the symlink itself (#1193)', async () => {
|
||||
// In Docker, uploadsDir (/app/server/uploads) is a symlink to the mounted
|
||||
// /app/uploads volume. cpSync(dereference:false) would throw
|
||||
// ERR_FS_CP_DIR_TO_NON_DIR overwriting the symlink node with a directory.
|
||||
// The fix resolves the symlink with realpathSync first, so the copy targets
|
||||
// the real directory behind it.
|
||||
setupSuccessfulExtraction();
|
||||
|
||||
const fakeDbInstance = {
|
||||
prepare: vi.fn()
|
||||
.mockReturnValueOnce({
|
||||
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
all: vi.fn().mockReturnValue([
|
||||
{ name: 'users' },
|
||||
{ name: 'trips' },
|
||||
{ name: 'trip_members' },
|
||||
{ name: 'places' },
|
||||
{ name: 'days' },
|
||||
]),
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
DatabaseMock.mockReturnValue(fakeDbInstance);
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
if (String(p).endsWith('travel.db')) return true;
|
||||
if (String(p).includes('uploads')) return true;
|
||||
return true;
|
||||
});
|
||||
fsMock.readdirSync.mockImplementation((p: string) => {
|
||||
if (String(p).includes('uploads') && !String(p).includes('restore-')) {
|
||||
return ['photos'] as any;
|
||||
}
|
||||
if (String(p).includes('photos')) return ['img1.jpg'] as any;
|
||||
return [] as any;
|
||||
});
|
||||
fsMock.statSync.mockReturnValue({ isDirectory: () => true } as any);
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
fsMock.copyFileSync.mockReturnValue(undefined);
|
||||
fsMock.cpSync.mockReturnValue(undefined);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
// Resolve the uploads symlink to a distinct real target directory.
|
||||
const REAL_TARGET = '/app/uploads';
|
||||
fsMock.realpathSync.mockReturnValueOnce(REAL_TARGET);
|
||||
|
||||
const result = await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
// The copy destination must be the resolved real path, never the symlink.
|
||||
expect(fsMock.cpSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('uploads'),
|
||||
REAL_TARGET,
|
||||
{ recursive: true, force: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { cookieOptions } from '../../../src/services/cookie';
|
||||
import { SESSION_DURATION_MS, SESSION_DURATION_REMEMBER_MS } from '../../../src/config';
|
||||
|
||||
describe('cookieOptions', () => {
|
||||
afterEach(() => {
|
||||
@@ -53,4 +54,16 @@ describe('cookieOptions', () => {
|
||||
const opts = cookieOptions(true);
|
||||
expect(opts).not.toHaveProperty('maxAge');
|
||||
});
|
||||
|
||||
it('keeps the default SESSION_DURATION maxAge when remember is undefined', () => {
|
||||
expect(cookieOptions(false, undefined)).toHaveProperty('maxAge', SESSION_DURATION_MS);
|
||||
});
|
||||
|
||||
it('uses the longer SESSION_DURATION_REMEMBER maxAge when remember is true', () => {
|
||||
expect(cookieOptions(false, undefined, true)).toHaveProperty('maxAge', SESSION_DURATION_REMEMBER_MS);
|
||||
});
|
||||
|
||||
it('omits maxAge (session cookie) when remember is false', () => {
|
||||
expect(cookieOptions(false, undefined, false)).not.toHaveProperty('maxAge');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('checkSsrf', () => {
|
||||
const result = await checkSsrf('http://nxdomain.example.com');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.isPrivate).toBe(false);
|
||||
expect(result.error).toBe('Could not resolve hostname');
|
||||
expect(result.error).toContain('Could not resolve hostname');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export type RegisterRequest = z.infer<typeof registerRequestSchema>;
|
||||
export const loginRequestSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
// "Remember me" — when true the server issues a longer-lived
|
||||
// (SESSION_DURATION_REMEMBER) JWT + persistent cookie; when false/absent the
|
||||
// session lasts SESSION_DURATION and the cookie is a browser-session cookie.
|
||||
remember_me: z.boolean().optional(),
|
||||
});
|
||||
export type LoginRequest = z.infer<typeof loginRequestSchema>;
|
||||
|
||||
@@ -45,6 +49,9 @@ export type ChangePasswordRequest = z.infer<typeof changePasswordRequestSchema>;
|
||||
export const mfaVerifyLoginRequestSchema = z.object({
|
||||
mfa_token: z.string(),
|
||||
code: z.string(),
|
||||
// Carries the login-form "Remember me" choice through the second (MFA) leg,
|
||||
// since the session token is only minted once the MFA code is verified.
|
||||
remember_me: z.boolean().optional(),
|
||||
});
|
||||
export type MfaVerifyLoginRequest = z.infer<typeof mfaVerifyLoginRequestSchema>;
|
||||
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'تبديل العملات',
|
||||
'dashboard.aria.addTimezone': 'إضافة منطقة زمنية',
|
||||
'dashboard.aria.removeTimezone': 'إزالة {city}',
|
||||
'dashboard.dayCountRequired': 'عدد الأيام مطلوب',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -59,6 +59,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'اسم المستخدم مطلوب',
|
||||
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
|
||||
'login.forgotPassword': 'نسيت كلمة المرور؟',
|
||||
'login.rememberMe': 'تذكرني',
|
||||
'login.forgotPasswordTitle': 'إعادة تعيين كلمة المرور',
|
||||
'login.forgotPasswordBody':
|
||||
'أدخل عنوان البريد الإلكتروني المسجَّل. إذا كان الحساب موجودًا، سنرسل رابط إعادة التعيين.',
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Trocar moedas',
|
||||
'dashboard.aria.addTimezone': 'Adicionar fuso horário',
|
||||
'dashboard.aria.removeTimezone': 'Remover {city}',
|
||||
'dashboard.dayCountRequired': 'O número de dias é obrigatório',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -62,6 +62,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'Nome de usuário é obrigatório',
|
||||
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
|
||||
'login.forgotPassword': 'Esqueceu a senha?',
|
||||
'login.rememberMe': 'Lembrar de mim',
|
||||
'login.forgotPasswordTitle': 'Redefinir sua senha',
|
||||
'login.forgotPasswordBody':
|
||||
'Digite o e-mail cadastrado. Se houver uma conta, enviaremos um link de redefinição.',
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Prohodit měny',
|
||||
'dashboard.aria.addTimezone': 'Přidat časové pásmo',
|
||||
'dashboard.aria.removeTimezone': 'Odebrat {city}',
|
||||
'dashboard.dayCountRequired': 'Počet dní je povinný',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -64,6 +64,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'Uživatelské jméno je povinné',
|
||||
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
|
||||
'login.forgotPassword': 'Zapomenuté heslo?',
|
||||
'login.rememberMe': 'Zapamatovat si mě',
|
||||
'login.forgotPasswordTitle': 'Obnovení hesla',
|
||||
'login.forgotPasswordBody':
|
||||
'Zadej e-mail použitý při registraci. Pokud účet existuje, pošleme odkaz pro obnovení.',
|
||||
|
||||
@@ -164,5 +164,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Währungen tauschen',
|
||||
'dashboard.aria.addTimezone': 'Zeitzone hinzufügen',
|
||||
'dashboard.aria.removeTimezone': '{city} entfernen',
|
||||
'dashboard.dayCountRequired': 'Anzahl der Tage ist erforderlich',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -65,6 +65,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'Benutzername ist erforderlich',
|
||||
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
|
||||
'login.forgotPassword': 'Passwort vergessen?',
|
||||
'login.rememberMe': 'Angemeldet bleiben',
|
||||
'login.forgotPasswordTitle': 'Passwort zurücksetzen',
|
||||
'login.forgotPasswordBody':
|
||||
'Gib die E-Mail-Adresse deines Kontos ein. Falls ein Konto existiert, schicken wir dir einen Reset-Link.',
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Swap currencies',
|
||||
'dashboard.aria.addTimezone': 'Add timezone',
|
||||
'dashboard.aria.removeTimezone': 'Remove {city}',
|
||||
'dashboard.dayCountRequired': 'Number of days is required',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -63,6 +63,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'Username is required',
|
||||
'login.passwordMinLength': 'Password must be at least 8 characters',
|
||||
'login.forgotPassword': 'Forgot password?',
|
||||
'login.rememberMe': 'Remember me',
|
||||
'login.forgotPasswordTitle': 'Reset your password',
|
||||
'login.forgotPasswordBody':
|
||||
"Enter the email address you signed up with. If an account exists, we'll send a reset link.",
|
||||
|
||||
@@ -164,5 +164,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Intercambiar monedas',
|
||||
'dashboard.aria.addTimezone': 'Añadir zona horaria',
|
||||
'dashboard.aria.removeTimezone': 'Eliminar {city}',
|
||||
'dashboard.dayCountRequired': 'El número de días es obligatorio',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -57,6 +57,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'El nombre de usuario es obligatorio',
|
||||
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
|
||||
'login.forgotPassword': '¿Olvidaste tu contraseña?',
|
||||
'login.rememberMe': 'Recuérdame',
|
||||
'login.forgotPasswordTitle': 'Restablecer tu contraseña',
|
||||
'login.forgotPasswordBody':
|
||||
'Introduce la dirección de correo con la que te registraste. Si existe una cuenta, enviaremos un enlace.',
|
||||
|
||||
@@ -167,5 +167,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Inverser les devises',
|
||||
'dashboard.aria.addTimezone': 'Ajouter un fuseau horaire',
|
||||
'dashboard.aria.removeTimezone': 'Supprimer {city}',
|
||||
'dashboard.dayCountRequired': 'Le nombre de jours est requis',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -60,6 +60,7 @@ const login: TranslationStrings = {
|
||||
'login.passwordMinLength':
|
||||
'Le mot de passe doit comporter au moins 8 caractères',
|
||||
'login.forgotPassword': 'Mot de passe oublié ?',
|
||||
'login.rememberMe': 'Se souvenir de moi',
|
||||
'login.forgotPasswordTitle': 'Réinitialiser votre mot de passe',
|
||||
'login.forgotPasswordBody':
|
||||
"Entrez l'adresse e-mail associée à votre compte. Si un compte existe, nous enverrons un lien de réinitialisation.",
|
||||
|
||||
@@ -166,5 +166,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Swap currencies', // en-fallback
|
||||
'dashboard.aria.addTimezone': 'Add timezone', // en-fallback
|
||||
'dashboard.aria.removeTimezone': 'Remove {city}', // en-fallback
|
||||
'dashboard.dayCountRequired': 'Ο αριθμός ημερών είναι υποχρεωτικός',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -70,6 +70,7 @@ const login: TranslationStrings = {
|
||||
'login.passwordMinLength':
|
||||
'Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες',
|
||||
'login.forgotPassword': 'Ξεχάσατε τον κωδικό;',
|
||||
'login.rememberMe': 'Να με θυμάσαι',
|
||||
'login.forgotPasswordTitle': 'Επαναφορά του κωδικού σας',
|
||||
'login.forgotPasswordBody':
|
||||
'Εισάγετε το email με το οποίο εγγραφήκατε. Αν υπάρχει λογαριασμός, θα στείλουμε έναν σύνδεσμο επαναφοράς.',
|
||||
|
||||
@@ -164,5 +164,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Pénznemek cseréje',
|
||||
'dashboard.aria.addTimezone': 'Időzóna hozzáadása',
|
||||
'dashboard.aria.removeTimezone': '{city} eltávolítása',
|
||||
'dashboard.dayCountRequired': 'A napok száma kötelező',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -69,6 +69,7 @@ const login: TranslationStrings = {
|
||||
'login.passwordMinLength':
|
||||
'A jelszónak legalább 8 karakter hosszúnak kell lennie',
|
||||
'login.forgotPassword': 'Elfelejtetted a jelszavad?',
|
||||
'login.rememberMe': 'Emlékezz rám',
|
||||
'login.forgotPasswordTitle': 'Jelszó visszaállítása',
|
||||
'login.forgotPasswordBody':
|
||||
'Írd be a regisztrációnál használt e-mail-címet. Ha létezik fiók, küldünk egy visszaállítási linket.',
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Tukar mata uang',
|
||||
'dashboard.aria.addTimezone': 'Tambah zona waktu',
|
||||
'dashboard.aria.removeTimezone': 'Hapus {city}',
|
||||
'dashboard.dayCountRequired': 'Jumlah hari wajib diisi',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -65,6 +65,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'Nama pengguna wajib diisi',
|
||||
'login.passwordMinLength': 'Kata sandi minimal 8 karakter',
|
||||
'login.forgotPassword': 'Lupa kata sandi?',
|
||||
'login.rememberMe': 'Ingat saya',
|
||||
'login.forgotPasswordTitle': 'Setel ulang kata sandi',
|
||||
'login.forgotPasswordBody':
|
||||
'Masukkan alamat email akunmu. Jika akun ada, kami akan mengirim tautan reset.',
|
||||
|
||||
@@ -167,5 +167,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Inverti valute',
|
||||
'dashboard.aria.addTimezone': 'Aggiungi fuso orario',
|
||||
'dashboard.aria.removeTimezone': 'Rimuovi {city}',
|
||||
'dashboard.dayCountRequired': 'Il numero di giorni è obbligatorio',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -64,6 +64,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'Il nome utente è obbligatorio',
|
||||
'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
|
||||
'login.forgotPassword': 'Password dimenticata?',
|
||||
'login.rememberMe': 'Ricordami',
|
||||
'login.forgotPasswordTitle': 'Reimposta la password',
|
||||
'login.forgotPasswordBody':
|
||||
'Inserisci l’indirizzo email del tuo account. Se esiste un account, invieremo un link per reimpostarla.',
|
||||
|
||||
@@ -162,5 +162,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': '通貨を入れ替え',
|
||||
'dashboard.aria.addTimezone': 'タイムゾーンを追加',
|
||||
'dashboard.aria.removeTimezone': '{city}を削除',
|
||||
'dashboard.dayCountRequired': '日数は必須です',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -63,6 +63,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'ユーザー名を入力してください',
|
||||
'login.passwordMinLength': 'パスワードは8文字以上である必要があります',
|
||||
'login.forgotPassword': 'パスワードを忘れた場合',
|
||||
'login.rememberMe': 'ログイン状態を保持する',
|
||||
'login.forgotPasswordTitle': 'パスワードをリセット',
|
||||
'login.forgotPasswordBody':
|
||||
'登録時のメールアドレスを入力してください。アカウントが存在する場合、リセット用リンクを送信します。',
|
||||
|
||||
@@ -162,5 +162,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': '통화 바꾸기',
|
||||
'dashboard.aria.addTimezone': '시간대 추가',
|
||||
'dashboard.aria.removeTimezone': '{city} 제거',
|
||||
'dashboard.dayCountRequired': '일수는 필수입니다',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -62,6 +62,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': '사용자 이름을 입력하세요',
|
||||
'login.passwordMinLength': '비밀번호는 최소 8자 이상이어야 합니다',
|
||||
'login.forgotPassword': '비밀번호를 잊으셨나요?',
|
||||
'login.rememberMe': '로그인 상태 유지',
|
||||
'login.forgotPasswordTitle': '비밀번호 재설정',
|
||||
'login.forgotPasswordBody':
|
||||
'가입 시 사용한 이메일 주소를 입력하세요. 계정이 존재하면 재설정 링크를 보내드립니다.',
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': "Valuta's omwisselen",
|
||||
'dashboard.aria.addTimezone': 'Tijdzone toevoegen',
|
||||
'dashboard.aria.removeTimezone': '{city} verwijderen',
|
||||
'dashboard.dayCountRequired': 'Aantal dagen is verplicht',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -56,6 +56,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'Gebruikersnaam is vereist',
|
||||
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
|
||||
'login.forgotPassword': 'Wachtwoord vergeten?',
|
||||
'login.rememberMe': 'Ingelogd blijven',
|
||||
'login.forgotPasswordTitle': 'Wachtwoord resetten',
|
||||
'login.forgotPasswordBody':
|
||||
'Voer het e-mailadres van je account in. Als er een account bestaat, sturen we een resetlink.',
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Zamień waluty',
|
||||
'dashboard.aria.addTimezone': 'Dodaj strefę czasową',
|
||||
'dashboard.aria.removeTimezone': 'Usuń {city}',
|
||||
'dashboard.dayCountRequired': 'Liczba dni jest wymagana',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -65,6 +65,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'Nazwa użytkownika jest wymagana',
|
||||
'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
|
||||
'login.forgotPassword': 'Nie pamiętasz hasła?',
|
||||
'login.rememberMe': 'Zapamiętaj mnie',
|
||||
'login.forgotPasswordTitle': 'Zresetuj hasło',
|
||||
'login.forgotPasswordBody':
|
||||
'Wpisz adres e-mail użyty przy rejestracji. Jeśli konto istnieje, wyślemy link do resetu.',
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Поменять валюты',
|
||||
'dashboard.aria.addTimezone': 'Добавить часовой пояс',
|
||||
'dashboard.aria.removeTimezone': 'Удалить {city}',
|
||||
'dashboard.dayCountRequired': 'Количество дней обязательно',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -56,6 +56,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'Имя пользователя обязательно',
|
||||
'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
|
||||
'login.forgotPassword': 'Забыли пароль?',
|
||||
'login.rememberMe': 'Запомнить меня',
|
||||
'login.forgotPasswordTitle': 'Сброс пароля',
|
||||
'login.forgotPasswordBody':
|
||||
'Введите e-mail, с которым вы регистрировались. Если аккаунт найдём — отправим ссылку для сброса.',
|
||||
|
||||
@@ -162,5 +162,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Para birimlerini değiştir',
|
||||
'dashboard.aria.addTimezone': 'Saat dilimi ekle',
|
||||
'dashboard.aria.removeTimezone': '{city} kaldır',
|
||||
'dashboard.dayCountRequired': 'Gün sayısı gereklidir',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -67,6 +67,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'Kullanıcı adı gerekli',
|
||||
'login.passwordMinLength': 'Şifre en az 8 karakter olmalıdır',
|
||||
'login.forgotPassword': 'Parolanızı mı unuttunuz?',
|
||||
'login.rememberMe': 'Beni hatırla',
|
||||
'login.forgotPasswordTitle': 'Şifrenizi sıfırlayın',
|
||||
'login.forgotPasswordBody':
|
||||
"Enter the email address you signed up with. If an account exists, we'll send a reset link.",
|
||||
|
||||
@@ -164,5 +164,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Поміняти валюти',
|
||||
'dashboard.aria.addTimezone': 'Додати часовий пояс',
|
||||
'dashboard.aria.removeTimezone': 'Вилучити {city}',
|
||||
'dashboard.dayCountRequired': 'Вкажіть кількість днів',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -57,6 +57,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': 'Ім’я користувача обов’язкове',
|
||||
'login.passwordMinLength': 'Пароль має містити щонайменше 8 символів',
|
||||
'login.forgotPassword': 'Забули пароль?',
|
||||
'login.rememberMe': "Запам'ятати мене",
|
||||
'login.forgotPasswordTitle': 'Скидання пароля',
|
||||
'login.forgotPasswordBody':
|
||||
'Введіть електронну пошту, з якою ви реєструвалися. Якщо акаунт існує — буде надіслано посилання для скидання.',
|
||||
|
||||
@@ -161,5 +161,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': '交換貨幣',
|
||||
'dashboard.aria.addTimezone': '新增時區',
|
||||
'dashboard.aria.removeTimezone': '移除 {city}',
|
||||
'dashboard.dayCountRequired': '天數為必填項',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -51,6 +51,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': '使用者名稱為必填',
|
||||
'login.passwordMinLength': '密碼至少需要8個字元',
|
||||
'login.forgotPassword': '忘記密碼?',
|
||||
'login.rememberMe': '記住我',
|
||||
'login.forgotPasswordTitle': '重設密碼',
|
||||
'login.forgotPasswordBody':
|
||||
'請輸入您註冊時使用的電子郵件。若帳號存在,我們將傳送重設連結。',
|
||||
|
||||
@@ -161,5 +161,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': '交换货币',
|
||||
'dashboard.aria.addTimezone': '添加时区',
|
||||
'dashboard.aria.removeTimezone': '移除 {city}',
|
||||
'dashboard.dayCountRequired': '天数为必填项',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -51,6 +51,7 @@ const login: TranslationStrings = {
|
||||
'login.usernameRequired': '用户名为必填项',
|
||||
'login.passwordMinLength': '密码至少需要8个字符',
|
||||
'login.forgotPassword': '忘记密码?',
|
||||
'login.rememberMe': '记住我',
|
||||
'login.forgotPasswordTitle': '重置密码',
|
||||
'login.forgotPasswordBody':
|
||||
'输入您注册时使用的邮箱地址。若账户存在,我们将发送重置链接。',
|
||||
|
||||
@@ -154,6 +154,36 @@ The `@trek/shared` package is the single source of truth for code shared between
|
||||
| `npm run lint` | Lint source |
|
||||
| `npm run format` | Format source |
|
||||
|
||||
### Root (`/`)
|
||||
|
||||
These commands run across all workspaces at once and are the recommended way to work:
|
||||
|
||||
| Command | Description |
|
||||
|----------------------|---------------------------------------------------------------------|
|
||||
| `npm run dev` | Build shared, then start shared (watch), server, and client together via `concurrently` |
|
||||
| `npm run build` | Build shared → server → client in order |
|
||||
| `npm test` | Run tests in shared, server, and client |
|
||||
| `npm run test:cov` | Run coverage for server and client |
|
||||
| `npm run test:e2e` | Run end-to-end tests (server) |
|
||||
| `npm run lint` | Lint shared, server, and client |
|
||||
| `npm run format` | Format shared, server, and client |
|
||||
| `npm run format:check` | Check formatting across all workspaces |
|
||||
|
||||
### Shared (`/shared`)
|
||||
|
||||
The `@trek/shared` package is the single source of truth for code shared between the client and server. It currently holds **Zod schemas that define API contracts** (request/response shapes, common primitives, pagination). Both workspaces import from it so schema changes automatically propagate to both sides.
|
||||
|
||||
> **Upcoming:** the i18n translation layer will be migrated into this package so that translation keys and types are enforced across the stack from one place.
|
||||
|
||||
| Command | Description |
|
||||
|------------------------|------------------------------------|
|
||||
| `npm run build` | Compile shared package (tsup) |
|
||||
| `npm run build:watch` | Compile in watch mode |
|
||||
| `npm test` | Run tests |
|
||||
| `npm run typecheck` | Type-check without emitting |
|
||||
| `npm run lint` | Lint source |
|
||||
| `npm run format` | Format source |
|
||||
|
||||
### Server (`/server`)
|
||||
|
||||
| Command | Description |
|
||||
|
||||
@@ -22,7 +22,8 @@ Complete reference for all environment variables TREK reads.
|
||||
| `TZ` | Timezone for logs, reminders, and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions; `debug` = verbose details | `info` |
|
||||
| `DEFAULT_LANGUAGE` | Default language on the login page — see supported codes below | `en` |
|
||||
| `SESSION_DURATION` | How long a login session stays valid before re-login is required. Applies to both the `trek_session` JWT `exp` claim and the cookie `maxAge`, so they never drift apart. Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. 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). | `24h` |
|
||||
| `SESSION_DURATION` | How long a login session stays valid before re-login is required. Used when **"Remember me" is unchecked** on the login form (the default): applies to the `trek_session` JWT `exp` claim, and the cookie is issued as a **browser-session cookie** (no `maxAge`, cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. 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). | `24h` |
|
||||
| `SESSION_DURATION_REMEMBER` | Session length used when the user **ticks "Remember me"** on login: a longer-lived JWT `exp` claim plus a **persistent** `trek_session` cookie whose `maxAge` matches, so the session survives browser restarts. Same `ms`-style format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email notification links | same-origin |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` |
|
||||
| `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links. | — |
|
||||
|
||||
Reference in New Issue
Block a user