mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
bfb3bcd63c
Some installs are run by somebody other than the person who administers them. The operator owns the credentials, the configuration and the upgrade schedule, and the admin of the instance is a user of the result rather than the one who set it up. Nothing in the app knows that today, so it keeps offering settings whose values are not the admin's to change, and the honest answer to half of them would be a 403 the UI never anticipated. TREK_MANAGED says it once. It is a single boolean and the surfaces that care read it themselves, so adding one never comes back to the config layer. Runtime-toggled, in the same class as DEMO_MODE, rather than a registerAs token: roughly sixty tests mutate env mid-lifetime, and a value frozen per built app breaks all of them. `=== true` on the way in, so an unset, empty or misspelled value means off — a managed install sets it deliberately, and a typo must not quietly take settings away from an admin who never asked for it. The guard keys on a decorator, not on a path. MfaPolicyGuard documents where the two literal path lists it replaced ended up: they stopped being maintained, and every endpoint added afterwards silently fell on the wrong side. A marker on the handler cannot drift away from the handler. It carries a mandatory reason, which is for the next reader of the source rather than for the client, so the marks stay auditable. It registers third, after the auth and MFA guards, so a stranger still gets the 401 those two would have given them instead of a 403 that would confirm the route exists. And it checks the mode before it touches metadata, because it runs on every request of every install and almost all of them are self-hosted. One 403 body, shared by REST and MCP. DEMO_MODE grew two texts for the same condition and neither can be changed now without hunting for the other. The flag reaches the client through app-config so the UI can stop offering what the server would refuse anyway, but it is deliberately not persisted the way demo_mode is: two installs can share a browser profile, and a stale "this one is managed" would take settings away on an install that never set it. No route carries the marker yet. The per-domain marks follow. Unset, which is every self-hosted install, nothing about this changes anything.
431 lines
11 KiB
TypeScript
431 lines
11 KiB
TypeScript
// Shared types for the TREK travel planner.
|
||
//
|
||
// Domain entity/response types are now sourced from @trek/shared — the single
|
||
// source of truth shared with the server. The Zod schemas there are built to
|
||
// match the REAL server response shapes (see shared/src/<domain>/*.schema.ts,
|
||
// each documented against the producing service). Re-exported here so the rest
|
||
// of the client keeps importing from '../types' unchanged.
|
||
import type {
|
||
TrekWsEventName,
|
||
TrekWsPluginEventName,
|
||
Trip,
|
||
TripMember,
|
||
Day,
|
||
DayNote,
|
||
Place,
|
||
AssignmentPlace,
|
||
PlaceCategory,
|
||
Assignment,
|
||
AssignmentParticipant,
|
||
PackingItem,
|
||
PackingBag,
|
||
PackingBagMember,
|
||
BudgetItem,
|
||
BudgetItemMember,
|
||
Reservation,
|
||
ReservationEndpoint,
|
||
Accommodation,
|
||
Tag,
|
||
Category,
|
||
AppearanceConfig,
|
||
} from '@trek/shared'
|
||
|
||
export type {
|
||
Trip,
|
||
TripMember,
|
||
Day,
|
||
DayNote,
|
||
Place,
|
||
AssignmentPlace,
|
||
PlaceCategory,
|
||
Assignment,
|
||
AssignmentParticipant,
|
||
PackingItem,
|
||
PackingBag,
|
||
PackingBagMember,
|
||
BudgetItem,
|
||
BudgetItemMember,
|
||
Reservation,
|
||
ReservationEndpoint,
|
||
Accommodation,
|
||
Tag,
|
||
Category,
|
||
AppearanceConfig,
|
||
}
|
||
|
||
export interface User {
|
||
id: number
|
||
username: string
|
||
email: string
|
||
role: 'admin' | 'user'
|
||
avatar_url: string | null
|
||
maps_api_key: string | null
|
||
created_at: string
|
||
/** Present after load; true when TOTP MFA is enabled for password login */
|
||
mfa_enabled?: boolean
|
||
/** True when a password change is required before the user can continue */
|
||
must_change_password?: boolean
|
||
}
|
||
|
||
export interface TodoItem {
|
||
id: number
|
||
trip_id: number
|
||
name: string
|
||
category: string | null
|
||
checked: number
|
||
sort_order: number
|
||
due_date: string | null
|
||
description: string | null
|
||
assigned_user_id: number | null
|
||
priority: number
|
||
}
|
||
|
||
export interface TripFile {
|
||
id: number
|
||
trip_id: number
|
||
place_id?: number | null
|
||
reservation_id?: number | null
|
||
note_id?: number | null
|
||
uploaded_by?: number | null
|
||
uploaded_by_name?: string | null
|
||
uploaded_by_avatar?: string | null
|
||
filename: string
|
||
original_name: string
|
||
file_size?: number | null
|
||
mime_type: string
|
||
description?: string | null
|
||
starred?: number
|
||
deleted_at?: string | null
|
||
created_at: string
|
||
reservation_title?: string
|
||
linked_reservation_ids?: (number | null)[]
|
||
linked_place_ids?: (number | null)[]
|
||
/** Served download path — always present on list/create/update responses (formatFile). */
|
||
url: string
|
||
}
|
||
|
||
export type DistanceUnit = 'metric' | 'imperial'
|
||
|
||
export interface Settings {
|
||
map_tile_url: string
|
||
dark_mode: boolean | string
|
||
/** Display currency for Costs. Empty/null = follow each trip's own currency. */
|
||
default_currency: string | null
|
||
language: string
|
||
temperature_unit: string
|
||
distance_unit?: DistanceUnit
|
||
time_format: string
|
||
show_place_description: boolean
|
||
blur_booking_codes?: boolean
|
||
map_booking_labels?: boolean
|
||
map_poi_pill_enabled?: boolean
|
||
map_always_show_routes?: boolean
|
||
optimize_from_accommodation?: boolean
|
||
map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl'
|
||
/** Leaflet base layer: default street tiles or a satellite/aerial view. */
|
||
map_base_layer?: 'default' | 'satellite'
|
||
mapbox_access_token?: string
|
||
mapbox_style?: string
|
||
maplibre_style?: string
|
||
mapbox_3d_enabled?: boolean
|
||
mapbox_quality_mode?: boolean
|
||
// Dashboard widget prefs — persisted server-side so a (docker) upgrade keeps them (#1311).
|
||
dashboard_fx_from?: string
|
||
dashboard_fx_to?: string
|
||
dashboard_timezones?: string[]
|
||
/** Where opening TREK lands: the dashboard, or straight in the active trip. */
|
||
start_page?: 'dashboard' | 'active_trip'
|
||
/** Which planner tab 'active_trip' opens on — a TripTabId (constants/tripTabs). */
|
||
start_trip_tab?: string
|
||
// AI booking-import fallback (per-user config; used when the admin has not set
|
||
// instance-wide config on the llm_parsing addon). llm_api_key is masked on read.
|
||
llm_provider?: 'local' | 'openai' | 'anthropic'
|
||
llm_model?: string
|
||
llm_base_url?: string
|
||
llm_multimodal?: boolean
|
||
llm_api_key?: string
|
||
/** Per-user appearance/customization config (theming, transparency, typography, dashboard widgets). */
|
||
appearance?: AppearanceConfig
|
||
}
|
||
|
||
export interface AssignmentsMap {
|
||
[dayId: string]: Assignment[]
|
||
}
|
||
|
||
export interface DayNotesMap {
|
||
[dayId: string]: DayNote[]
|
||
}
|
||
|
||
export interface RouteSegment {
|
||
mid: [number, number]
|
||
from: [number, number]
|
||
to: [number, number]
|
||
distance: number
|
||
duration: number
|
||
walkingText: string
|
||
drivingText: string
|
||
distanceText: string
|
||
durationText?: string
|
||
/** Extra text a plugin route attached to this leg (e.g. "25 min charge"). */
|
||
noteText?: string
|
||
/** The travel mode this leg was routed with (#1281) — drives the connector icon. */
|
||
mode?: string
|
||
}
|
||
|
||
/** An intermediate stop a plugin route places on the drawn line (charging stop, rest area). */
|
||
export interface RouteVia {
|
||
lat: number
|
||
lng: number
|
||
label?: string
|
||
tone: 'default' | 'success' | 'warn' | 'danger'
|
||
dwellSeconds?: number
|
||
}
|
||
|
||
export interface RouteWithLegs {
|
||
coordinates: [number, number][]
|
||
distance: number
|
||
duration: number
|
||
legs: RouteSegment[]
|
||
/** Present on plugin-provided routes only. */
|
||
vias?: RouteVia[]
|
||
}
|
||
|
||
export interface RouteResult {
|
||
coordinates: [number, number][]
|
||
distance: number
|
||
duration: number
|
||
distanceText: string
|
||
durationText: string
|
||
walkingText: string
|
||
drivingText: string
|
||
}
|
||
|
||
export interface Waypoint {
|
||
lat: number
|
||
lng: number
|
||
}
|
||
|
||
// Optional fixed start/end points for route optimization (e.g. the day's accommodation).
|
||
export interface RouteAnchors {
|
||
start?: Waypoint
|
||
end?: Waypoint
|
||
}
|
||
|
||
// User with optional OIDC fields
|
||
export interface UserWithOidc extends User {
|
||
oidc_issuer?: string | null
|
||
}
|
||
|
||
// Atlas place detail
|
||
export interface AtlasPlace {
|
||
id: number
|
||
name: string
|
||
lat: number | null
|
||
lng: number | null
|
||
}
|
||
|
||
// GeoJSON types (simplified for atlas map)
|
||
export interface GeoJsonFeature {
|
||
type: 'Feature'
|
||
properties: Record<string, string | number | null | undefined>
|
||
geometry: {
|
||
type: string
|
||
coordinates: unknown
|
||
}
|
||
id?: string
|
||
}
|
||
|
||
export interface GeoJsonFeatureCollection {
|
||
type: 'FeatureCollection'
|
||
features: GeoJsonFeature[]
|
||
}
|
||
|
||
// App config from /auth/app-config
|
||
export interface AppConfig {
|
||
has_users: boolean
|
||
allow_registration: boolean
|
||
/** True when the operator of this install owns its configuration, not the admin */
|
||
managed?: boolean
|
||
demo_mode: boolean
|
||
oidc_configured: boolean
|
||
oidc_display_name?: string
|
||
oidc_only_mode?: boolean
|
||
has_maps_key?: boolean
|
||
allowed_file_types?: string
|
||
timezone?: string
|
||
/** When true, users without MFA cannot use the app until they enable it */
|
||
require_mfa?: boolean
|
||
// Granular auth toggles
|
||
password_login?: boolean
|
||
password_registration?: boolean
|
||
oidc_login?: boolean
|
||
oidc_registration?: boolean
|
||
env_override_oidc_only?: boolean
|
||
}
|
||
|
||
// Translation function type
|
||
export type TranslationFn = (key: string, params?: Record<string, string | number | null>) => string
|
||
|
||
// WebSocket event type — `type` is derived from the shared WS event registry
|
||
// (TREK_WS_EVENTS): a registered name, the reserved plugin namespace, or (via
|
||
// the `string & {}` widening) a transport control frame the registry
|
||
// deliberately excludes (welcome/joined/left/error). Payload fields stay
|
||
// index-typed at this boundary; per-event payload contracts live in
|
||
// TrekWsPayload<E> from @trek/shared.
|
||
export interface WebSocketEvent {
|
||
type: TrekWsEventName | TrekWsPluginEventName | (string & {})
|
||
[key: string]: unknown
|
||
}
|
||
|
||
// Vacay types
|
||
export interface VacayHolidayCalendar {
|
||
id: number
|
||
plan_id: number
|
||
type?: 'public_holiday' | 'school_holiday'
|
||
region: string
|
||
label: string | null
|
||
color: string
|
||
sort_order: number
|
||
}
|
||
|
||
export interface VacayPlan {
|
||
id: number
|
||
holidays_enabled: boolean
|
||
school_holidays_enabled?: boolean
|
||
holidays_region: string | null
|
||
holiday_calendars: VacayHolidayCalendar[]
|
||
block_weekends: boolean
|
||
carry_over_enabled: boolean
|
||
company_holidays_enabled: boolean
|
||
// Comma-separated weekday indices (e.g. '0,6'); stored as TEXT on vacay_plans.
|
||
weekend_days?: string
|
||
week_start?: number
|
||
name?: string
|
||
year?: number
|
||
owner_id?: number
|
||
created_at?: string
|
||
updated_at?: string
|
||
}
|
||
|
||
export interface VacayUser {
|
||
id: number
|
||
username: string
|
||
color: string | null
|
||
}
|
||
|
||
export interface VacayEntry {
|
||
date: string
|
||
user_id: number
|
||
plan_id?: number
|
||
person_color?: string
|
||
person_name?: string
|
||
// Portion of a vacation day this entry counts as: 1 = full day, 0.5 = half
|
||
// day (#552). Absent on legacy entries, which are treated as full days.
|
||
fraction?: number
|
||
// Leave type (#1074): 'comp' = flex/comp day (does not touch the entitlement),
|
||
// 'vacation' (or absent, for legacy entries) = a regular vacation day.
|
||
kind?: 'vacation' | 'comp'
|
||
}
|
||
|
||
// Vacay per-user stats row as returned by getStats
|
||
// (server/src/services/vacayService.ts -> getStats).
|
||
export interface VacayStat {
|
||
user_id: number
|
||
person_name: string
|
||
person_color: string
|
||
year: number
|
||
vacation_days: number
|
||
carried_over: number
|
||
total_available: number
|
||
used: number
|
||
remaining: number
|
||
// Comp/flex days used this year (#1074) — informational, not deducted from the
|
||
// entitlement. Absent on older server builds.
|
||
comp_used?: number
|
||
// The leave-year window this row was computed over (#737), as YYYY-MM-DD.
|
||
// `window_end` is exclusive. Absent on older server builds.
|
||
window_start?: string
|
||
window_end?: string
|
||
}
|
||
|
||
export type VacayYearType = 'calendar' | 'fiscal' | 'anniversary'
|
||
|
||
/**
|
||
* Per-user leave-year configuration (#737). 'calendar' is the unchanged Jan–Dec
|
||
* default, 'fiscal' starts on a fixed month/day, 'anniversary' on the month/day
|
||
* of the hire date.
|
||
*/
|
||
export interface VacayYearSettings {
|
||
year_type: VacayYearType
|
||
year_start_month: number
|
||
year_start_day: number
|
||
hire_date: string | null
|
||
}
|
||
|
||
export interface HolidayInfo {
|
||
name: string
|
||
localName: string
|
||
color: string
|
||
label: string | null
|
||
type?: 'public_holiday' | 'school_holiday'
|
||
}
|
||
|
||
export interface HolidaysMap {
|
||
[date: string]: HolidayInfo | HolidayInfo[]
|
||
}
|
||
|
||
// Read-only calendar shares (#444/#667)
|
||
export interface VacayShareOutgoing {
|
||
id: number
|
||
user_id: number
|
||
username: string
|
||
}
|
||
|
||
export interface VacayShareIncoming {
|
||
id: number
|
||
owner_id: number
|
||
username: string
|
||
color: string
|
||
hidden: boolean
|
||
}
|
||
|
||
export interface SharedVacayCalendar {
|
||
share_id: number
|
||
owner_id: number
|
||
owner_name: string
|
||
color: string
|
||
hidden: boolean
|
||
entries: { date: string; fraction?: number; kind?: 'vacation' | 'comp' }[]
|
||
companyHolidays: { date: string; note?: string }[]
|
||
}
|
||
|
||
// API error shape from axios
|
||
export interface ApiError {
|
||
response?: {
|
||
data?: {
|
||
error?: string
|
||
}
|
||
status?: number
|
||
}
|
||
message: string
|
||
}
|
||
|
||
/** Safely extract an error message from an unknown catch value */
|
||
export function getApiErrorMessage(err: unknown, fallback: string): string {
|
||
if (typeof err === 'object' && err !== null && 'response' in err) {
|
||
const apiErr = err as ApiError
|
||
// Axios' own message ("Request failed with status code 500") is untranslated
|
||
// boilerplate, so only the server's error text beats the localized fallback.
|
||
return apiErr.response?.data?.error || fallback
|
||
}
|
||
if (err instanceof Error) return err.message
|
||
return fallback
|
||
}
|
||
|
||
// MergedItem used in day notes hook
|
||
export interface MergedItem {
|
||
type: 'assignment' | 'note' | 'place' | 'transport'
|
||
sortKey: number
|
||
data: Assignment | DayNote | Reservation
|
||
}
|