mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-23 15:21:46 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ab8b401fb | |||
| 49af7a8b0d | |||
| dd90c6d424 | |||
| 3d887f15ab | |||
| 82bb08e685 | |||
| 4f3368502a | |||
| 0d534f13cf | |||
| ffa10cac65 | |||
| b85f8c5bca | |||
| da39b570eb | |||
| 151950d08a | |||
| e562d7a7ec | |||
| d0383c06c3 | |||
| 5978eec270 | |||
| 242d1bf8d4 | |||
| 4a8260dfbc | |||
| 076a752ee7 | |||
| 545d62c400 | |||
| f8542b4d87 |
@@ -16,6 +16,7 @@ structured API.
|
||||
- [Limitations & Important Notes](#limitations--important-notes)
|
||||
- [Resources (read-only)](#resources-read-only)
|
||||
- [Tools (read-write)](#tools-read-write)
|
||||
- [Compound Tools](#compound-tools)
|
||||
- [Prompts](#prompts)
|
||||
- [Example](#example)
|
||||
|
||||
@@ -52,10 +53,11 @@ management required — just provide the server URL:
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
|
||||
**What happens automatically:**
|
||||
1. The client fetches `/.well-known/oauth-authorization-server` to discover the TREK authorization server.
|
||||
2. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
|
||||
3. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
|
||||
4. The client receives a short-lived access token and a rotating refresh token — no re-authorization needed.
|
||||
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
|
||||
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
|
||||
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
|
||||
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
|
||||
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
|
||||
|
||||
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
|
||||
> discovery to work correctly.
|
||||
@@ -140,13 +142,17 @@ that match your granted scopes for that session.
|
||||
| `vacay:write` | Manage vacation plans | Vacation |
|
||||
| `geo:read` | Maps & geocoding | Geo |
|
||||
| `weather:read` | Weather forecasts | Weather |
|
||||
| `journey:read` | View journeys | Journey |
|
||||
| `journey:write` | Manage journeys | Journey |
|
||||
| `journey:share` | Manage journey share links | Journey |
|
||||
|
||||
**Scope rules:**
|
||||
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
|
||||
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
|
||||
- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access.
|
||||
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
|
||||
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
|
||||
- Addon-gated tools (Atlas Extended, Collab, Vacay) require both the relevant scope **and** the addon to be enabled.
|
||||
- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
|
||||
|
||||
---
|
||||
|
||||
@@ -167,7 +173,7 @@ that match your granted scopes for that session.
|
||||
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
|
||||
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
|
||||
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
|
||||
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay) is enabled by an admin. |
|
||||
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. |
|
||||
|
||||
---
|
||||
|
||||
@@ -194,7 +200,6 @@ making changes.
|
||||
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
|
||||
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
|
||||
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
|
||||
| Files | `trek://trips/{tripId}/files` | Files attached to a trip (excludes trashed files) |
|
||||
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
|
||||
| Categories | `trek://categories` | Available place categories (for use when creating places) |
|
||||
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
|
||||
@@ -214,6 +219,10 @@ These resources are only available when the corresponding addon is enabled by an
|
||||
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
|
||||
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
|
||||
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
|
||||
| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user |
|
||||
| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips |
|
||||
| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) |
|
||||
| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey |
|
||||
|
||||
---
|
||||
|
||||
@@ -226,7 +235,23 @@ trip in a single call.
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, files, and poll/message counts. Use this as your context loader. |
|
||||
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. |
|
||||
|
||||
### Compound Tools
|
||||
|
||||
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
|
||||
|
||||
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
|
||||
|
||||
| Tool | Wraps | Description |
|
||||
|---|---|---|
|
||||
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
|
||||
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
|
||||
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
|
||||
|
||||
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
|
||||
|
||||
---
|
||||
|
||||
### Trips
|
||||
|
||||
@@ -247,14 +272,18 @@ trip in a single call.
|
||||
|
||||
### Places
|
||||
|
||||
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
|
||||
|
||||
| Tool | Description |
|
||||
|------------------|--------------------------------------------------------------------------------------------------|
|
||||
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
|
||||
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
|
||||
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
|
||||
| `delete_place` | Remove a place from a trip. |
|
||||
| `list_categories`| List all available place categories with id, name, icon and color. |
|
||||
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
|
||||
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
|
||||
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
|
||||
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
|
||||
| `delete_place` | Remove a place from a trip. |
|
||||
| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** |
|
||||
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
|
||||
| `list_categories` | List all available place categories with id, name, icon and color. |
|
||||
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
|
||||
|
||||
### Day Planning
|
||||
|
||||
@@ -273,24 +302,40 @@ trip in a single call.
|
||||
|
||||
### Accommodations
|
||||
|
||||
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
|
||||
|
||||
| Tool | Description |
|
||||
|------------------------|------------------------------------------------------------------------------------------|
|
||||
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
|
||||
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
|
||||
| `delete_accommodation` | Delete an accommodation record from a trip. |
|
||||
|
||||
### Transport
|
||||
|
||||
Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight.
|
||||
|
||||
| Tool | Description |
|
||||
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. |
|
||||
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. |
|
||||
| `delete_transport` | Delete a transport booking from a trip. |
|
||||
|
||||
### Reservations
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
||||
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||
| `reorder_reservations` | Update the display order of reservations within a day. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types.
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
||||
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||
|
||||
### Budget
|
||||
|
||||
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|---------------------------------------------------------------------------------------|
|
||||
| `create_budget_item` | Add an expense with name, category, and price. |
|
||||
@@ -370,7 +415,14 @@ trip in a single call.
|
||||
| `get_weather` | Get weather forecast for a location and date. |
|
||||
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
|
||||
|
||||
### Collab Notes
|
||||
### Airports
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
|
||||
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
|
||||
|
||||
### Collab Notes _(Collab addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------|-------------------------------------------------------------------------------------------------|
|
||||
@@ -392,14 +444,14 @@ trip in a single call.
|
||||
| `delete_collab_message`| Delete a chat message (own messages only). |
|
||||
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
|
||||
|
||||
### Bucket List
|
||||
### Bucket List _(Atlas addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------------|--------------------------------------------------------------------------------------------|
|
||||
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
|
||||
| `delete_bucket_list_item` | Remove an item from your bucket list. |
|
||||
|
||||
### Atlas
|
||||
### Atlas _(Atlas addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------------|---------------------------------------------------------------------------------|
|
||||
@@ -444,6 +496,33 @@ trip in a single call.
|
||||
| `list_holiday_countries` | List countries available for public holiday calendars. |
|
||||
| `list_holidays` | List public holidays for a country and year. |
|
||||
|
||||
### Journey _(Journey addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
|
||||
| `list_journeys` | List all journeys owned or contributed to by the current user. |
|
||||
| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. |
|
||||
| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. |
|
||||
| `update_journey` | Update a journey's title, subtitle, or status. |
|
||||
| `delete_journey` | Delete a journey. |
|
||||
| `add_journey_trip` | Link an existing trip to a journey. |
|
||||
| `remove_journey_trip` | Remove a trip from a journey. |
|
||||
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
|
||||
| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. |
|
||||
| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. |
|
||||
| `delete_journey_entry` | Remove an entry from a journey. |
|
||||
| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. |
|
||||
| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). |
|
||||
| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. |
|
||||
| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. |
|
||||
| `remove_journey_contributor` | Remove a contributor from a journey. |
|
||||
| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). |
|
||||
| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). |
|
||||
| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. |
|
||||
| `get_journey_share_link` | Get the current public share link for a journey. |
|
||||
| `create_journey_share_link` | Create or update the public share link for a journey. |
|
||||
| `delete_journey_share_link` | Revoke the public share link for a journey. |
|
||||
|
||||
---
|
||||
|
||||
## Prompts
|
||||
|
||||
@@ -32,8 +32,8 @@ describe('SCOPE_GROUPS', () => {
|
||||
})
|
||||
|
||||
describe('ALL_SCOPES', () => {
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly 24 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(24)
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(27)
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
|
||||
|
||||
@@ -38,6 +38,9 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
|
||||
'vacay:write': { labelKey: 'oauth.scope.vacay:write.label', descriptionKey: 'oauth.scope.vacay:write.description', groupKey: 'oauth.scope.group.vacay' },
|
||||
'geo:read': { labelKey: 'oauth.scope.geo:read.label', descriptionKey: 'oauth.scope.geo:read.description', groupKey: 'oauth.scope.group.geo' },
|
||||
'weather:read': { labelKey: 'oauth.scope.weather:read.label', descriptionKey: 'oauth.scope.weather:read.description', groupKey: 'oauth.scope.group.weather' },
|
||||
'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' },
|
||||
'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' },
|
||||
'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' },
|
||||
}
|
||||
|
||||
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Menu, X, type LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface PageSidebarTab {
|
||||
id: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
interface PageSidebarProps {
|
||||
/** Uppercase label shown above the tab list, e.g. "SETTINGS". */
|
||||
sidebarLabel: string
|
||||
tabs: PageSidebarTab[]
|
||||
activeTab: string
|
||||
onTabChange: (id: string) => void
|
||||
children: React.ReactNode
|
||||
/** Small text at the very bottom of the sidebar (e.g. "v3.0 · self-hosted"). */
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-sidebar + right-panel layout used by the Settings and Admin pages.
|
||||
*
|
||||
* Desktop (>=1024px): sidebar is always visible at 260px; panel fills rest.
|
||||
* Mobile: sidebar collapses behind a hamburger at the top of the panel; tap
|
||||
* the hamburger to slide the sidebar in as an overlay, tap a tab to close.
|
||||
*/
|
||||
export default function PageSidebar({
|
||||
sidebarLabel,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
children,
|
||||
footer,
|
||||
}: PageSidebarProps): React.ReactElement {
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const activeLabel = tabs.find(t => t.id === activeTab)?.label ?? ''
|
||||
|
||||
// Close the mobile drawer on Escape or on outside click.
|
||||
const drawerRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMobileOpen(false) }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [mobileOpen])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden flex flex-col lg:flex-row relative"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
minHeight: 'min(820px, calc(100vh - var(--nav-h) - 120px))',
|
||||
}}
|
||||
>
|
||||
{/* Mobile top bar with hamburger */}
|
||||
<div
|
||||
className="lg:hidden flex items-center justify-between px-4 py-3 border-b"
|
||||
style={{ borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
|
||||
aria-label="Open navigation"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{activeLabel}
|
||||
</div>
|
||||
<div className="w-9" />
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar (always visible on lg) */}
|
||||
<aside
|
||||
className="hidden lg:flex flex-col shrink-0 relative"
|
||||
style={{
|
||||
width: 260,
|
||||
background: 'var(--bg-secondary)',
|
||||
borderRight: '1px solid var(--border-primary)',
|
||||
padding: '24px 14px',
|
||||
}}
|
||||
>
|
||||
<SidebarInner
|
||||
sidebarLabel={sidebarLabel}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
footer={footer}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<>
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40"
|
||||
style={{ background: 'rgba(0,0,0,0.35)' }}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
className="lg:hidden fixed top-0 left-0 bottom-0 z-50 flex flex-col shadow-2xl"
|
||||
style={{
|
||||
width: 280,
|
||||
background: 'var(--bg-secondary)',
|
||||
padding: '18px 14px',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 px-2">
|
||||
<span
|
||||
className="text-[11px] font-bold tracking-widest uppercase"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{sidebarLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
|
||||
aria-label="Close navigation"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<SidebarInner
|
||||
sidebarLabel={null}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => {
|
||||
onTabChange(id)
|
||||
setMobileOpen(false)
|
||||
}}
|
||||
footer={footer}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
<div className="flex-1 min-w-0" style={{ padding: '26px 28px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInner({
|
||||
sidebarLabel,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
footer,
|
||||
}: {
|
||||
sidebarLabel: string | null
|
||||
tabs: PageSidebarTab[]
|
||||
activeTab: string
|
||||
onTabChange: (id: string) => void
|
||||
footer?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{sidebarLabel && (
|
||||
<div
|
||||
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{sidebarLabel}
|
||||
</div>
|
||||
)}
|
||||
<nav className="flex flex-col gap-1 flex-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const active = tab.id === activeTab
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors"
|
||||
style={{
|
||||
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: active ? 600 : 500,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) e.currentTarget.style.background = 'var(--bg-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" />
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{footer && (
|
||||
<div
|
||||
className="mt-4 pt-3 px-3 text-[10px] tracking-wide"
|
||||
style={{ color: 'var(--text-faint)', borderTop: '1px solid var(--border-primary)' }}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
}
|
||||
} else {
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '' })
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ function TagChip({ tag }: { tag: string }) {
|
||||
}
|
||||
|
||||
function StyleDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -94,7 +95,7 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-slate-900 dark:text-white truncate">
|
||||
{selected ? selected.name : 'Select a Mapbox style'}
|
||||
{selected ? selected.name : t('settings.mapStylePlaceholder')}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
@@ -213,7 +214,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
{/* Provider picker — big cards so the choice is obvious */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Map Provider</label>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -227,7 +228,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
<Layers size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">Leaflet</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">Classic 2D, any raster tiles</div>
|
||||
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapLeafletSubtitle')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -240,17 +241,17 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
}`}
|
||||
>
|
||||
<span className="absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
||||
Experimental
|
||||
{t('settings.mapExperimental')}
|
||||
</span>
|
||||
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">Mapbox GL</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">Vector tiles, 3D buildings & terrain</div>
|
||||
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
Affects Trip Planner and Journey maps. Atlas always uses Leaflet.
|
||||
{t('settings.mapProviderHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -281,7 +282,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
{provider === 'mapbox-gl' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Mapbox Access Token</label>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
@@ -290,15 +291,15 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Public token (pk.*) from{' '}
|
||||
{t('settings.mapMapboxTokenHint')}{' '}
|
||||
<a href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noreferrer" className="underline">
|
||||
mapbox.com → Access tokens
|
||||
{t('settings.mapMapboxTokenLink')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Map Style</label>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label>
|
||||
<div className="mb-2">
|
||||
<StyleDropdown value={mapboxStyle} onChange={setMapboxStyle} />
|
||||
</div>
|
||||
@@ -310,7 +311,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Preset or your own <code className="text-[11px]">mapbox://styles/USER/ID</code> URL
|
||||
{t('settings.mapStyleHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -320,9 +321,9 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
: 'border-slate-200 opacity-60 dark:border-slate-700'
|
||||
}`}>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">3D Buildings & Terrain</div>
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">{t('settings.map3dBuildings')}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
Pitch + real 3D building extrusions — works on every style, including satellite.
|
||||
{t('settings.map3dHint')}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
@@ -333,22 +334,22 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white flex items-center gap-2">
|
||||
High Quality Mode
|
||||
<span className="text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
||||
Experimental
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="order-2 sm:order-1">{t('settings.mapHighQuality')}</span>
|
||||
<span className="order-1 sm:order-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
||||
{t('settings.mapExperimental')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
Antialiasing + globe projection for sharper edges and a realistic world view.{' '}
|
||||
<span className="text-amber-600 dark:text-amber-400">May impact performance on lower-end devices.</span>
|
||||
{t('settings.mapHighQualityHint')}{' '}
|
||||
<span className="text-amber-600 dark:text-amber-400">{t('settings.mapHighQualityWarning')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={mapboxQuality} onToggle={() => setMapboxQuality(!mapboxQuality)} />
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
|
||||
<strong className="text-slate-600 dark:text-slate-300">Tip:</strong> right-click and drag to rotate/pitch the map. Middle-click to add a place (right-click is reserved for rotation).
|
||||
<strong className="text-slate-600 dark:text-slate-300">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const isTestEnv = typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent ?? '')
|
||||
|
||||
// Zählt beim Mount von 0 auf target hoch. Feste Dauer mit ease-out-quint.
|
||||
export function useCountUp(target: number, duration = 800): number {
|
||||
const [value, setValue] = useState(0)
|
||||
const [value, setValue] = useState(() => isTestEnv || target <= 0 ? target : 0)
|
||||
const startRef = useRef<number | null>(null)
|
||||
const frameRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
|
||||
const isJsdom = typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent ?? '')
|
||||
if (reduced || isJsdom || target <= 0) { setValue(target); return }
|
||||
if (reduced || isTestEnv || target <= 0) { setValue(target); return }
|
||||
|
||||
startRef.current = null
|
||||
const step = (now: number) => {
|
||||
|
||||
@@ -161,6 +161,24 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'اتركه فارغًا لاستخدام OpenStreetMap افتراضيًا',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'قالب URL لبلاطات الخريطة',
|
||||
'settings.mapProvider': 'مزود الخريطة',
|
||||
'settings.mapProviderHint': 'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.',
|
||||
'settings.mapLeafletSubtitle': '2D كلاسيكي، أي بلاطات نقطية',
|
||||
'settings.mapMapboxSubtitle': 'بلاطات متجهية ومبانٍ ثلاثية الأبعاد وتضاريس',
|
||||
'settings.mapExperimental': 'تجريبي',
|
||||
'settings.mapMapboxToken': 'رمز وصول Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'الرمز العام (pk.*) من',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com ← رموز الوصول',
|
||||
'settings.mapStyle': 'نمط الخريطة',
|
||||
'settings.mapStylePlaceholder': 'اختر نمط Mapbox',
|
||||
'settings.mapStyleHint': 'إعداد مسبق أو عنوان URL mapbox://styles/USER/ID خاص بك',
|
||||
'settings.map3dBuildings': 'مبانٍ ثلاثية الأبعاد وتضاريس',
|
||||
'settings.map3dHint': 'إمالة + مبانٍ ثلاثية الأبعاد حقيقية — يعمل مع كل نمط بما في ذلك الأقمار الصناعية.',
|
||||
'settings.mapHighQuality': 'وضع الجودة العالية',
|
||||
'settings.mapHighQualityHint': 'تحسين الحواف + إسقاط كروي لحواف أكثر حدة وعرض واقعي للعالم.',
|
||||
'settings.mapHighQualityWarning': 'قد يؤثر على الأداء في الأجهزة الأقل قدرة.',
|
||||
'settings.mapTipLabel': 'نصيحة:',
|
||||
'settings.mapTip': 'انقر بزر الماوس الأيمن واسحب لتدوير/إمالة الخريطة. النقر الأوسط لإضافة مكان (النقر الأيمن مخصص للتدوير).',
|
||||
'settings.latitude': 'خط العرض',
|
||||
'settings.longitude': 'خط الطول',
|
||||
'settings.saveMap': 'حفظ الخريطة',
|
||||
@@ -1992,6 +2010,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'الإجازة',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'الطقس',
|
||||
'oauth.scope.group.journey': 'مذكرة السفر',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'عرض الرحلات وخطط السفر',
|
||||
@@ -2042,6 +2061,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات',
|
||||
'oauth.scope.weather:read.label': 'توقعات الطقس',
|
||||
'oauth.scope.weather:read.description': 'جلب توقعات الطقس لمواقع الرحلة وتواريخها',
|
||||
'oauth.scope.journey:read.label': 'عرض مذكرات السفر',
|
||||
'oauth.scope.journey:read.description': 'قراءة مذكرات السفر والمدخلات وقائمة المساهمين',
|
||||
'oauth.scope.journey:write.label': 'إدارة مذكرات السفر',
|
||||
'oauth.scope.journey:write.description': 'إنشاء مذكرات السفر وتحديثها وحذفها وإدخالاتها',
|
||||
'oauth.scope.journey:share.label': 'إدارة روابط مذكرات السفر',
|
||||
'oauth.scope.journey:share.description': 'إنشاء روابط مشاركة عامة لمذكرات السفر وتحديثها وإلغاؤها',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'مرحبًا بك في TREK',
|
||||
|
||||
@@ -156,6 +156,24 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL do modelo de blocos do mapa',
|
||||
'settings.mapProvider': 'Provedor de mapa',
|
||||
'settings.mapProviderHint': 'Afeta os mapas do Planejador de Viagem e Diário. Atlas sempre usa Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Clássico 2D, quaisquer blocos raster',
|
||||
'settings.mapMapboxSubtitle': 'Blocos vetoriais, prédios 3D & terreno',
|
||||
'settings.mapExperimental': 'Experimental',
|
||||
'settings.mapMapboxToken': 'Token de acesso Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Tokens de acesso',
|
||||
'settings.mapStyle': 'Estilo do mapa',
|
||||
'settings.mapStylePlaceholder': 'Selecionar um estilo Mapbox',
|
||||
'settings.mapStyleHint': 'Preset ou sua própria URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': 'Prédios 3D & terreno',
|
||||
'settings.map3dHint': 'Inclinação + extrusões 3D reais de prédios — funciona em todo estilo, incluindo satélite.',
|
||||
'settings.mapHighQuality': 'Modo alta qualidade',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + projeção global para bordas mais nítidas e uma visão realista do mundo.',
|
||||
'settings.mapHighQualityWarning': 'Pode afetar o desempenho em dispositivos menos potentes.',
|
||||
'settings.mapTipLabel': 'Dica:',
|
||||
'settings.mapTip': 'Clique direito e arraste para girar/inclinar o mapa. Clique do meio para adicionar um local (o clique direito é reservado para rotação).',
|
||||
'settings.latitude': 'Latitude',
|
||||
'settings.longitude': 'Longitude',
|
||||
'settings.saveMap': 'Salvar mapa',
|
||||
@@ -2195,6 +2213,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Férias',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Clima',
|
||||
'oauth.scope.group.journey': 'Jornada',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Ver viagens e itinerários',
|
||||
@@ -2245,6 +2264,12 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Pesquisar locais, resolver URLs de mapa e geocodificar coordenadas',
|
||||
'oauth.scope.weather:read.label': 'Previsão do tempo',
|
||||
'oauth.scope.weather:read.description': 'Obter previsão do tempo para locais e datas da viagem',
|
||||
'oauth.scope.journey:read.label': 'Ver jornadas',
|
||||
'oauth.scope.journey:read.description': 'Ler jornadas, entradas e lista de colaboradores',
|
||||
'oauth.scope.journey:write.label': 'Gerenciar jornadas',
|
||||
'oauth.scope.journey:write.description': 'Criar, atualizar e excluir jornadas e suas entradas',
|
||||
'oauth.scope.journey:share.label': 'Gerenciar links de jornadas',
|
||||
'oauth.scope.journey:share.description': 'Criar, atualizar e revogar links de compartilhamento públicos para jornadas',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Bem-vindo ao TREK',
|
||||
|
||||
@@ -157,6 +157,24 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Ponechte prázdné pro OpenStreetMap (výchozí)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL šablony pro mapové dlaždice',
|
||||
'settings.mapProvider': 'Poskytovatel mapy',
|
||||
'settings.mapProviderHint': 'Ovlivňuje mapy v Trip Planneru a Journey. Atlas vždy používá Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Klasické 2D, libovolné rastrové dlaždice',
|
||||
'settings.mapMapboxSubtitle': 'Vektorové dlaždice, 3D budovy a terén',
|
||||
'settings.mapExperimental': 'Experimentální',
|
||||
'settings.mapMapboxToken': 'Mapbox přístupový token',
|
||||
'settings.mapMapboxTokenHint': 'Veřejný token (pk.*) z',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Přístupové tokeny',
|
||||
'settings.mapStyle': 'Styl mapy',
|
||||
'settings.mapStylePlaceholder': 'Vyberte styl Mapbox',
|
||||
'settings.mapStyleHint': 'Preset nebo vaše vlastní URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': '3D budovy a terén',
|
||||
'settings.map3dHint': 'Náklon + skutečné 3D vyvýšení budov — funguje s každým stylem, včetně satelitu.',
|
||||
'settings.mapHighQuality': 'Režim vysoké kvality',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + zobrazení glóbu pro ostřejší hrany a realistický pohled na svět.',
|
||||
'settings.mapHighQualityWarning': 'Může ovlivnit výkon na slabších zařízeních.',
|
||||
'settings.mapTipLabel': 'Tip:',
|
||||
'settings.mapTip': 'Pravé tlačítko myši a táhněte pro rotaci/náklon mapy. Prostřední tlačítko pro přidání místa (pravé tlačítko je vyhrazeno pro rotaci).',
|
||||
'settings.latitude': 'Zeměpisná šířka',
|
||||
'settings.longitude': 'Zeměpisná délka',
|
||||
'settings.saveMap': 'Uložit nastavení mapy',
|
||||
@@ -2199,6 +2217,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Dovolená',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Počasí',
|
||||
'oauth.scope.group.journey': 'Cestovní deník',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Zobrazit výlety a itineráře',
|
||||
@@ -2249,6 +2268,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Vyhledávat místa, řešit URL map a zpětně geokódovat souřadnice',
|
||||
'oauth.scope.weather:read.label': 'Předpovědi počasí',
|
||||
'oauth.scope.weather:read.description': 'Získávat předpovědi počasí pro místa a data výletu',
|
||||
'oauth.scope.journey:read.label': 'Zobrazit cestovní deníky',
|
||||
'oauth.scope.journey:read.description': 'Číst cestovní deníky, záznamy a seznam přispěvatelů',
|
||||
'oauth.scope.journey:write.label': 'Spravovat cestovní deníky',
|
||||
'oauth.scope.journey:write.description': 'Vytvářet, aktualizovat a mazat cestovní deníky a jejich záznamy',
|
||||
'oauth.scope.journey:share.label': 'Spravovat odkazy na cestovní deníky',
|
||||
'oauth.scope.journey:share.description': 'Vytvářet, aktualizovat a rušit veřejné sdílené odkazy na cestovní deníky',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Vítejte v TREK',
|
||||
|
||||
@@ -159,6 +159,24 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Leer lassen für OpenStreetMap (Standard)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL-Template für die Kartenkacheln',
|
||||
'settings.mapProvider': 'Kartenanbieter',
|
||||
'settings.mapProviderHint': 'Gilt für Trip Planner und Journey. Atlas nutzt immer Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Klassisch 2D, beliebige Raster-Kacheln',
|
||||
'settings.mapMapboxSubtitle': 'Vektor-Kacheln, 3D-Gebäude & Terrain',
|
||||
'settings.mapExperimental': 'Experimentell',
|
||||
'settings.mapMapboxToken': 'Mapbox Access Token',
|
||||
'settings.mapMapboxTokenHint': 'Öffentliches Token (pk.*) von',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Access Tokens',
|
||||
'settings.mapStyle': 'Kartenstil',
|
||||
'settings.mapStylePlaceholder': 'Mapbox-Stil wählen',
|
||||
'settings.mapStyleHint': 'Preset oder eigene mapbox://styles/USER/ID URL',
|
||||
'settings.map3dBuildings': '3D-Gebäude & Terrain',
|
||||
'settings.map3dHint': 'Neigung + echte 3D-Gebäude-Extrusionen — funktioniert mit jedem Stil, auch Satellit.',
|
||||
'settings.mapHighQuality': 'Hochqualitäts-Modus',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + Globus-Projektion für schärfere Kanten und eine realistische Weltsicht.',
|
||||
'settings.mapHighQualityWarning': 'Kann die Performance auf schwächeren Geräten beeinträchtigen.',
|
||||
'settings.mapTipLabel': 'Tipp:',
|
||||
'settings.mapTip': 'Rechtsklick und ziehen, um die Karte zu drehen/neigen. Mittelklick, um einen Ort hinzuzufügen (Rechtsklick ist für die Rotation reserviert).',
|
||||
'settings.latitude': 'Breitengrad',
|
||||
'settings.longitude': 'Längengrad',
|
||||
'settings.saveMap': 'Karte speichern',
|
||||
@@ -2205,6 +2223,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Urlaub',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Wetter',
|
||||
'oauth.scope.group.journey': 'Journey',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Reisen und Reisepläne anzeigen',
|
||||
@@ -2255,6 +2274,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Orte suchen, Karten-URLs auflösen und Koordinaten rückwärts geokodieren',
|
||||
'oauth.scope.weather:read.label': 'Wettervorhersagen',
|
||||
'oauth.scope.weather:read.description': 'Wettervorhersagen für Reiseorte und -daten abrufen',
|
||||
'oauth.scope.journey:read.label': 'Journeys ansehen',
|
||||
'oauth.scope.journey:read.description': 'Journeys, Einträge und Mitarbeiterliste lesen',
|
||||
'oauth.scope.journey:write.label': 'Journeys verwalten',
|
||||
'oauth.scope.journey:write.description': 'Journeys und deren Einträge erstellen, bearbeiten und löschen',
|
||||
'oauth.scope.journey:share.label': 'Journey-Links verwalten',
|
||||
'oauth.scope.journey:share.description': 'Öffentliche Freigabelinks für Journeys erstellen, aktualisieren und widerrufen',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Willkommen bei TREK',
|
||||
|
||||
@@ -159,6 +159,24 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Leave empty for OpenStreetMap (default)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL template for map tiles',
|
||||
'settings.mapProvider': 'Map Provider',
|
||||
'settings.mapProviderHint': 'Affects Trip Planner and Journey maps. Atlas always uses Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Classic 2D, any raster tiles',
|
||||
'settings.mapMapboxSubtitle': 'Vector tiles, 3D buildings & terrain',
|
||||
'settings.mapExperimental': 'Experimental',
|
||||
'settings.mapMapboxToken': 'Mapbox Access Token',
|
||||
'settings.mapMapboxTokenHint': 'Public token (pk.*) from',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Access tokens',
|
||||
'settings.mapStyle': 'Map Style',
|
||||
'settings.mapStylePlaceholder': 'Select a Mapbox style',
|
||||
'settings.mapStyleHint': 'Preset or your own mapbox://styles/USER/ID URL',
|
||||
'settings.map3dBuildings': '3D Buildings & Terrain',
|
||||
'settings.map3dHint': 'Pitch + real 3D building extrusions — works on every style, including satellite.',
|
||||
'settings.mapHighQuality': 'High Quality Mode',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + globe projection for sharper edges and a realistic world view.',
|
||||
'settings.mapHighQualityWarning': 'May impact performance on lower-end devices.',
|
||||
'settings.mapTipLabel': 'Tip:',
|
||||
'settings.mapTip': 'right-click and drag to rotate/pitch the map. Middle-click to add a place (right-click is reserved for rotation).',
|
||||
'settings.latitude': 'Latitude',
|
||||
'settings.longitude': 'Longitude',
|
||||
'settings.saveMap': 'Save Map',
|
||||
@@ -2242,6 +2260,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Vacation',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Weather',
|
||||
'oauth.scope.group.journey': 'Journey',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'View trips & itineraries',
|
||||
@@ -2292,6 +2311,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Search locations, resolve map URLs, and reverse geocode coordinates',
|
||||
'oauth.scope.weather:read.label': 'Weather forecasts',
|
||||
'oauth.scope.weather:read.description': 'Fetch weather forecasts for trip locations and dates',
|
||||
'oauth.scope.journey:read.label': 'View journeys',
|
||||
'oauth.scope.journey:read.description': 'Read journeys, entries, and contributor list',
|
||||
'oauth.scope.journey:write.label': 'Manage journeys',
|
||||
'oauth.scope.journey:write.description': 'Create, update, and delete journeys and their entries',
|
||||
'oauth.scope.journey:share.label': 'Manage journey links',
|
||||
'oauth.scope.journey:share.description': 'Create, update, and revoke public share links for journeys',
|
||||
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Photos have moved in 3.0',
|
||||
|
||||
@@ -157,6 +157,24 @@ const es: Record<string, string> = {
|
||||
'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa',
|
||||
'settings.mapProvider': 'Proveedor de mapa',
|
||||
'settings.mapProviderHint': 'Afecta a los mapas de Trip Planner y Journey. Atlas siempre usa Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Clásico 2D, cualquier mosaico raster',
|
||||
'settings.mapMapboxSubtitle': 'Mosaicos vectoriales, edificios 3D y terreno',
|
||||
'settings.mapExperimental': 'Experimental',
|
||||
'settings.mapMapboxToken': 'Token de acceso de Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Tokens de acceso',
|
||||
'settings.mapStyle': 'Estilo de mapa',
|
||||
'settings.mapStylePlaceholder': 'Seleccionar un estilo de Mapbox',
|
||||
'settings.mapStyleHint': 'Preset o tu propia URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': 'Edificios 3D y terreno',
|
||||
'settings.map3dHint': 'Inclinación + extrusiones 3D reales de edificios — funciona con todos los estilos, incluyendo satélite.',
|
||||
'settings.mapHighQuality': 'Modo de alta calidad',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + proyección global para bordes más nítidos y una vista realista del mundo.',
|
||||
'settings.mapHighQualityWarning': 'Puede afectar el rendimiento en dispositivos menos potentes.',
|
||||
'settings.mapTipLabel': 'Consejo:',
|
||||
'settings.mapTip': 'Clic derecho y arrastrar para rotar/inclinar el mapa. Clic central para añadir un lugar (el clic derecho está reservado para la rotación).',
|
||||
'settings.latitude': 'Latitud',
|
||||
'settings.longitude': 'Longitud',
|
||||
'settings.saveMap': 'Guardar mapa',
|
||||
@@ -2201,6 +2219,7 @@ const es: Record<string, string> = {
|
||||
'oauth.scope.group.vacay': 'Vacaciones',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Clima',
|
||||
'oauth.scope.group.journey': 'Travesía',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Ver viajes e itinerarios',
|
||||
@@ -2251,6 +2270,12 @@ const es: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Buscar lugares, resolver URLs de mapa y geocodificar coordenadas',
|
||||
'oauth.scope.weather:read.label': 'Previsiones meteorológicas',
|
||||
'oauth.scope.weather:read.description': 'Obtener previsiones meteorológicas para lugares y fechas del viaje',
|
||||
'oauth.scope.journey:read.label': 'Ver travesías',
|
||||
'oauth.scope.journey:read.description': 'Leer travesías, entradas y lista de colaboradores',
|
||||
'oauth.scope.journey:write.label': 'Gestionar travesías',
|
||||
'oauth.scope.journey:write.description': 'Crear, actualizar y eliminar travesías y sus entradas',
|
||||
'oauth.scope.journey:share.label': 'Gestionar enlaces de travesías',
|
||||
'oauth.scope.journey:share.description': 'Crear, actualizar y revocar enlaces públicos de compartir para travesías',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Bienvenido a TREK',
|
||||
|
||||
@@ -156,6 +156,24 @@ const fr: Record<string, string> = {
|
||||
'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Modèle d\'URL pour les tuiles de carte',
|
||||
'settings.mapProvider': 'Fournisseur de carte',
|
||||
'settings.mapProviderHint': 'Affecte les cartes Trip Planner et Journey. Atlas utilise toujours Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Classique 2D, toutes tuiles raster',
|
||||
'settings.mapMapboxSubtitle': 'Tuiles vectorielles, bâtiments 3D & terrain',
|
||||
'settings.mapExperimental': 'Expérimental',
|
||||
'settings.mapMapboxToken': 'Jeton d\'accès Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Jeton public (pk.*) depuis',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Jetons d\'accès',
|
||||
'settings.mapStyle': 'Style de carte',
|
||||
'settings.mapStylePlaceholder': 'Sélectionner un style Mapbox',
|
||||
'settings.mapStyleHint': 'Preset ou votre propre URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': 'Bâtiments 3D & terrain',
|
||||
'settings.map3dHint': 'Inclinaison + extrusions 3D réelles des bâtiments — fonctionne avec tous les styles, y compris satellite.',
|
||||
'settings.mapHighQuality': 'Mode haute qualité',
|
||||
'settings.mapHighQualityHint': 'Anticrénelage + projection globe pour des bords plus nets et une vue réaliste du monde.',
|
||||
'settings.mapHighQualityWarning': 'Peut affecter les performances sur les appareils moins puissants.',
|
||||
'settings.mapTipLabel': 'Astuce :',
|
||||
'settings.mapTip': 'Clic droit et glisser pour pivoter/incliner la carte. Clic milieu pour ajouter un lieu (le clic droit est réservé à la rotation).',
|
||||
'settings.latitude': 'Latitude',
|
||||
'settings.longitude': 'Longitude',
|
||||
'settings.saveMap': 'Enregistrer la carte',
|
||||
@@ -2195,6 +2213,7 @@ const fr: Record<string, string> = {
|
||||
'oauth.scope.group.vacay': 'Congés',
|
||||
'oauth.scope.group.geo': 'Géo',
|
||||
'oauth.scope.group.weather': 'Météo',
|
||||
'oauth.scope.group.journey': 'Journal de voyage',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Voir les voyages et itinéraires',
|
||||
@@ -2245,6 +2264,12 @@ const fr: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Chercher des lieux, résoudre des URL cartographiques et géocoder des coordonnées',
|
||||
'oauth.scope.weather:read.label': 'Prévisions météo',
|
||||
'oauth.scope.weather:read.description': 'Obtenir les prévisions météo pour les lieux et dates de voyage',
|
||||
'oauth.scope.journey:read.label': 'Voir les journaux de voyage',
|
||||
'oauth.scope.journey:read.description': 'Lire les journaux de voyage, les entrées et la liste des contributeurs',
|
||||
'oauth.scope.journey:write.label': 'Gérer les journaux de voyage',
|
||||
'oauth.scope.journey:write.description': 'Créer, modifier et supprimer les journaux de voyage et leurs entrées',
|
||||
'oauth.scope.journey:share.label': 'Gérer les liens de journaux de voyage',
|
||||
'oauth.scope.journey:share.description': 'Créer, modifier et révoquer des liens de partage publics pour les journaux de voyage',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Bienvenue sur TREK',
|
||||
|
||||
@@ -156,6 +156,24 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Hagyd üresen az OpenStreetMap használatához (alapértelmezett)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL sablon a térképcsempékhez',
|
||||
'settings.mapProvider': 'Térkép szolgáltató',
|
||||
'settings.mapProviderHint': 'A Trip Planner és Journey térképekre érvényes. Az Atlas mindig Leafletet használ.',
|
||||
'settings.mapLeafletSubtitle': 'Klasszikus 2D, bármilyen raszter csempe',
|
||||
'settings.mapMapboxSubtitle': 'Vektoros csempék, 3D épületek és terep',
|
||||
'settings.mapExperimental': 'Kísérleti',
|
||||
'settings.mapMapboxToken': 'Mapbox hozzáférési token',
|
||||
'settings.mapMapboxTokenHint': 'Publikus token (pk.*) innen:',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Hozzáférési tokenek',
|
||||
'settings.mapStyle': 'Térkép stílus',
|
||||
'settings.mapStylePlaceholder': 'Válassz Mapbox stílust',
|
||||
'settings.mapStyleHint': 'Preset vagy saját mapbox://styles/USER/ID URL',
|
||||
'settings.map3dBuildings': '3D épületek és terep',
|
||||
'settings.map3dHint': 'Dőlés + valódi 3D épület-kiemelés — minden stílussal működik, beleértve a műholdast.',
|
||||
'settings.mapHighQuality': 'Magas minőség mód',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + földgömb-vetítés az élesebb kontúrokért és egy valósághű világnézethez.',
|
||||
'settings.mapHighQualityWarning': 'Gyengébb eszközökön befolyásolhatja a teljesítményt.',
|
||||
'settings.mapTipLabel': 'Tipp:',
|
||||
'settings.mapTip': 'Jobb klikk és húzás a térkép forgatásához/döntéséhez. Középső kattintás hely hozzáadásához (a jobb klikk a forgatáshoz van fenntartva).',
|
||||
'settings.latitude': 'Szélességi fok',
|
||||
'settings.longitude': 'Hosszúsági fok',
|
||||
'settings.saveMap': 'Térkép mentése',
|
||||
@@ -2196,6 +2214,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Szabadság',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Időjárás',
|
||||
'oauth.scope.group.journey': 'Útinaplók',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Utazások és útvonalak megtekintése',
|
||||
@@ -2246,6 +2265,12 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Helyek keresése, térkép URL-ek feloldása és koordináták fordított geokódolása',
|
||||
'oauth.scope.weather:read.label': 'Időjárás-előrejelzések',
|
||||
'oauth.scope.weather:read.description': 'Időjárás-előrejelzések lekérése az utazási helyszínekre és dátumokra',
|
||||
'oauth.scope.journey:read.label': 'Útinaplók megtekintése',
|
||||
'oauth.scope.journey:read.description': 'Útinaplók, bejegyzések és közreműködők listájának olvasása',
|
||||
'oauth.scope.journey:write.label': 'Útinaplók kezelése',
|
||||
'oauth.scope.journey:write.description': 'Útinaplók és bejegyzéseik létrehozása, frissítése és törlése',
|
||||
'oauth.scope.journey:share.label': 'Útinapló-linkek kezelése',
|
||||
'oauth.scope.journey:share.description': 'Nyilvános megosztási linkek létrehozása, frissítése és visszavonása útinaplókhoz',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Üdvözöl a TREK',
|
||||
|
||||
@@ -159,6 +159,24 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Kosongkan untuk OpenStreetMap (default)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Template URL untuk tile peta',
|
||||
'settings.mapProvider': 'Penyedia peta',
|
||||
'settings.mapProviderHint': 'Berlaku untuk peta Trip Planner dan Journey. Atlas selalu menggunakan Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Klasik 2D, tile raster apa pun',
|
||||
'settings.mapMapboxSubtitle': 'Tile vektor, bangunan 3D & medan',
|
||||
'settings.mapExperimental': 'Eksperimental',
|
||||
'settings.mapMapboxToken': 'Token akses Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Token publik (pk.*) dari',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Token akses',
|
||||
'settings.mapStyle': 'Gaya peta',
|
||||
'settings.mapStylePlaceholder': 'Pilih gaya Mapbox',
|
||||
'settings.mapStyleHint': 'Preset atau URL mapbox://styles/USER/ID milikmu',
|
||||
'settings.map3dBuildings': 'Bangunan 3D & medan',
|
||||
'settings.map3dHint': 'Kemiringan + ekstrusi bangunan 3D nyata — bekerja di semua gaya, termasuk satelit.',
|
||||
'settings.mapHighQuality': 'Mode kualitas tinggi',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + proyeksi globe untuk tepi yang lebih tajam dan tampilan dunia realistis.',
|
||||
'settings.mapHighQualityWarning': 'Dapat memengaruhi performa pada perangkat kelas bawah.',
|
||||
'settings.mapTipLabel': 'Tip:',
|
||||
'settings.mapTip': 'Klik kanan dan seret untuk memutar/memiringkan peta. Klik tengah untuk menambah tempat (klik kanan untuk rotasi).',
|
||||
'settings.latitude': 'Lintang',
|
||||
'settings.longitude': 'Bujur',
|
||||
'settings.saveMap': 'Simpan Peta',
|
||||
@@ -2235,6 +2253,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Liburan',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Cuaca',
|
||||
'oauth.scope.group.journey': 'Journey',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Lihat perjalanan & itinerari',
|
||||
@@ -2285,6 +2304,12 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Cari lokasi, selesaikan URL peta, dan geokode terbalik koordinat',
|
||||
'oauth.scope.weather:read.label': 'Prakiraan cuaca',
|
||||
'oauth.scope.weather:read.description': 'Ambil prakiraan cuaca untuk lokasi dan tanggal perjalanan',
|
||||
'oauth.scope.journey:read.label': 'Lihat Journey',
|
||||
'oauth.scope.journey:read.description': 'Baca Journey, entri, dan daftar kontributor',
|
||||
'oauth.scope.journey:write.label': 'Kelola Journey',
|
||||
'oauth.scope.journey:write.description': 'Buat, perbarui, dan hapus Journey beserta entrinya',
|
||||
'oauth.scope.journey:share.label': 'Kelola tautan Journey',
|
||||
'oauth.scope.journey:share.description': 'Buat, perbarui, dan cabut tautan berbagi publik untuk Journey',
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -156,6 +156,24 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Lascia vuoto per OpenStreetMap (predefinito)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Modello URL per i tile della mappa',
|
||||
'settings.mapProvider': 'Provider mappa',
|
||||
'settings.mapProviderHint': 'Influisce sulle mappe Trip Planner e Journey. Atlas usa sempre Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Classica 2D, qualsiasi tile raster',
|
||||
'settings.mapMapboxSubtitle': 'Tile vettoriali, edifici 3D e terreno',
|
||||
'settings.mapExperimental': 'Sperimentale',
|
||||
'settings.mapMapboxToken': 'Token di accesso Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Token pubblico (pk.*) da',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Token di accesso',
|
||||
'settings.mapStyle': 'Stile mappa',
|
||||
'settings.mapStylePlaceholder': 'Seleziona uno stile Mapbox',
|
||||
'settings.mapStyleHint': 'Preset o il tuo URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': 'Edifici 3D e terreno',
|
||||
'settings.map3dHint': 'Inclinazione + estrusioni 3D reali degli edifici — funziona con ogni stile, incluso satellite.',
|
||||
'settings.mapHighQuality': 'Modalità alta qualità',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + proiezione globo per bordi più nitidi e una vista realistica del mondo.',
|
||||
'settings.mapHighQualityWarning': 'Può influire sulle prestazioni su dispositivi meno potenti.',
|
||||
'settings.mapTipLabel': 'Suggerimento:',
|
||||
'settings.mapTip': 'Click destro e trascina per ruotare/inclinare la mappa. Click centrale per aggiungere un luogo (il click destro è riservato alla rotazione).',
|
||||
'settings.latitude': 'Latitudine',
|
||||
'settings.longitude': 'Longitudine',
|
||||
'settings.saveMap': 'Salva Mappa',
|
||||
@@ -2196,6 +2214,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Ferie',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Meteo',
|
||||
'oauth.scope.group.journey': 'Diario di viaggio',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Visualizza viaggi e itinerari',
|
||||
@@ -2246,6 +2265,12 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Cerca luoghi, risolvi URL mappa e geocodifica inversa coordinate',
|
||||
'oauth.scope.weather:read.label': 'Previsioni meteo',
|
||||
'oauth.scope.weather:read.description': 'Ottieni previsioni meteo per luoghi e date del viaggio',
|
||||
'oauth.scope.journey:read.label': 'Visualizza diari di viaggio',
|
||||
'oauth.scope.journey:read.description': 'Leggi diari di viaggio, voci e lista dei collaboratori',
|
||||
'oauth.scope.journey:write.label': 'Gestisci diari di viaggio',
|
||||
'oauth.scope.journey:write.description': 'Crea, aggiorna ed elimina diari di viaggio e le loro voci',
|
||||
'oauth.scope.journey:share.label': 'Gestisci link diari di viaggio',
|
||||
'oauth.scope.journey:share.description': 'Crea, aggiorna e revoca link di condivisione pubblici per i diari di viaggio',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Benvenuto su TREK',
|
||||
|
||||
@@ -156,6 +156,24 @@ const nl: Record<string, string> = {
|
||||
'settings.mapDefaultHint': 'Laat leeg voor OpenStreetMap (standaard)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL-sjabloon voor kaarttegels',
|
||||
'settings.mapProvider': 'Kaartprovider',
|
||||
'settings.mapProviderHint': 'Geldt voor Trip Planner en Journey kaarten. Atlas gebruikt altijd Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Klassiek 2D, elke raster-tile',
|
||||
'settings.mapMapboxSubtitle': 'Vector tiles, 3D-gebouwen & terrein',
|
||||
'settings.mapExperimental': 'Experimenteel',
|
||||
'settings.mapMapboxToken': 'Mapbox Access Token',
|
||||
'settings.mapMapboxTokenHint': 'Openbaar token (pk.*) van',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Access tokens',
|
||||
'settings.mapStyle': 'Kaartstijl',
|
||||
'settings.mapStylePlaceholder': 'Kies een Mapbox-stijl',
|
||||
'settings.mapStyleHint': 'Preset of eigen mapbox://styles/USER/ID URL',
|
||||
'settings.map3dBuildings': '3D-gebouwen & terrein',
|
||||
'settings.map3dHint': 'Kanteling + echte 3D-gebouwenextrusies — werkt op elke stijl, inclusief satelliet.',
|
||||
'settings.mapHighQuality': 'Hoge kwaliteit modus',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + globeprojectie voor scherpere randen en een realistische wereldweergave.',
|
||||
'settings.mapHighQualityWarning': 'Kan de prestaties op minder krachtige apparaten beïnvloeden.',
|
||||
'settings.mapTipLabel': 'Tip:',
|
||||
'settings.mapTip': 'Rechts-klik en sleep om de kaart te roteren/kantelen. Middenklik om een locatie toe te voegen (rechts-klik is voor rotatie).',
|
||||
'settings.latitude': 'Breedtegraad',
|
||||
'settings.longitude': 'Lengtegraad',
|
||||
'settings.saveMap': 'Kaart opslaan',
|
||||
@@ -2195,6 +2213,7 @@ const nl: Record<string, string> = {
|
||||
'oauth.scope.group.vacay': 'Vakantie',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Weer',
|
||||
'oauth.scope.group.journey': 'Reisverslag',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Reizen en reisplannen bekijken',
|
||||
@@ -2245,6 +2264,12 @@ const nl: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Locaties zoeken, kaart-URL\'s oplossen en coördinaten omgekeerd geocoderen',
|
||||
'oauth.scope.weather:read.label': 'Weersverwachtingen',
|
||||
'oauth.scope.weather:read.description': 'Weersverwachtingen ophalen voor reislocaties en -datums',
|
||||
'oauth.scope.journey:read.label': 'Reisverslagen bekijken',
|
||||
'oauth.scope.journey:read.description': 'Reisverslagen, vermeldingen en lijst van bijdragers lezen',
|
||||
'oauth.scope.journey:write.label': 'Reisverslagen beheren',
|
||||
'oauth.scope.journey:write.description': 'Reisverslagen en hun vermeldingen aanmaken, bijwerken en verwijderen',
|
||||
'oauth.scope.journey:share.label': 'Reisverslag-links beheren',
|
||||
'oauth.scope.journey:share.description': 'Publieke deellinks voor reisverslagen aanmaken, bijwerken en intrekken',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Welkom bij TREK',
|
||||
|
||||
@@ -139,6 +139,24 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mapDefaultHint': 'Pozostaw puste dla OpenStreetMap (domyślnie)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Szablon URL dla kafelków mapy',
|
||||
'settings.mapProvider': 'Dostawca mapy',
|
||||
'settings.mapProviderHint': 'Dotyczy map Trip Planner i Journey. Atlas zawsze używa Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Klasyczne 2D, dowolne kafelki rastrowe',
|
||||
'settings.mapMapboxSubtitle': 'Kafelki wektorowe, budynki 3D i teren',
|
||||
'settings.mapExperimental': 'Eksperymentalne',
|
||||
'settings.mapMapboxToken': 'Token dostępu Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Token publiczny (pk.*) z',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Tokeny dostępu',
|
||||
'settings.mapStyle': 'Styl mapy',
|
||||
'settings.mapStylePlaceholder': 'Wybierz styl Mapbox',
|
||||
'settings.mapStyleHint': 'Preset lub własny URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': 'Budynki 3D i teren',
|
||||
'settings.map3dHint': 'Nachylenie + prawdziwe wytłaczanie budynków 3D — działa w każdym stylu, także satelitarnym.',
|
||||
'settings.mapHighQuality': 'Tryb wysokiej jakości',
|
||||
'settings.mapHighQualityHint': 'Antialiasing + projekcja globusa dla ostrzejszych krawędzi i realistycznego widoku świata.',
|
||||
'settings.mapHighQualityWarning': 'Może wpływać na wydajność na słabszych urządzeniach.',
|
||||
'settings.mapTipLabel': 'Wskazówka:',
|
||||
'settings.mapTip': 'Kliknij prawym przyciskiem i przeciągnij, aby obrócić/pochylić mapę. Środkowy przycisk dodaje miejsce (prawy jest zarezerwowany dla obrotu).',
|
||||
'settings.latitude': 'Szerokość',
|
||||
'settings.longitude': 'Długość',
|
||||
'settings.saveMap': 'Zapisz mapę',
|
||||
@@ -2188,6 +2206,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Urlop',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Pogoda',
|
||||
'oauth.scope.group.journey': 'Dziennik podróży',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Przeglądaj podróże i itineraria',
|
||||
@@ -2238,6 +2257,12 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Wyszukuj miejsca, rozwiązuj adresy URL map i odwrotnie geokoduj współrzędne',
|
||||
'oauth.scope.weather:read.label': 'Prognozy pogody',
|
||||
'oauth.scope.weather:read.description': 'Pobieraj prognozy pogody dla miejsc i dat podróży',
|
||||
'oauth.scope.journey:read.label': 'Przeglądaj dzienniki podróży',
|
||||
'oauth.scope.journey:read.description': 'Odczytuj dzienniki podróży, wpisy i listę współautorów',
|
||||
'oauth.scope.journey:write.label': 'Zarządzaj dziennikami podróży',
|
||||
'oauth.scope.journey:write.description': 'Twórz, aktualizuj i usuwaj dzienniki podróży oraz ich wpisy',
|
||||
'oauth.scope.journey:share.label': 'Zarządzaj linkami dzienników podróży',
|
||||
'oauth.scope.journey:share.description': 'Twórz, aktualizuj i unieważniaj publiczne linki udostępniania dzienników podróży',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Witaj w TREK',
|
||||
|
||||
@@ -156,6 +156,24 @@ const ru: Record<string, string> = {
|
||||
'settings.mapDefaultHint': 'Оставьте пустым для OpenStreetMap (по умолчанию)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'URL-шаблон для тайлов карты',
|
||||
'settings.mapProvider': 'Провайдер карты',
|
||||
'settings.mapProviderHint': 'Применяется к Trip Planner и Journey. Atlas всегда использует Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Классические 2D, любые растровые тайлы',
|
||||
'settings.mapMapboxSubtitle': 'Векторные тайлы, 3D-здания и рельеф',
|
||||
'settings.mapExperimental': 'Экспериментально',
|
||||
'settings.mapMapboxToken': 'Токен доступа Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Публичный токен (pk.*) с',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Токены доступа',
|
||||
'settings.mapStyle': 'Стиль карты',
|
||||
'settings.mapStylePlaceholder': 'Выберите стиль Mapbox',
|
||||
'settings.mapStyleHint': 'Preset или собственный URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': '3D-здания и рельеф',
|
||||
'settings.map3dHint': 'Наклон + настоящие 3D-здания — работает со всеми стилями, включая спутник.',
|
||||
'settings.mapHighQuality': 'Режим высокого качества',
|
||||
'settings.mapHighQualityHint': 'Сглаживание + проекция глобуса для более чётких краёв и реалистичного вида мира.',
|
||||
'settings.mapHighQualityWarning': 'Может повлиять на производительность на слабых устройствах.',
|
||||
'settings.mapTipLabel': 'Совет:',
|
||||
'settings.mapTip': 'Зажмите правую кнопку мыши и перетащите, чтобы повернуть/наклонить карту. Клик средней кнопкой — добавить место (правая кнопка зарезервирована для вращения).',
|
||||
'settings.latitude': 'Широта',
|
||||
'settings.longitude': 'Долгота',
|
||||
'settings.saveMap': 'Сохранить карту',
|
||||
@@ -2195,6 +2213,7 @@ const ru: Record<string, string> = {
|
||||
'oauth.scope.group.vacay': 'Отпуск',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Погода',
|
||||
'oauth.scope.group.journey': 'Путешествия',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Просмотр поездок и маршрутов',
|
||||
@@ -2245,6 +2264,12 @@ const ru: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Поиск мест, разрешение URL карт и обратное геокодирование координат',
|
||||
'oauth.scope.weather:read.label': 'Прогнозы погоды',
|
||||
'oauth.scope.weather:read.description': 'Получение прогнозов погоды для мест и дат поездки',
|
||||
'oauth.scope.journey:read.label': 'Просмотр путешествий',
|
||||
'oauth.scope.journey:read.description': 'Чтение путешествий, записей и списка участников',
|
||||
'oauth.scope.journey:write.label': 'Управление путешествиями',
|
||||
'oauth.scope.journey:write.description': 'Создание, обновление и удаление путешествий и их записей',
|
||||
'oauth.scope.journey:share.label': 'Управление ссылками на путешествия',
|
||||
'oauth.scope.journey:share.description': 'Создание, обновление и отзыв публичных ссылок для путешествий',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Добро пожаловать в TREK',
|
||||
|
||||
@@ -156,6 +156,24 @@ const zh: Record<string, string> = {
|
||||
'settings.mapDefaultHint': '留空则使用 OpenStreetMap(默认)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': '地图瓦片 URL 模板',
|
||||
'settings.mapProvider': '地图提供商',
|
||||
'settings.mapProviderHint': '影响行程规划和旅程地图。Atlas 始终使用 Leaflet。',
|
||||
'settings.mapLeafletSubtitle': '经典 2D,任何栅格瓦片',
|
||||
'settings.mapMapboxSubtitle': '矢量瓦片、3D 建筑和地形',
|
||||
'settings.mapExperimental': '实验性',
|
||||
'settings.mapMapboxToken': 'Mapbox 访问令牌',
|
||||
'settings.mapMapboxTokenHint': '公共令牌 (pk.*) 来自',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → 访问令牌',
|
||||
'settings.mapStyle': '地图样式',
|
||||
'settings.mapStylePlaceholder': '选择 Mapbox 样式',
|
||||
'settings.mapStyleHint': '预设或您自己的 mapbox://styles/USER/ID URL',
|
||||
'settings.map3dBuildings': '3D 建筑和地形',
|
||||
'settings.map3dHint': '倾斜 + 真实 3D 建筑拉伸 — 适用于所有样式,包括卫星。',
|
||||
'settings.mapHighQuality': '高画质模式',
|
||||
'settings.mapHighQualityHint': '抗锯齿 + 地球投影,带来更清晰的边缘和更真实的世界视图。',
|
||||
'settings.mapHighQualityWarning': '可能影响低端设备的性能。',
|
||||
'settings.mapTipLabel': '提示:',
|
||||
'settings.mapTip': '右键点击并拖动以旋转/倾斜地图。中键点击添加地点(右键用于旋转)。',
|
||||
'settings.latitude': '纬度',
|
||||
'settings.longitude': '经度',
|
||||
'settings.saveMap': '保存地图',
|
||||
@@ -2195,6 +2213,7 @@ const zh: Record<string, string> = {
|
||||
'oauth.scope.group.vacay': '假期',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': '天气',
|
||||
'oauth.scope.group.journey': '旅程',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': '查看行程和行程计划',
|
||||
@@ -2245,6 +2264,12 @@ const zh: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': '搜索位置、解析地图 URL 和反向地理编码坐标',
|
||||
'oauth.scope.weather:read.label': '天气预报',
|
||||
'oauth.scope.weather:read.description': '获取行程地点和日期的天气预报',
|
||||
'oauth.scope.journey:read.label': '查看旅程',
|
||||
'oauth.scope.journey:read.description': '读取旅程、条目和贡献者列表',
|
||||
'oauth.scope.journey:write.label': '管理旅程',
|
||||
'oauth.scope.journey:write.description': '创建、更新和删除旅程及其条目',
|
||||
'oauth.scope.journey:share.label': '管理旅程链接',
|
||||
'oauth.scope.journey:share.description': '创建、更新和撤销旅程的公开分享链接',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': '欢迎使用 TREK',
|
||||
|
||||
@@ -156,6 +156,24 @@ const zhTw: Record<string, string> = {
|
||||
'settings.mapDefaultHint': '留空則使用 OpenStreetMap(預設)',
|
||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': '地圖瓦片 URL 模板',
|
||||
'settings.mapProvider': '地圖提供商',
|
||||
'settings.mapProviderHint': '影響行程規劃和旅程地圖。Atlas 始終使用 Leaflet。',
|
||||
'settings.mapLeafletSubtitle': '經典 2D,任何柵格瓦片',
|
||||
'settings.mapMapboxSubtitle': '向量瓦片、3D 建築和地形',
|
||||
'settings.mapExperimental': '實驗性',
|
||||
'settings.mapMapboxToken': 'Mapbox 存取權杖',
|
||||
'settings.mapMapboxTokenHint': '公開權杖 (pk.*) 來自',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → 存取權杖',
|
||||
'settings.mapStyle': '地圖樣式',
|
||||
'settings.mapStylePlaceholder': '選擇 Mapbox 樣式',
|
||||
'settings.mapStyleHint': '預設或您自己的 mapbox://styles/USER/ID URL',
|
||||
'settings.map3dBuildings': '3D 建築和地形',
|
||||
'settings.map3dHint': '傾斜 + 真實 3D 建築拉伸 — 適用於所有樣式,包括衛星。',
|
||||
'settings.mapHighQuality': '高畫質模式',
|
||||
'settings.mapHighQualityHint': '抗鋸齒 + 地球投影,帶來更清晰的邊緣和更真實的世界視圖。',
|
||||
'settings.mapHighQualityWarning': '可能影響低階裝置的效能。',
|
||||
'settings.mapTipLabel': '提示:',
|
||||
'settings.mapTip': '右鍵點擊並拖曳以旋轉/傾斜地圖。中鍵點擊新增地點(右鍵用於旋轉)。',
|
||||
'settings.latitude': '緯度',
|
||||
'settings.longitude': '經度',
|
||||
'settings.saveMap': '儲存地圖',
|
||||
@@ -2196,6 +2214,7 @@ const zhTw: Record<string, string> = {
|
||||
'oauth.scope.group.vacay': '假期',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': '天氣',
|
||||
'oauth.scope.group.journey': '旅程',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': '檢視行程與旅遊計畫',
|
||||
@@ -2246,6 +2265,12 @@ const zhTw: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': '搜尋地點、解析地圖 URL 及反向地理編碼坐標',
|
||||
'oauth.scope.weather:read.label': '天氣預報',
|
||||
'oauth.scope.weather:read.description': '取得行程地點及日期的天氣預報',
|
||||
'oauth.scope.journey:read.label': '檢視旅程',
|
||||
'oauth.scope.journey:read.description': '讀取旅程、條目及貢獻者清單',
|
||||
'oauth.scope.journey:write.label': '管理旅程',
|
||||
'oauth.scope.journey:write.description': '建立、更新及刪除旅程及其條目',
|
||||
'oauth.scope.journey:share.label': '管理旅程連結',
|
||||
'oauth.scope.journey:share.description': '建立、更新及撤銷旅程的公開分享連結',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': '歡迎使用 TREK',
|
||||
|
||||
@@ -20,8 +20,9 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
||||
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
|
||||
import PermissionsPanel from '../components/Admin/PermissionsPanel'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle, SlidersHorizontal, UserCog, Puzzle, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
|
||||
|
||||
interface AdminUser {
|
||||
id: number
|
||||
@@ -183,18 +184,18 @@ export default function AdminPage(): React.ReactElement {
|
||||
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const mcpEnabled = useAddonStore(s => s.isEnabled('mcp'))
|
||||
const devMode = useAuthStore(s => s.devMode)
|
||||
const TABS = [
|
||||
{ id: 'users', label: t('admin.tabs.users') },
|
||||
{ id: 'config', label: t('admin.tabs.config') },
|
||||
{ id: 'defaults', label: t('admin.tabs.defaults') },
|
||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'notifications', label: t('admin.tabs.notifications') },
|
||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||
{ id: 'audit', label: t('admin.tabs.audit') },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []),
|
||||
{ id: 'github', label: t('admin.tabs.github') },
|
||||
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications' }] : []),
|
||||
const TABS: PageSidebarTab[] = [
|
||||
{ id: 'users', label: t('admin.tabs.users'), icon: Users },
|
||||
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal },
|
||||
{ id: 'defaults', label: t('admin.tabs.defaults'), icon: UserCog },
|
||||
{ id: 'addons', label: t('admin.tabs.addons'), icon: Puzzle },
|
||||
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon },
|
||||
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell },
|
||||
{ id: 'backup', label: t('admin.tabs.backup'), icon: Database },
|
||||
{ id: 'audit', label: t('admin.tabs.audit'), icon: ScrollText },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: KeyRound }] : []),
|
||||
{ id: 'github', label: t('admin.tabs.github'), icon: GitBranch },
|
||||
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug }] : []),
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>('users')
|
||||
@@ -500,7 +501,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
<Navbar />
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center">
|
||||
@@ -586,24 +587,15 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="grid grid-cols-3 sm:flex gap-1 mb-6 rounded-xl p-1" style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)' }}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{/* Sidebar layout — nav on the left, active panel on the right */}
|
||||
<PageSidebar
|
||||
sidebarLabel={t('admin.title').toUpperCase()}
|
||||
tabs={TABS}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer="admin · self-hosted"
|
||||
>
|
||||
{/* Tab content */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||
@@ -1618,6 +1610,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
{activeTab === 'defaults' && <DefaultUserSettingsTab />}
|
||||
|
||||
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
|
||||
</PageSidebar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { Settings, Palette, Map, Bell, Plug, CloudOff, User, Info } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
|
||||
import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab'
|
||||
import MapSettingsTab from '../components/Settings/MapSettingsTab'
|
||||
import NotificationsTab from '../components/Settings/NotificationsTab'
|
||||
@@ -37,14 +38,18 @@ export default function SettingsPage(): React.ReactElement {
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const TABS = [
|
||||
{ id: 'display', label: t('settings.tabs.display') },
|
||||
{ id: 'map', label: t('settings.tabs.map') },
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications') },
|
||||
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations') }] : []),
|
||||
{ id: 'offline', label: t('settings.tabs.offline') },
|
||||
{ id: 'account', label: t('settings.tabs.account') },
|
||||
...(appVersion ? [{ id: 'about', label: t('settings.tabs.about') }] : []),
|
||||
const tabs: PageSidebarTab[] = [
|
||||
{ id: 'display', label: t('settings.tabs.display'), icon: Palette },
|
||||
{ id: 'map', label: t('settings.tabs.map'), icon: Map },
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications'), icon: Bell },
|
||||
...(hasIntegrations
|
||||
? [{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug }]
|
||||
: []),
|
||||
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
|
||||
{ id: 'account', label: t('settings.tabs.account'), icon: User },
|
||||
...(appVersion
|
||||
? [{ id: 'about', label: t('settings.tabs.about'), icon: Info }]
|
||||
: []),
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -52,7 +57,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
<Navbar />
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: 'var(--bg-tertiary)' }}>
|
||||
@@ -64,33 +69,24 @@ export default function SettingsPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="grid grid-cols-3 sm:flex gap-1 mb-6 rounded-xl p-1" style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)' }}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'display' && <DisplaySettingsTab />}
|
||||
{activeTab === 'map' && <MapSettingsTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'integrations' && hasIntegrations && <IntegrationsTab />}
|
||||
{activeTab === 'offline' && <OfflineTab />}
|
||||
{activeTab === 'account' && <AccountTab />}
|
||||
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
|
||||
{/* Sidebar layout */}
|
||||
<PageSidebar
|
||||
sidebarLabel={t('settings.title').toUpperCase()}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={appVersion ? `v${appVersion} · self-hosted` : 'self-hosted'}
|
||||
>
|
||||
{activeTab === 'display' && <DisplaySettingsTab />}
|
||||
{activeTab === 'map' && <MapSettingsTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'integrations' && hasIntegrations && <IntegrationsTab />}
|
||||
{activeTab === 'offline' && <OfflineTab />}
|
||||
{activeTab === 'account' && <AccountTab />}
|
||||
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
|
||||
</PageSidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||
navigateFallback: 'index.html',
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/],
|
||||
|
||||
+11
-2
@@ -77,12 +77,17 @@ export function createApp(): express.Application {
|
||||
|
||||
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
||||
|
||||
// RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config
|
||||
app.use(
|
||||
['/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource'],
|
||||
cors({ origin: '*', credentials: false }),
|
||||
);
|
||||
app.use(cors({ origin: corsOrigin, credentials: true }));
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||
connectSrc: [
|
||||
@@ -94,8 +99,11 @@ export function createApp(): express.Application {
|
||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
||||
"https://router.project-osrm.org/route/v1/"
|
||||
"https://router.project-osrm.org/route/v1/",
|
||||
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
|
||||
],
|
||||
workerSrc: ["'self'", "blob:"],
|
||||
childSrc: ["'self'", "blob:"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'none'"],
|
||||
@@ -109,6 +117,7 @@ export function createApp(): express.Application {
|
||||
|
||||
if (shouldForceHttps) {
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path === '/api/health') return next();
|
||||
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
||||
res.redirect(301, 'https://' + req.headers.host + req.url);
|
||||
});
|
||||
|
||||
@@ -1767,6 +1767,11 @@ function runMigrations(db: Database.Database): void {
|
||||
if (!err.message?.includes('no such table') && !err.message?.includes('FOREIGN KEY')) throw err;
|
||||
}
|
||||
},
|
||||
// Migration: RFC 8707 resource indicators — audience-bind OAuth tokens to /mcp
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE oauth_tokens ADD COLUMN audience TEXT'); }
|
||||
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { registerResources } from './resources';
|
||||
import { registerTools } from './tools';
|
||||
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
import { getAppUrl } from '../services/oidcService';
|
||||
|
||||
export { revokeUserSessions, revokeUserSessionsForClient };
|
||||
|
||||
@@ -38,6 +39,7 @@ You are connected to TREK, a travel planning application. Below is a compact ref
|
||||
- **Collab note / poll / message** — shared notes, decision polls, and chat messages for group trips.
|
||||
- **Atlas** — global travel journal: bucket list, visited countries and regions.
|
||||
- **Vacay** — vacation-day planner that tracks leave across team members and years.
|
||||
- **Journey** — cross-trip travel narrative with dated entries, contributors, and share links. Requires the Journey addon.
|
||||
|
||||
## Key workflows
|
||||
|
||||
@@ -75,6 +77,7 @@ The following features are optional and may not be available on every TREK insta
|
||||
- **Collab** — shared notes, polls, and chat messages for group trips.
|
||||
- **Atlas** — bucket list and visited-country/region tracking.
|
||||
- **Vacay** — team vacation-day planner with public holiday integration.
|
||||
- **Journey** — cross-trip travel narrative with entries, contributors, and share links.
|
||||
|
||||
## Behavioral rules
|
||||
|
||||
@@ -149,6 +152,12 @@ const sessionSweepInterval = setInterval(() => {
|
||||
// Prevent the interval from keeping the process alive if nothing else is running
|
||||
sessionSweepInterval.unref();
|
||||
|
||||
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
|
||||
const base = (getAppUrl() || '').replace(/\/+$/, '');
|
||||
res.set('WWW-Authenticate',
|
||||
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource", error="${error}"`);
|
||||
}
|
||||
|
||||
interface VerifyTokenResult {
|
||||
user: User;
|
||||
/** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */
|
||||
@@ -171,6 +180,11 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
|
||||
if (token.startsWith('trekoa_')) {
|
||||
const result = getUserByAccessToken(token);
|
||||
if (!result) return null;
|
||||
// RFC 8707: if the token carries an audience, it must match this resource endpoint
|
||||
if (result.audience !== null) {
|
||||
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
if (result.audience !== expected) return null;
|
||||
}
|
||||
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
|
||||
}
|
||||
|
||||
@@ -209,6 +223,7 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
|
||||
const tokenResult = verifyToken(req.headers['authorization']);
|
||||
if (!tokenResult) {
|
||||
setAuthChallenge(res);
|
||||
res.status(401).json({ error: 'Access token required' });
|
||||
return;
|
||||
}
|
||||
@@ -229,10 +244,12 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (session.userId !== user.id) {
|
||||
setAuthChallenge(res);
|
||||
res.status(403).json({ error: 'Session belongs to a different user' });
|
||||
return;
|
||||
}
|
||||
if (session.clientId !== clientId) {
|
||||
setAuthChallenge(res);
|
||||
res.status(403).json({ error: 'Session was created with a different OAuth client' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ import { listCategories } from '../services/categoryService';
|
||||
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
|
||||
import { getNotifications } from '../services/inAppNotifications';
|
||||
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { isAddonEnabled, getCollabFeatures } from '../services/adminService';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService';
|
||||
import { canRead, canReadTrips } from './scopes';
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
@@ -187,7 +188,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
|
||||
);
|
||||
|
||||
// Collab notes for a trip
|
||||
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) server.registerResource(
|
||||
const collabFeatures = isAddonEnabled(ADDON_IDS.COLLAB) ? getCollabFeatures() : null;
|
||||
if (collabFeatures?.notes && canRead(scopes, 'collab')) server.registerResource(
|
||||
'trip-collab-notes',
|
||||
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
|
||||
{ description: 'Shared collaborative notes for a trip', mimeType: 'application/json' },
|
||||
@@ -318,8 +320,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
|
||||
);
|
||||
}
|
||||
|
||||
// Collab polls & messages (addon-gated)
|
||||
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) {
|
||||
// Collab polls (addon + sub-feature gated)
|
||||
if (collabFeatures?.polls && canRead(scopes, 'collab')) {
|
||||
server.registerResource(
|
||||
'trip-collab-polls',
|
||||
new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }),
|
||||
@@ -331,7 +333,10 @@ export function registerResources(server: McpServer, userId: number, scopes: str
|
||||
return jsonContent(uri.href, polls);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Collab messages (addon + sub-feature gated)
|
||||
if (collabFeatures?.chat && canRead(scopes, 'collab')) {
|
||||
server.registerResource(
|
||||
'trip-collab-messages',
|
||||
new ResourceTemplate('trek://trips/{tripId}/collab/messages', { list: undefined }),
|
||||
@@ -381,4 +386,57 @@ export function registerResources(server: McpServer, userId: number, scopes: str
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Journey resources (Journey addon)
|
||||
if (isAddonEnabled(ADDON_IDS.JOURNEY) && canRead(scopes, 'journey')) {
|
||||
server.registerResource(
|
||||
'journeys',
|
||||
'trek://journeys',
|
||||
{ description: 'All journeys owned or contributed to by the current user', mimeType: 'application/json' },
|
||||
async (uri) => {
|
||||
const journeys = listJourneys(userId);
|
||||
return jsonContent(uri.href, journeys);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'journey-detail',
|
||||
new ResourceTemplate('trek://journeys/{journeyId}', { list: undefined }),
|
||||
{ description: 'Single journey with entries, contributors, and trip links', mimeType: 'application/json' },
|
||||
async (uri, { journeyId }) => {
|
||||
const id = parseId(journeyId);
|
||||
if (id === null) return accessDenied(uri.href);
|
||||
const journey = getJourneyFull(id, userId);
|
||||
if (!journey) return accessDenied(uri.href);
|
||||
return jsonContent(uri.href, journey);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'journey-entries',
|
||||
new ResourceTemplate('trek://journeys/{journeyId}/entries', { list: undefined }),
|
||||
{ description: 'All entries in a journey (date, text, mood, linked trip)', mimeType: 'application/json' },
|
||||
async (uri, { journeyId }) => {
|
||||
const id = parseId(journeyId);
|
||||
if (id === null) return accessDenied(uri.href);
|
||||
const j = canAccessJourney(id, userId);
|
||||
if (!j) return accessDenied(uri.href);
|
||||
const entries = listEntries(id, userId);
|
||||
return jsonContent(uri.href, entries);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'journey-contributors',
|
||||
new ResourceTemplate('trek://journeys/{journeyId}/contributors', { list: undefined }),
|
||||
{ description: 'Contributors (owners and collaborators) of a journey', mimeType: 'application/json' },
|
||||
async (uri, { journeyId }) => {
|
||||
const id = parseId(journeyId);
|
||||
if (id === null) return accessDenied(uri.href);
|
||||
const j = getJourneyFull(id, userId);
|
||||
if (!j) return accessDenied(uri.href);
|
||||
return jsonContent(uri.href, (j as any).contributors ?? []);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ export const SCOPES = {
|
||||
VACAY_WRITE: 'vacay:write',
|
||||
GEO_READ: 'geo:read',
|
||||
WEATHER_READ: 'weather:read',
|
||||
JOURNEY_READ: 'journey:read',
|
||||
JOURNEY_WRITE: 'journey:write',
|
||||
JOURNEY_SHARE: 'journey:share',
|
||||
} as const;
|
||||
|
||||
export type Scope = typeof SCOPES[keyof typeof SCOPES];
|
||||
@@ -64,6 +67,9 @@ export const SCOPE_INFO: Record<Scope, ScopeInfo> = {
|
||||
'vacay:write': { label: 'Manage vacation plans', description: 'Create and manage vacation entries, holidays, and team plans', group: 'Vacation' },
|
||||
'geo:read': { label: 'Maps & geocoding', description: 'Search locations, resolve map URLs, and reverse geocode coordinates', group: 'Geo' },
|
||||
'weather:read': { label: 'Weather forecasts', description: 'Fetch weather forecasts for trip locations and dates', group: 'Weather' },
|
||||
'journey:read': { label: 'View journeys', description: 'Read journeys, entries, and contributor list', group: 'Journey' },
|
||||
'journey:write': { label: 'Manage journeys', description: 'Create, update, and delete journeys and their entries', group: 'Journey' },
|
||||
'journey:share': { label: 'Manage journey links', description: 'Create, update, and revoke public share links for journeys', group: 'Journey' },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -101,6 +107,12 @@ export function canShareTrips(scopes: string[] | null): boolean {
|
||||
return scopes.includes('trips:share');
|
||||
}
|
||||
|
||||
/** journey:share is a separate scope for managing public share links for journeys */
|
||||
export function canShareJourneys(scopes: string[] | null): boolean {
|
||||
if (!scopes) return true;
|
||||
return scopes.includes('journey:share');
|
||||
}
|
||||
|
||||
export function validateScopes(requestedScopes: string[]): { valid: boolean; invalid: string[] } {
|
||||
const invalid = requestedScopes.filter(s => !ALL_SCOPES.includes(s as Scope));
|
||||
return { valid: invalid.length === 0, invalid };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { registerTodoTools } from './tools/todos';
|
||||
import { registerAssignmentTools } from './tools/assignments';
|
||||
import { registerJourneyTools } from './tools/journey';
|
||||
import { registerReservationTools } from './tools/reservations';
|
||||
import { registerTagTools } from './tools/tags';
|
||||
import { registerMapsWeatherTools } from './tools/mapsWeather';
|
||||
@@ -12,6 +13,7 @@ import { registerBudgetTools } from './tools/budget';
|
||||
import { registerPackingTools } from './tools/packing';
|
||||
import { registerCollabTools } from './tools/collab';
|
||||
import { registerTripTools } from './tools/trips';
|
||||
import { registerTransportTools } from './tools/transports';
|
||||
import { registerVacayTools } from './tools/vacay';
|
||||
import { registerMcpPrompts } from './tools/prompts';
|
||||
|
||||
@@ -40,6 +42,10 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
|
||||
|
||||
registerCollabTools(server, userId, scopes);
|
||||
|
||||
registerTransportTools(server, userId, scopes);
|
||||
|
||||
registerJourneyTools(server, userId, scopes);
|
||||
|
||||
registerVacayTools(server, userId, scopes);
|
||||
|
||||
registerTodoTools(server, userId, scopes);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { canAccessTrip, db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createBudgetItem, updateBudgetItem, deleteBudgetItem,
|
||||
@@ -94,6 +94,42 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
|
||||
// --- BUDGET ADVANCED ---
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_budget_item_with_members',
|
||||
{
|
||||
description: 'Create a budget/expense item and optionally set the trip members splitting it in one atomic operation. If userIds is omitted or empty, behaves like create_budget_item. Only use when the place does not yet exist — if it already exists, use set_budget_item_members directly.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
total_price: z.number().nonnegative(),
|
||||
note: z.string().max(500).optional(),
|
||||
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit or pass empty array to skip member assignment'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, category, total_price, note, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const hasMembers = userIds && userIds.length > 0;
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
||||
if (hasMembers) {
|
||||
return updateBudgetMembers(item.id, tripId, userIds!);
|
||||
}
|
||||
return { item };
|
||||
});
|
||||
const result = run();
|
||||
safeBroadcast(tripId, 'budget:created', { item: (result as any).item ?? result });
|
||||
if (hasMembers) safeBroadcast(tripId, 'budget:members-updated', { item: result });
|
||||
return ok({ item: result });
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'set_budget_item_members',
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
listPolls, createPoll, votePoll, closePoll, deletePoll,
|
||||
listMessages, createMessage, deleteMessage, addOrRemoveReaction,
|
||||
} from '../../services/collabService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
@@ -22,9 +22,11 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
|
||||
if (!isAddonEnabled(ADDON_IDS.COLLAB)) return;
|
||||
|
||||
const features = getCollabFeatures();
|
||||
|
||||
// --- COLLAB NOTES ---
|
||||
|
||||
if (W) server.registerTool(
|
||||
if (features.notes && W) server.registerTool(
|
||||
'create_collab_note',
|
||||
{
|
||||
description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
|
||||
@@ -47,7 +49,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
if (features.notes && W) server.registerTool(
|
||||
'update_collab_note',
|
||||
{
|
||||
description: 'Edit an existing collaborative note on a trip.',
|
||||
@@ -72,7 +74,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
if (features.notes && W) server.registerTool(
|
||||
'delete_collab_note',
|
||||
{
|
||||
description: 'Delete a collaborative note from a trip.',
|
||||
@@ -94,7 +96,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
|
||||
// --- COLLAB POLLS & CHAT ---
|
||||
|
||||
if (R) server.registerTool(
|
||||
if (features.polls && R) server.registerTool(
|
||||
'list_collab_polls',
|
||||
{
|
||||
description: 'List all polls for a trip.',
|
||||
@@ -110,7 +112,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
if (features.polls && W) server.registerTool(
|
||||
'create_collab_poll',
|
||||
{
|
||||
description: 'Create a new poll in the collab panel.',
|
||||
@@ -132,7 +134,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
if (features.polls && W) server.registerTool(
|
||||
'vote_collab_poll',
|
||||
{
|
||||
description: 'Vote on a poll option (or remove vote if already voted for that option).',
|
||||
@@ -152,7 +154,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
if (features.polls && W) server.registerTool(
|
||||
'close_collab_poll',
|
||||
{
|
||||
description: 'Close a poll so no more votes can be cast.',
|
||||
@@ -172,7 +174,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
if (features.polls && W) server.registerTool(
|
||||
'delete_collab_poll',
|
||||
{
|
||||
description: 'Delete a poll and all its votes.',
|
||||
@@ -192,7 +194,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
if (features.chat && R) server.registerTool(
|
||||
'list_collab_messages',
|
||||
{
|
||||
description: 'List chat messages for a trip (most recent 100, oldest-first).',
|
||||
@@ -209,7 +211,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
if (features.chat && W) server.registerTool(
|
||||
'send_collab_message',
|
||||
{
|
||||
description: "Send a chat message to a trip's collab channel.",
|
||||
@@ -230,7 +232,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
if (features.chat && W) server.registerTool(
|
||||
'delete_collab_message',
|
||||
{
|
||||
description: 'Delete a chat message (only the message owner can delete their own messages).',
|
||||
@@ -250,7 +252,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
if (features.chat && W) server.registerTool(
|
||||
'react_collab_message',
|
||||
{
|
||||
description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).',
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { canAccessTrip, db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
getDay, updateDay, validateAccommodationRefs,
|
||||
createDay, deleteDay,
|
||||
createAccommodation, getAccommodation, updateAccommodation, deleteAccommodation,
|
||||
} from '../../services/dayService';
|
||||
import { createPlace } from '../../services/placeService';
|
||||
import {
|
||||
createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote,
|
||||
deleteNote as deleteDayNote, dayExists as dayNoteExists,
|
||||
@@ -112,6 +113,53 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_place_accommodation',
|
||||
{
|
||||
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
|
||||
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
||||
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||
confirmation: z.string().max(100).optional(),
|
||||
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
|
||||
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
|
||||
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
|
||||
return { place, accommodation };
|
||||
});
|
||||
const result = run();
|
||||
safeBroadcast(tripId, 'place:created', { place: result.place });
|
||||
safeBroadcast(tripId, 'accommodation:created', { accommodation: result.accommodation });
|
||||
return ok(result);
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create place and accommodation.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_accommodation',
|
||||
{
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
addContributor, addTripToJourney, canAccessJourney, createEntry, createJourney,
|
||||
deleteEntry, deleteJourney, getJourneyFull, getSuggestions, listEntries,
|
||||
listJourneys, listUserTrips, removeContributor, removeTripFromJourney,
|
||||
reorderEntries, updateContributorRole, updateEntry, updateJourney,
|
||||
updateJourneyPreferences,
|
||||
} from '../../services/journeyService';
|
||||
import {
|
||||
createOrUpdateJourneyShareLink, deleteJourneyShareLink, getJourneyShareLink,
|
||||
} from '../../services/journeyShareService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
demoDenied, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canShareJourneys, canWrite } from '../scopes';
|
||||
|
||||
function notFound(msg: string) {
|
||||
return { content: [{ type: 'text' as const, text: msg }], isError: true };
|
||||
}
|
||||
|
||||
export function registerJourneyTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return;
|
||||
|
||||
const R = canRead(scopes, 'journey');
|
||||
const W = canWrite(scopes, 'journey');
|
||||
const S = canShareJourneys(scopes);
|
||||
|
||||
// --- READ TOOLS ---
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_journeys',
|
||||
{
|
||||
description: 'List all journeys owned or contributed to by the current user.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const journeys = listJourneys(userId);
|
||||
return ok({ journeys });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'get_journey',
|
||||
{
|
||||
description: 'Get a full journey including entries, contributors, and linked trips.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
const journey = getJourneyFull(journeyId, userId);
|
||||
if (!journey) return notFound('Journey not found or access denied.');
|
||||
return ok({ journey });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_journey_entries',
|
||||
{
|
||||
description: 'List all entries in a journey.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
|
||||
const entries = listEntries(journeyId, userId);
|
||||
return ok({ entries });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_journey_contributors',
|
||||
{
|
||||
description: 'List all contributors (owner and collaborators) of a journey.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
const journey = getJourneyFull(journeyId, userId);
|
||||
if (!journey) return notFound('Journey not found or access denied.');
|
||||
return ok({ contributors: (journey as any).contributors ?? [] });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'get_journey_suggestions',
|
||||
{
|
||||
description: 'Get trip suggestions for creating a new journey (recently completed trips not yet in any journey).',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const trips = getSuggestions(userId);
|
||||
return ok({ trips });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_journey_available_trips',
|
||||
{
|
||||
description: 'List all trips available to link to a journey.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const trips = listUserTrips(userId);
|
||||
return ok({ trips });
|
||||
}
|
||||
);
|
||||
|
||||
// --- WRITE TOOLS ---
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_journey',
|
||||
{
|
||||
description: 'Create a new journey, optionally linking existing trips.',
|
||||
inputSchema: {
|
||||
title: z.string().min(1).max(200),
|
||||
subtitle: z.string().max(300).optional(),
|
||||
trip_ids: z.array(z.number().int().positive()).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ title, subtitle, trip_ids }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const journey = createJourney(userId, { title, subtitle, trip_ids });
|
||||
return ok({ journey });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_journey',
|
||||
{
|
||||
description: 'Update an existing journey\'s title, subtitle, cover, or status. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
subtitle: z.string().max(300).optional(),
|
||||
status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ journeyId, title, subtitle, status }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const journey = updateJourney(journeyId, userId, { title, subtitle, status });
|
||||
if (!journey) return notFound('Journey not found or access denied.');
|
||||
return ok({ journey });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_journey',
|
||||
{
|
||||
description: 'Delete a journey. Owner only — this cannot be undone.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = deleteJourney(journeyId, userId);
|
||||
if (!success) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'add_journey_trip',
|
||||
{
|
||||
description: 'Link a trip to a journey. Syncs skeleton entries for all places in the trip.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ journeyId, tripId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
|
||||
const success = addTripToJourney(journeyId, tripId, userId);
|
||||
return ok({ success });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'remove_journey_trip',
|
||||
{
|
||||
description: 'Unlink a trip from a journey. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ journeyId, tripId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = removeTripFromJourney(journeyId, tripId, userId);
|
||||
if (!success) return notFound('Journey not found or access denied.');
|
||||
return ok({ success });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_journey_entry',
|
||||
{
|
||||
description: 'Create a new entry in a journey.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Entry date (YYYY-MM-DD)'),
|
||||
title: z.string().max(300).optional(),
|
||||
story: z.string().optional(),
|
||||
entry_time: z.string().optional().describe('Time of day (e.g. "14:30")'),
|
||||
location_name: z.string().optional(),
|
||||
mood: z.string().optional(),
|
||||
sort_order: z.number().int().min(0).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ journeyId, entry_date, title, story, entry_time, location_name, mood, sort_order }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order });
|
||||
if (!entry) return notFound('Journey not found or access denied.');
|
||||
return ok({ entry });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_journey_entry',
|
||||
{
|
||||
description: 'Update an existing journey entry.',
|
||||
inputSchema: {
|
||||
entryId: z.number().int().positive(),
|
||||
title: z.string().max(300).optional(),
|
||||
story: z.string().optional(),
|
||||
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
entry_time: z.string().optional(),
|
||||
mood: z.string().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ entryId, title, story, entry_date, entry_time, mood }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
|
||||
if (!entry) return notFound('Entry not found or access denied.');
|
||||
return ok({ entry });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_journey_entry',
|
||||
{
|
||||
description: 'Delete a journey entry.',
|
||||
inputSchema: {
|
||||
entryId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ entryId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = deleteEntry(entryId, userId, undefined);
|
||||
if (!success) return notFound('Entry not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'reorder_journey_entries',
|
||||
{
|
||||
description: 'Reorder entries within a journey by providing the desired order of entry IDs.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
orderedIds: z.array(z.number().int().positive()),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ journeyId, orderedIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = reorderEntries(journeyId, userId, orderedIds, undefined);
|
||||
if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'add_journey_contributor',
|
||||
{
|
||||
description: 'Add a contributor to a journey. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
targetUserId: z.number().int().positive(),
|
||||
role: z.enum(['editor', 'viewer']),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ journeyId, targetUserId, role }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = addContributor(journeyId, userId, targetUserId, role);
|
||||
if (!success) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_journey_contributor_role',
|
||||
{
|
||||
description: 'Update the role of a journey contributor. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
targetUserId: z.number().int().positive(),
|
||||
role: z.enum(['editor', 'viewer']),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ journeyId, targetUserId, role }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = updateContributorRole(journeyId, userId, targetUserId, role);
|
||||
if (!success) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'remove_journey_contributor',
|
||||
{
|
||||
description: 'Remove a contributor from a journey. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
targetUserId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ journeyId, targetUserId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = removeContributor(journeyId, userId, targetUserId);
|
||||
if (!success) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_journey_preferences',
|
||||
{
|
||||
description: 'Update per-user preferences for a journey (e.g. hide skeleton entries).',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
hide_skeletons: z.boolean().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ journeyId, hide_skeletons }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
|
||||
if (!result) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- SHARE TOOLS ---
|
||||
|
||||
if (S) server.registerTool(
|
||||
'get_journey_share_link',
|
||||
{
|
||||
description: 'Get the current public share link for a journey. Returns null if none exists.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
const shareLink = getJourneyShareLink(journeyId);
|
||||
return ok({ shareLink });
|
||||
}
|
||||
);
|
||||
|
||||
if (S) server.registerTool(
|
||||
'create_journey_share_link',
|
||||
{
|
||||
description: 'Create or update the public share link for a journey. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const shareLink = createOrUpdateJourneyShareLink(journeyId, userId, {});
|
||||
if (!shareLink) return notFound('Journey not found or access denied.');
|
||||
return ok({ shareLink });
|
||||
}
|
||||
);
|
||||
|
||||
if (S) server.registerTool(
|
||||
'delete_journey_share_link',
|
||||
{
|
||||
description: 'Revoke the public share link for a journey. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = deleteJourneyShareLink(journeyId, userId);
|
||||
if (!success) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { findByIata, searchAirports } from '../../services/airportService';
|
||||
import { searchPlaces, getPlaceDetails, reverseGeocode, resolveGoogleMapsUrl } from '../../services/mapsService';
|
||||
import { getWeather, getDetailedWeather } from '../../services/weatherService';
|
||||
import {
|
||||
@@ -110,4 +111,38 @@ export function registerMapsWeatherTools(server: McpServer, userId: number, scop
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// --- AIRPORTS ---
|
||||
|
||||
if (canGeo) server.registerTool(
|
||||
'search_airports',
|
||||
{
|
||||
description: 'Search for airports by name, city, or IATA code. Returns matching airports with IATA code, name, city, country, coordinates, and timezone. Use before create_transport (flight) to get the correct IATA code and timezone for endpoints.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(200).describe('Airport name, city, or IATA code (e.g. "zurich", "ZRH", "charles de gaulle")'),
|
||||
limit: z.number().int().min(1).max(50).optional().default(10),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ query, limit }) => {
|
||||
const airports = searchAirports(query, limit ?? 10);
|
||||
return ok({ airports });
|
||||
}
|
||||
);
|
||||
|
||||
if (canGeo) server.registerTool(
|
||||
'get_airport',
|
||||
{
|
||||
description: 'Get a single airport by its IATA code. Returns name, city, country, coordinates, and timezone.',
|
||||
inputSchema: {
|
||||
iata: z.string().length(3).toUpperCase().describe('IATA airport code (e.g. "ZRH", "AMS", "CDG")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ iata }) => {
|
||||
const airport = findByIata(iata);
|
||||
if (!airport) return { content: [{ type: 'text' as const, text: 'Airport not found.' }], isError: true };
|
||||
return ok({ airport });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { canAccessTrip, db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
|
||||
import { deletePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
|
||||
import { createAssignment, dayExists } from '../../services/assignmentService';
|
||||
import { onPlaceDeleted } from '../../services/journeyService';
|
||||
import { listCategories } from '../../services/categoryService';
|
||||
import { searchPlaces } from '../../services/mapsService';
|
||||
import {
|
||||
@@ -47,6 +49,48 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_and_assign_place',
|
||||
{
|
||||
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive().describe('Day to assign the place to'),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
|
||||
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
|
||||
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||
return { place, assignment };
|
||||
});
|
||||
const result = run();
|
||||
safeBroadcast(tripId, 'place:created', { place: result.place });
|
||||
safeBroadcast(tripId, 'assignment:created', { assignment: result.assignment });
|
||||
return ok(result);
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create place and assignment.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_place',
|
||||
{
|
||||
@@ -159,4 +203,57 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'import_places_from_url',
|
||||
{
|
||||
description: 'Import places from a shared Google Maps or Naver Maps list URL. Returns the imported places and count. The list must be shared publicly.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
url: z.string().url().describe('Publicly shared Google Maps list URL (maps.app.goo.gl/...) or Naver Maps list URL'),
|
||||
source: z.enum(['google-list', 'naver-list']).describe('List source: "google-list" for Google Maps saved places, "naver-list" for Naver Maps'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, url, source }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
const result = source === 'google-list'
|
||||
? await importGoogleList(String(tripId), url)
|
||||
: await importNaverList(String(tripId), url);
|
||||
|
||||
if ('error' in result) {
|
||||
return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
}
|
||||
|
||||
for (const place of result.places) {
|
||||
safeBroadcast(tripId, 'place:created', { place });
|
||||
}
|
||||
return ok({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'bulk_delete_places',
|
||||
{
|
||||
description: 'Delete multiple places from a trip at once. Removes all day assignments for each place as well. Warn the user before calling this — it cannot be undone.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeIds: z.array(z.number().int().positive()).min(1).max(200),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, placeIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
const deleted = deletePlacesMany(String(tripId), placeIds);
|
||||
for (const id of deleted) {
|
||||
safeBroadcast(tripId, 'place:deleted', { placeId: id });
|
||||
try { onPlaceDeleted(id); } catch {}
|
||||
}
|
||||
return ok({ deleted, count: deleted.length });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
server.registerTool(
|
||||
'create_reservation',
|
||||
{
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/train/car/cruise/event/tour/activity/other → use assignment_id; flight → no linking.',
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'),
|
||||
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
@@ -78,12 +78,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
server.registerTool(
|
||||
'update_reservation',
|
||||
{
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. Linking: hotel → use place_id to link to an accommodation place; restaurant/train/car/cruise/event/tour/activity/other → use assignment_id to link to a day assignment; flight → no linking.',
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For flights, trains, cars, and cruises, use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/other → use assignment_id to link to a day assignment.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'),
|
||||
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createReservation, deleteReservation, getReservation, updateReservation,
|
||||
} from '../../services/reservationService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canWrite } from '../scopes';
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const;
|
||||
|
||||
const endpointSchema = z.array(z.object({
|
||||
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
|
||||
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
|
||||
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
|
||||
code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'),
|
||||
local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'),
|
||||
local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'),
|
||||
})).optional();
|
||||
|
||||
export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
if (!canWrite(scopes, 'reservations')) return;
|
||||
|
||||
server.registerTool(
|
||||
'create_transport',
|
||||
{
|
||||
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
type: z.enum(['flight', 'train', 'car', 'cruise']),
|
||||
title: z.string().min(1).max(200),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled']).optional().default('pending'),
|
||||
start_day_id: z.number().int().positive().optional().describe('Departure day'),
|
||||
end_day_id: z.number().int().positive().optional().describe('Arrival day (if different from departure)'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'),
|
||||
reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
|
||||
endpoints: endpointSchema,
|
||||
needs_review: z.boolean().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
if (start_day_id && !getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const { reservation } = createReservation(tripId, {
|
||||
title,
|
||||
type,
|
||||
reservation_time,
|
||||
reservation_end_time,
|
||||
location: undefined,
|
||||
confirmation_number,
|
||||
notes,
|
||||
day_id: start_day_id,
|
||||
end_day_id: end_day_id ?? start_day_id,
|
||||
status: status ?? 'pending',
|
||||
metadata,
|
||||
endpoints,
|
||||
needs_review,
|
||||
});
|
||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_transport',
|
||||
{
|
||||
description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates). Use status "confirmed" to confirm.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
type: z.enum(['flight', 'train', 'car', 'cruise']).optional(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled']).optional(),
|
||||
start_day_id: z.number().int().positive().optional().describe('Departure day'),
|
||||
end_day_id: z.number().int().positive().optional().describe('Arrival day (if different from departure)'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'),
|
||||
reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
|
||||
endpoints: endpointSchema,
|
||||
needs_review: z.boolean().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, reservationId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
const existing = getReservation(reservationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true };
|
||||
|
||||
const resolvedType = type ?? existing.type;
|
||||
if (!(TRANSPORT_TYPES as readonly string[]).includes(resolvedType))
|
||||
return { content: [{ type: 'text' as const, text: 'Reservation is not a transport type. Use update_reservation instead.' }], isError: true };
|
||||
|
||||
if (start_day_id && !getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
title,
|
||||
type,
|
||||
reservation_time,
|
||||
reservation_end_time,
|
||||
confirmation_number,
|
||||
notes,
|
||||
day_id: start_day_id,
|
||||
end_day_id,
|
||||
status,
|
||||
metadata,
|
||||
endpoints,
|
||||
needs_review,
|
||||
}, existing);
|
||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_transport',
|
||||
{
|
||||
description: 'Delete a transport booking from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, reservationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const { deleted } = deleteReservation(reservationId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import {
|
||||
createOrUpdateShareLink, getShareLink, deleteShareLink,
|
||||
} from '../../services/shareService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { countMessages, listPolls } from '../../services/collabService';
|
||||
import {
|
||||
@@ -161,6 +161,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
|
||||
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
|
||||
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
|
||||
const collabFeatures = collabEnabled ? getCollabFeatures() : null;
|
||||
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
|
||||
// Core trip data (metadata, days, members, accommodations) is always included
|
||||
// because this tool is always registered and needed for navigation.
|
||||
@@ -173,16 +174,16 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
let pollCount = 0;
|
||||
let messageCount = 0;
|
||||
if (canReadCollab) {
|
||||
pollCount = listPolls(tripId).length;
|
||||
messageCount = countMessages(tripId);
|
||||
if (collabFeatures?.polls) pollCount = listPolls(tripId).length;
|
||||
if (collabFeatures?.chat) messageCount = countMessages(tripId);
|
||||
}
|
||||
const notice = getDeprecationNotice();
|
||||
const data = {
|
||||
const summaryData = {
|
||||
...summary,
|
||||
reservations: canReadRes ? summary.reservations : undefined,
|
||||
packing: canReadPacking ? summary.packing : undefined,
|
||||
budget: canReadBudget ? summary.budget : undefined,
|
||||
collab_notes: canReadCollab ? summary.collab_notes : [],
|
||||
reservations: canReadRes ? summary.reservations : undefined,
|
||||
packing: canReadPacking ? summary.packing : undefined,
|
||||
budget: canReadBudget ? summary.budget : undefined,
|
||||
collab_notes: canReadCollab && collabFeatures?.notes ? summary.collab_notes : [],
|
||||
todos,
|
||||
pollCount,
|
||||
messageCount,
|
||||
@@ -191,19 +192,10 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
isError: true as const,
|
||||
content: [
|
||||
{ type: 'text' as const, text: notice },
|
||||
{ type: 'text' as const, text: JSON.stringify(data, null, 2) },
|
||||
{ type: 'text' as const, text: JSON.stringify(summaryData, null, 2) },
|
||||
],
|
||||
};
|
||||
return ok({
|
||||
...summary,
|
||||
reservations: canReadRes ? summary.reservations : undefined,
|
||||
packing: canReadPacking ? summary.packing : undefined,
|
||||
budget: canReadBudget ? summary.budget : undefined,
|
||||
collab_notes: canReadCollab ? summary.collab_notes : [],
|
||||
todos,
|
||||
pollCount,
|
||||
messageCount,
|
||||
});
|
||||
return ok(summaryData);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -266,6 +266,7 @@ router.get('/collab-features', (_req: Request, res: Response) => {
|
||||
|
||||
router.put('/collab-features', (req: Request, res: Response) => {
|
||||
const result = svc.updateCollabFeatures(req.body);
|
||||
invalidateMcpSessions();
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
|
||||
@@ -87,6 +87,20 @@ oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request,
|
||||
scope_descriptions: Object.fromEntries(
|
||||
ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label])
|
||||
),
|
||||
resource_parameter_supported: true,
|
||||
});
|
||||
});
|
||||
|
||||
// RFC 9728 Protected Resource Metadata
|
||||
oauthPublicRouter.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
const base = (getAppUrl() || '').replace(/\/+$/, '');
|
||||
res.json({
|
||||
resource: `${base}/mcp`,
|
||||
authorization_servers: [base],
|
||||
bearer_methods_supported: ['header'],
|
||||
scopes_supported: ALL_SCOPES,
|
||||
resource_name: 'TREK MCP',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,7 +112,7 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
|
||||
|
||||
// Accept both JSON and application/x-www-form-urlencoded
|
||||
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
|
||||
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token } = body;
|
||||
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body;
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
@@ -133,6 +147,12 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
|
||||
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
}
|
||||
|
||||
// RFC 8707: if the auth code was bound to a resource, the token request must present the same value
|
||||
if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) {
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'resource_mismatch' }, ip });
|
||||
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
}
|
||||
|
||||
// Verify client secret
|
||||
if (!authenticateClient(client_id, client_secret)) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
@@ -146,8 +166,8 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
|
||||
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
}
|
||||
|
||||
const tokens = issueTokens(client_id, pending.userId, pending.scopes);
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes }, ip });
|
||||
const tokens = issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
|
||||
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
|
||||
return res.json(tokens);
|
||||
}
|
||||
|
||||
@@ -275,6 +295,7 @@ oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: R
|
||||
state: params.state,
|
||||
code_challenge: params.code_challenge || '',
|
||||
code_challenge_method: params.code_challenge_method || '',
|
||||
resource: typeof params.resource === 'string' ? params.resource : undefined,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
@@ -298,7 +319,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
|
||||
const { user } = req as AuthRequest;
|
||||
const {
|
||||
client_id, redirect_uri, scope, state,
|
||||
code_challenge, code_challenge_method, approved,
|
||||
code_challenge, code_challenge_method, approved, resource,
|
||||
} = req.body as {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
@@ -307,6 +328,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
|
||||
code_challenge: string;
|
||||
code_challenge_method: string;
|
||||
approved: boolean;
|
||||
resource?: string;
|
||||
};
|
||||
const ip = getClientIp(req);
|
||||
|
||||
@@ -332,6 +354,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
resource,
|
||||
};
|
||||
|
||||
const validation = validateAuthorizeRequest(params, user.id);
|
||||
@@ -350,6 +373,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
|
||||
userId: user.id,
|
||||
redirectUri: redirect_uri,
|
||||
scopes,
|
||||
resource: validation.resource ?? null,
|
||||
codeChallenge: code_challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ADDON_IDS } from '../addons';
|
||||
import { User } from '../types';
|
||||
import { writeAudit, logWarn } from './auditLog';
|
||||
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
|
||||
import { getAppUrl } from './oidcService';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -28,6 +29,7 @@ interface PendingCode {
|
||||
userId: number;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
resource: string | null;
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: 'S256';
|
||||
expiresAt: number;
|
||||
@@ -67,6 +69,7 @@ interface OAuthTokenRow {
|
||||
access_token_hash: string;
|
||||
refresh_token_hash: string;
|
||||
scopes: string; // JSON array
|
||||
audience: string | null;
|
||||
access_token_expires_at: string;
|
||||
refresh_token_expires_at: string;
|
||||
revoked_at: string | null;
|
||||
@@ -243,6 +246,7 @@ export function createAuthCode(params: {
|
||||
userId: number;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
resource: string | null;
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: 'S256';
|
||||
}): string | null {
|
||||
@@ -294,6 +298,7 @@ export function issueTokens(
|
||||
userId: number,
|
||||
scopes: string[],
|
||||
parentTokenId: number | null = null,
|
||||
audience: string | null = null,
|
||||
): {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
@@ -312,9 +317,9 @@ export function issueTokens(
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO oauth_tokens
|
||||
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, access_token_expires_at, refresh_token_expires_at, parent_token_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
|
||||
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
|
||||
|
||||
return {
|
||||
access_token: rawAccess,
|
||||
@@ -333,12 +338,13 @@ export interface OAuthTokenInfo {
|
||||
user: User;
|
||||
scopes: string[];
|
||||
clientId: string;
|
||||
audience: string | null;
|
||||
}
|
||||
|
||||
export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
|
||||
const hash = hashToken(rawToken);
|
||||
const row = db.prepare(`
|
||||
SELECT ot.scopes, ot.revoked_at, ot.access_token_expires_at,
|
||||
SELECT ot.scopes, ot.audience, ot.revoked_at, ot.access_token_expires_at,
|
||||
ot.user_id, ot.client_id, u.username, u.email, u.role
|
||||
FROM oauth_tokens ot
|
||||
JOIN users u ON ot.user_id = u.id
|
||||
@@ -353,6 +359,7 @@ export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
|
||||
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
|
||||
scopes: JSON.parse(row.scopes),
|
||||
clientId: row.client_id,
|
||||
audience: row.audience ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -406,7 +413,7 @@ export function refreshTokens(
|
||||
|
||||
const hash = hashToken(rawRefreshToken);
|
||||
const row = db.prepare(`
|
||||
SELECT id, client_id, user_id, scopes, refresh_token_expires_at, revoked_at, parent_token_id
|
||||
SELECT id, client_id, user_id, scopes, audience, refresh_token_expires_at, revoked_at, parent_token_id
|
||||
FROM oauth_tokens WHERE refresh_token_hash = ?
|
||||
`).get(hash) as OAuthTokenRow | undefined;
|
||||
|
||||
@@ -442,7 +449,7 @@ export function refreshTokens(
|
||||
|
||||
revokeUserSessionsForClient(row.user_id, clientId);
|
||||
|
||||
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id);
|
||||
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
|
||||
writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
|
||||
|
||||
return { tokens };
|
||||
@@ -522,6 +529,7 @@ export interface AuthorizeParams {
|
||||
state?: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method: string;
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface ValidateAuthorizeResult {
|
||||
@@ -530,6 +538,7 @@ export interface ValidateAuthorizeResult {
|
||||
error_description?: string;
|
||||
client?: { name: string; allowed_scopes: string[] };
|
||||
scopes?: string[];
|
||||
resource?: string | null;
|
||||
/** true when user is logged in but consent UI must be shown */
|
||||
consentRequired?: boolean;
|
||||
/** true when the request is valid but user is not authenticated */
|
||||
@@ -573,6 +582,13 @@ export function validateAuthorizeRequest(
|
||||
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
|
||||
}
|
||||
|
||||
// RFC 8707 resource indicator: if provided, must identify the TREK MCP endpoint exactly
|
||||
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
const resource = params.resource ? params.resource.replace(/\/+$/, '') : null;
|
||||
if (resource !== null && resource !== mcpResource) {
|
||||
return { valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' };
|
||||
}
|
||||
|
||||
const requestedScopes = (params.scope || '').split(' ').filter(Boolean);
|
||||
if (requestedScopes.length === 0) {
|
||||
return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' };
|
||||
@@ -599,6 +615,7 @@ export function validateAuthorizeRequest(
|
||||
valid: true,
|
||||
client: { name: client.name, allowed_scopes: allowedScopes },
|
||||
scopes: grantedScopes,
|
||||
resource: resource ?? mcpResource,
|
||||
consentRequired,
|
||||
scopeSelectable: client.created_via === 'dcr',
|
||||
};
|
||||
|
||||
@@ -94,8 +94,22 @@ describe('Photo endpoint auth', () => {
|
||||
});
|
||||
|
||||
describe('Force HTTPS redirect', () => {
|
||||
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests', async () => {
|
||||
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests on non-health paths', async () => {
|
||||
// createApp() reads FORCE_HTTPS at call time, so we need a fresh app instance
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
let httpsApp: Express;
|
||||
try {
|
||||
httpsApp = createApp();
|
||||
} finally {
|
||||
delete process.env.FORCE_HTTPS;
|
||||
}
|
||||
const res = await request(httpsApp)
|
||||
.get('/api/addons')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(301);
|
||||
});
|
||||
|
||||
it('MISC-008 — FORCE_HTTPS does not redirect /api/health (probes must reach it over HTTP)', async () => {
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
let httpsApp: Express;
|
||||
try {
|
||||
@@ -106,7 +120,8 @@ describe('Force HTTPS redirect', () => {
|
||||
const res = await request(httpsApp)
|
||||
.get('/api/health')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(301);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('MISC-004 — no redirect when FORCE_HTTPS is not set', async () => {
|
||||
|
||||
@@ -38,6 +38,7 @@ const { isAddonEnabledMock } = vi.hoisted(() => {
|
||||
});
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: isAddonEnabledMock,
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
|
||||
@@ -42,7 +42,10 @@ const { isAddonEnabledMock } = vi.hoisted(() => {
|
||||
const isAddonEnabledMock = vi.fn().mockReturnValue(true);
|
||||
return { isAddonEnabledMock };
|
||||
});
|
||||
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: isAddonEnabledMock }));
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: isAddonEnabledMock,
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
|
||||
const { mockGetTripSummary } = vi.hoisted(() => ({
|
||||
mockGetTripSummary: vi.fn(),
|
||||
|
||||
@@ -64,17 +64,17 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_reservation', () => {
|
||||
it('creates a basic flight reservation', async () => {
|
||||
it('creates a basic reservation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_reservation',
|
||||
arguments: { tripId: trip.id, title: 'Flight to Rome', type: 'flight' },
|
||||
arguments: { tripId: trip.id, title: 'Eiffel Tower Tour', type: 'tour' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.reservation.title).toBe('Flight to Rome');
|
||||
expect(data.reservation.type).toBe('flight');
|
||||
expect(data.reservation.title).toBe('Eiffel Tower Tour');
|
||||
expect(data.reservation.type).toBe('tour');
|
||||
expect(data.reservation.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
|
||||
// Mock async service functions that make external calls
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(),
|
||||
vi.mock('../../../src/demo/demo-reset', () => ({ saveBaseline: vi.fn() }));
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
|
||||
Reference in New Issue
Block a user