Compare commits
238 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d880cb881 | |||
| 3d076704f9 | |||
| 1b0f043506 | |||
| c383b11648 | |||
| fed564e7c0 | |||
| 9e216f5ae4 | |||
| 0f0232491b | |||
| 35fb59e64e | |||
| ee3fff1dab | |||
| 3bc75d2b1c | |||
| 094426debb | |||
| 313198ba63 | |||
| e677d58f91 | |||
| bc60aea839 | |||
| b5cd951bd2 | |||
| fd818050bd | |||
| 0313597b10 | |||
| 2c867673bf | |||
| 39ebf0976c | |||
| 1ecc449f3e | |||
| b8990782cc | |||
| 8ba7add541 | |||
| a3664e7117 | |||
| 64ed2175a5 | |||
| 447ae54b93 | |||
| 0067b0ff19 | |||
| d589da59f2 | |||
| 69ff295184 | |||
| d07c17ded6 | |||
| 44509723fd | |||
| b80fdf27f2 | |||
| a0b7b4d196 | |||
| aa24a9eff7 | |||
| de5417f9f0 | |||
| a48dd7645f | |||
| 217a576e16 | |||
| f6976d5cec | |||
| c1c2bbf146 | |||
| c05349f7c4 | |||
| a5342df648 | |||
| f44b143371 | |||
| d911122a37 | |||
| a69eee191c | |||
| 90f31d3f07 | |||
| d4da05bbc9 | |||
| bd1de9e1d5 | |||
| c33eb5b593 | |||
| dcfbf89234 | |||
| 6b6d971f07 | |||
| 9fc98b5d49 | |||
| daaa6c425d | |||
| b33ef6bec2 | |||
| eb9f6a3438 | |||
| 7823ab1550 | |||
| 47fdd3588c | |||
| a9ebd2d1ac | |||
| 9c32b42256 | |||
| 26611b46e4 | |||
| e3eec8d6d1 | |||
| 2d398568da | |||
| 4051af58a7 | |||
| 3681db6cd4 | |||
| 44cefc2fe2 | |||
| 176bfaefe0 | |||
| 2372dde716 | |||
| 42ed858853 | |||
| a9fb0fc475 | |||
| 5596355141 | |||
| 9c83386655 | |||
| 98961dadd5 | |||
| 7ada99acfc | |||
| 2ac333f842 | |||
| 270a13ff60 | |||
| d800e64f93 | |||
| 390135f3bc | |||
| cad7d54e83 | |||
| 2df677d065 | |||
| 03493d4f79 | |||
| eae74c5605 | |||
| ec486214f1 | |||
| d09a09ad52 | |||
| 8b41561db9 | |||
| 73744203fb | |||
| bc385f27dd | |||
| 866b9e94af | |||
| ed7c79bcb7 | |||
| 1e4b618878 | |||
| 982861264e | |||
| 6434fe7a3c | |||
| 70e07f1965 | |||
| 87041c8842 | |||
| edb113f97f | |||
| fcd5f5dbb6 | |||
| 6849742d67 | |||
| 022e088da4 | |||
| 2471fc5aa7 | |||
| fad301ef5e | |||
| 1c83bf78fa | |||
| a6cd4ffdff | |||
| 988d27c39d | |||
| 62b954cffc | |||
| a2fed6afb8 | |||
| 97510e007e | |||
| 36f81934aa | |||
| 303bce431d | |||
| 73811e0bb7 | |||
| 0ca9f7241a | |||
| bab4c7edcd | |||
| 166b0d8ab3 | |||
| a5dbf6934c | |||
| d2bd6e7556 | |||
| 676d086ff2 | |||
| d8793f3d0b | |||
| fcf4c6e736 | |||
| fa40dfba2c | |||
| 2a17227337 | |||
| a5757dd59f | |||
| 2e10263155 | |||
| 7a7c66e801 | |||
| a55728fe46 | |||
| 8bd5e428b4 | |||
| 504b58ecf6 | |||
| 0f05b0342c | |||
| 7eea0e7669 | |||
| 0f88a74976 | |||
| 9ffd5e5ffb | |||
| 00a326029b | |||
| a47b35b965 | |||
| fcfcf2ff17 | |||
| 016567dd9a | |||
| b64dd700a8 | |||
| b0b6f5872f | |||
| e119a2c936 | |||
| 45ae8b5af2 | |||
| e28bd2fc99 | |||
| 1696162d22 | |||
| f375a2f3cc | |||
| 53bd21a7d7 | |||
| 67d8f22622 | |||
| 8433251c86 | |||
| 13449c3338 | |||
| 35c3ab4bd7 | |||
| 7356de5124 | |||
| 8a2cb10f7b | |||
| 0050072b76 | |||
| da43a81d26 | |||
| a955328070 | |||
| 53c05ab33b | |||
| 5160bd23d5 | |||
| 48ff1b3a98 | |||
| cf35dc7afd | |||
| 4b3340546f | |||
| 1d9648267b | |||
| c0ec8e567b | |||
| 2508fd8624 | |||
| de497d15a4 | |||
| 05666295e0 | |||
| 62da83f5f7 | |||
| b333910533 | |||
| 2d1da19152 | |||
| e7ac46c756 | |||
| f1a2174f96 | |||
| 89c33ac3f4 | |||
| aeda658f18 | |||
| 2f3a0219cd | |||
| 41fb5759e2 | |||
| 6a77496438 | |||
| 487f8dd886 | |||
| 09f286fbfe | |||
| 4cc62f4c4e | |||
| cb34c46226 | |||
| e777118272 | |||
| 3f2da7a867 | |||
| 9c3649f011 | |||
| 73fdbdc0c3 | |||
| 0879b5aecc | |||
| 9729b0b575 | |||
| 35acdb8ba1 | |||
| a19fdf6161 | |||
| 813dd7a7eb | |||
| d51b9de2b0 | |||
| 6254510e0f | |||
| c525b2f84c | |||
| b3d30a834e | |||
| 589d3354eb | |||
| 8ce3b24a01 | |||
| 06a1dcd730 | |||
| 4b633d8cd2 | |||
| 728a275fef | |||
| f6befaa6b2 | |||
| fd6d3fddd5 | |||
| 280e55bbc9 | |||
| e84060cdf7 | |||
| fbc85b05a1 | |||
| 01333c6ff3 | |||
| aecef03090 | |||
| 8dacd0a50c | |||
| 3c73da4427 | |||
| cff2e55db7 | |||
| 429a25faa9 | |||
| 486a2eeca3 | |||
| 26a6147e8d | |||
| a7b53988ff | |||
| 3f3e2f0bcf | |||
| f956ab07f3 | |||
| 736594696e | |||
| 1be997f7d8 | |||
| 5a8ff16afa | |||
| 4435dc32b8 | |||
| f5a33e0c1e | |||
| a8326b6f5a | |||
| 1e999d968a | |||
| d0f74c9f02 | |||
| f14f1e41b9 | |||
| 9138e722bb | |||
| 9695e269e3 | |||
| 82bdf62c7b | |||
| 8893e3afe0 | |||
| cd1e7d8fa8 | |||
| 059e72df69 | |||
| dc519b3ea7 | |||
| ad4074f107 | |||
| 0534d7b3a7 | |||
| a0554c2346 | |||
| bc5af23896 | |||
| 92be8a7ca5 | |||
| 44ea1ea0ba | |||
| 3bee600096 | |||
| 5b4030f86b | |||
| e2c00ba18b | |||
| 1050029842 | |||
| 64edf899ac | |||
| 0a9075af73 | |||
| c3a5383f5e | |||
| e96813edcf | |||
| 5e36e38b72 | |||
| 890473ebd5 | |||
| 2b86d1fda7 |
@@ -1161,7 +1161,7 @@ jobs:
|
||||
docker run -d --name trek-ci -p 127.0.0.1:3000:3000 \
|
||||
--read-only --tmpfs /tmp:noexec,nosuid,size=128m \
|
||||
--security-opt no-new-privileges:true \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add KILL \
|
||||
-v "$RUNNER_TEMP/trek/data:/app/data" \
|
||||
-v "$RUNNER_TEMP/trek/uploads:/app/uploads" \
|
||||
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
||||
@@ -1194,6 +1194,17 @@ jobs:
|
||||
curl -sf http://127.0.0.1:3000/ | grep -q '<div id="root">' \
|
||||
|| { echo "::error::/ does not serve the client"; exit 1; }
|
||||
|
||||
# Same capabilities as the shipped compose file: dumb-init has to be able
|
||||
# to pass the stop signal on to node, or docker stop ends in SIGKILL (137).
|
||||
- name: Stop gracefully
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker stop -t 30 trek-ci >/dev/null
|
||||
code="$(docker inspect -f '{{.State.ExitCode}}' trek-ci)"
|
||||
[ "$code" = "0" ] || { echo "::error::docker stop ended with exit code $code, not a graceful shutdown"; exit 1; }
|
||||
docker logs trek-ci 2>&1 | grep -q 'SIGTERM received' \
|
||||
|| { echo "::error::the server never saw SIGTERM"; exit 1; }
|
||||
|
||||
- name: Container log
|
||||
if: always()
|
||||
run: docker logs trek-ci 2>&1 || true
|
||||
|
||||
@@ -22,7 +22,6 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY client/package.json ./client/
|
||||
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
|
||||
RUN npm ci --workspace=client
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY client/ ./client/
|
||||
|
||||
@@ -62,7 +62,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<br />
|
||||
|
||||
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
|
||||
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Tours, Vacay and Atlas ship on; Journey, Collections, MCP, the AI assistant, AI Parsing, Road trip, Dawarich and AirTrail ship off and are marked below.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -70,76 +70,110 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
#### 🧭 Planning
|
||||
|
||||
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
|
||||
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
|
||||
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
|
||||
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
|
||||
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
|
||||
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
|
||||
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
|
||||
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
|
||||
- **Public transport**: door-to-door itineraries over Transitous
|
||||
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
|
||||
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
|
||||
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
|
||||
- **Day plans**: drag places, notes and bookings between days with undo, or drop a map marker straight onto a day
|
||||
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token) with clustering, photo markers, route lines and a whole-trip route overview
|
||||
- **Places**: search the TREK Places index and OpenStreetMap with no key, or Google Places and Amap with one. Descriptions, hours and photos from Wikipedia, Wikidata and Wikimedia Commons, plus a rating and a custom image per place
|
||||
- **Explore**: nearby places by category for the map view, from the TREK Places index with Overpass as fallback
|
||||
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotels stay put), a travel mode per leg over OSRM or Valhalla, then open the day in Google Maps or CoMaps and a place in seven navigation apps
|
||||
- **Public transport**: door-to-door itineraries over Transitous, or Google where Transitous has no data
|
||||
- **Import and export**: Google Maps and Naver Maps lists, Google Maps directions links, GPX, KML and KMZ in; GPX and an ICS feed per trip or across all trips out
|
||||
- **Weather**: 16-day forecast from Open-Meteo, no key, and the archive for dates beyond it
|
||||
- **Trip dates**: move a trip and its days re-date themselves, with the bookings along or re-anchored. Trips also copy and archive
|
||||
- **Road trip** (off by default): drives with stops, day windows and arrival times that recompute, route alternatives, hazards and EV charging with range
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Bookings and money
|
||||
#### 🥾 Tours
|
||||
|
||||
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
|
||||
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
|
||||
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
|
||||
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
|
||||
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
|
||||
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
|
||||
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
|
||||
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
|
||||
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
|
||||
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
|
||||
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
|
||||
- **Seven tour types**: hike, city walk, run, bike (touring, road, gravel, MTB), paddle (kayak, canoe, SUP), horse ride, and ski and winter (alpine, cross-country, touring, snowshoe, winter hike)
|
||||
- **Routing**: legs routed over Valhalla with shaping points, hand-drawn legs, route variants, round hikes of a wished length and trip places as stops, all with undo
|
||||
- **Hikes**: routed within a maximum SAC grade (T1 to T6), the trail grades along the way, and via ferratas on the map
|
||||
- **Terrain**: elevation profile, ascent and descent, and a 3D terrain map with grade colouring and a flyover, no token needed. Topo, hiking, cycling and satellite bases with sea marks and ski pistes as overlays
|
||||
- **Multi-day tours**: one route cut into stages and spread across the trip's days
|
||||
- **Along the way**: viewpoints, huts, water, food and more within a corridor of the route, inserted as a stop with the detour it adds
|
||||
- **Ski, paddle and city walks**: a piste router by difficulty and lift type; a water router with portages, locks and a wind forecast; city walks with stays, opening hours and transit between stops
|
||||
- **Plan against reality**: link a recorded GPX to a planned day and compare distance, ascent and time. Import trails from a wanderer instance, save tours to a collection and take them into another trip
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Bookings and money
|
||||
|
||||
- **Reservations**: 18 booking types with status, confirmation code, travellers and attached files
|
||||
- **Flights and trains**: several legs with stopovers and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
|
||||
- **Accommodation**: a stay spans a range of nights with a check-in window
|
||||
- **Booking import**: EML, PDF, PKPass, HTML, TXT and photos of confirmations, through [KItinerary](https://invent.kde.org/pim/kitinerary) or an AI model, with a review step before anything is saved. Prices become linked costs
|
||||
- **AirTrail** (off by default): import flights from a self-hosted AirTrail instance and keep them in sync
|
||||
- **Costs**: split in integer cents with equal or custom shares, several payers, settle-up suggestions, a per-person summary, a settlement log and CSV export. A receipt photo can fill in an expense
|
||||
- **Currencies**: one per expense with the rate frozen at entry, from Frankfurter with no key
|
||||
- **Packing lists and to-dos**: categories, templates, assignees and visibility tiers; bags with weights as an admin switch; to-dos with due dates, priorities and reminders
|
||||
- **Files**: attach to a trip, place, day or booking, 50 MB each and 500 MB for video, with trash and restore
|
||||
- **PDF export**: cover, place photos, day notes, bookings and costs, with an optional page break per day
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 👥 Collaboration
|
||||
|
||||
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
|
||||
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
|
||||
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
|
||||
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
|
||||
- **Public share**: a read-only trip page anyone can open without an account
|
||||
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
|
||||
- **Real-time sync** (WebSocket): edits land live for everyone who has the trip open
|
||||
- **Members**: add by email or username, hand over ownership, or add guests who have no login at all
|
||||
- **Permissions**: an admin maps each of 17 trip actions to admin, trip owner, trip member or everybody
|
||||
- **Invites**: one reusable link per trip with an optional expiry, and admin signup invites that drop a new account straight into a trip
|
||||
- **Public share**: a read-only trip page without an account, with switches for map, bookings, packing, costs and chat
|
||||
- **Collab**: group chat with replies, reactions and link previews, shared notes, links, polls and a What's Next list, each one its own switch
|
||||
- **Notifications**: an inbox grouped by day with answers right from the notification, and a per-user matrix of events against in-app, email, webhook, ntfy, Web Push and plugin channels
|
||||
|
||||
#### 📔 Journal, Atlas and Vacay
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
|
||||
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
|
||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
|
||||
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
|
||||
#### 📔 Journey and TREK Studio
|
||||
|
||||
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads, [Immich](https://immich.app) or Synology Photos, map views, co-authors, drafts and a public share link
|
||||
- **Linked journal**: a trip can show one journal as its own, with the members picked on the trip as viewers or editors
|
||||
- **Recorded days** (with Dawarich): keep a day's recorded route and turn its stays into entries
|
||||
- **TREK Studio**: turn a journal into a printable photo book. 34 print-licensed typefaces, 17 themes, 108 designed pages in twelve styles, 160 recolourable stickers, text effects, layers and a Travel tab with route maps, country stamps and trip figures
|
||||
- **Auto layout**: lays the whole book on the designed pages in the style of its theme, every photo in a frame, or rebuilds one spread
|
||||
- **Print and share**: PDF export as pages or spreads with crop marks, several people editing at once, and design files to move a spread into another book
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🗺️ Atlas, Vacay and Collections
|
||||
|
||||
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
|
||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
|
||||
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
|
||||
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🤖 AI assistant and MCP
|
||||
|
||||
- **AI assistant** (off by default): a chat that plans with you in a trip, across all your trips, in a journal and in Studio, and works with tours, costs, bookings, packing, Vacay and the Atlas
|
||||
- **Your own model**: an API key for Anthropic, OpenAI, Gemini or OpenRouter, a ChatGPT plan, or any OpenAI-compatible endpoint, set for the instance or per user
|
||||
- **You stay in charge**: reads run at once, every change waits as a card to apply or discard and can be undone for 15 minutes. Nothing leaves before you agree to the provider, and a file's text only with your consent
|
||||
- **Attachments**: drop a booking confirmation, a GPX file or a photo into the chat, and the assistant offers what to do with it
|
||||
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration, 410 tools, 30 resources and 4 prompts, 35 scopes in 17 groups ticked on the consent screen, and every call in the audit log
|
||||
- **Addon-aware**: twelve addon gates decide which tools a session sees, and flipping one re-registers live sessions
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧩 Plugins
|
||||
|
||||
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
|
||||
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
|
||||
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
|
||||
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
|
||||
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
|
||||
|
||||
#### 🤖 AI and MCP
|
||||
|
||||
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
|
||||
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
|
||||
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
|
||||
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
|
||||
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
|
||||
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
|
||||
- **Third-party plugins**: install from the TREK registry or sideload a zip, with their own pages under `/plugins/<id>`
|
||||
- **Sandboxed**: one child process per plugin, 66 grantable permissions, an admin-edited outbound host allowlist, memory and call caps
|
||||
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key, a sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
|
||||
- **Extension points**: map layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, search and photo providers, route profiles, notification channels and MCP tools
|
||||
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) with a CLI to create, validate, run against real data, sign and publish
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -148,30 +182,26 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
#### 📱 Mobile and offline
|
||||
|
||||
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
|
||||
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
|
||||
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
|
||||
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
|
||||
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
|
||||
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
|
||||
- **Installable**: iOS and Android straight from the browser, no App Store, standalone with a themed status bar
|
||||
- **Phone layouts**: their own screens for every part of the app, with a bottom bar and dashboard widgets the user orders
|
||||
- **Offline reads**: the app shell is precached and trips, places, tours, files and nearby places live in a per-user IndexedDB store, so a trip opens with no network
|
||||
- **Offline writes**: changes queue and replay with an `X-Idempotency-Key`, and a stale edit waits for a keep-mine or keep-theirs choice
|
||||
- **Offline maps**: raster and vector tiles of a trip downloaded ahead, and a switch that takes the whole app offline on purpose
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### ⚙️ Admin, accounts and security
|
||||
|
||||
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
|
||||
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
|
||||
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
|
||||
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
|
||||
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
|
||||
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
|
||||
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
|
||||
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
|
||||
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
|
||||
- **Sign-in**: password, OIDC and passkeys each switch on and off on their own, `OIDC_ONLY` leaves SSO as the only way in, and an OIDC claim can grant admin
|
||||
- **SSO and 2FA**: one OIDC provider by discovery with PKCE (Authentik, Keycloak, Google and the like), TOTP with backup codes that an admin can require, and passkeys that count as the second factor
|
||||
- **Sessions**: every sign-in is a session you can see and end, one by one or all others at once
|
||||
- **Hardening**: rate limits on sign-in, reset and 2FA, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
|
||||
- **Admin panel**: users and the permission matrix, defaults for new users, categories and templates, addons, plugins, storage, notifications, MCP tokens, backups, audit log and releases
|
||||
- **Backups and storage**: scheduled backups with retention that restore their own secrets, and local or S3-compatible storage per content category with replicas
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, density, reduced motion and text size
|
||||
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
|
||||
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
|
||||
- **Help center**: a ? in the top bar with 381 step-by-step guides with screenshots, searched together with the wiki that ships in the image
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -204,12 +234,15 @@ the details.
|
||||
## Get started in 30 seconds
|
||||
|
||||
```bash
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d --name trek -p 3000:3000 \
|
||||
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||
-e COOKIE_SECURE=false \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
|
||||
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.3.4
|
||||
version: 5.0.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.3.4"
|
||||
appVersion: "5.0.0"
|
||||
|
||||
@@ -460,6 +460,20 @@ const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' }
|
||||
const thumbs = (page: Page) => page.locator('.st-thumb-row')
|
||||
/** The hit targets over the spread, one per element. */
|
||||
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
|
||||
/**
|
||||
* The hit target of the first text on the spread on screen. The targets above the page are empty
|
||||
* boxes, so the one over the drawn words is found by where the words are and marked for the locator.
|
||||
*/
|
||||
async function markTextTarget(page: Page): Promise<void> {
|
||||
const words = page.locator('.st-sheet').getByText(/\p{L}{3,}/u).first()
|
||||
await expect(words).toBeVisible()
|
||||
await words.evaluate(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||||
hit?.setAttribute('data-help-text', '')
|
||||
})
|
||||
}
|
||||
const textTarget = (page: Page) => page.locator('.st-sheet [data-help-text]')
|
||||
|
||||
async function studioSection(page: Page, name: string): Promise<void> {
|
||||
await railButton(page, name).click()
|
||||
@@ -1300,7 +1314,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.st-back'),
|
||||
target: p => p.locator('.st-head-close'),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1458,16 +1472,162 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
],
|
||||
},
|
||||
|
||||
'studio-my-layouts': {
|
||||
guide: guide('studio-my-layouts'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => railButton(p, 'Layouts'),
|
||||
act: async p => { await studioSection(p, 'Layouts') },
|
||||
},
|
||||
{
|
||||
// Pointed at rather than pressed, so the seed book keeps no layout of its own for the guides after this one.
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('button', { name: 'Save this page as a layout' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => sidePanel(p).locator('.st-saved-layouts'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-theme': {
|
||||
guide: guide('studio-theme'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
target: p => railButton(p, 'Theme'),
|
||||
act: async p => { await studioSection(p, 'Theme') },
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-theme-grid'),
|
||||
act: async p => {
|
||||
await sidePanel(p).locator('.st-theme-card').nth(1).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('button', { name: 'Apply to the book' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => sidePanel(p).locator('.st-theme-apply'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-stickers': {
|
||||
guide: guide('studio-stickers'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => { await studioSection(p, 'Elements') },
|
||||
target: p => sidePanel(p).getByRole('tab', { name: 'Stickers' }),
|
||||
act: async p => {
|
||||
await sidePanel(p).getByRole('tab', { name: 'Stickers' }).click()
|
||||
await expect(sidePanel(p).locator('.st-shape-grid').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-shape-grid').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-travel': {
|
||||
guide: guide('studio-travel'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
target: p => railButton(p, 'Travel'),
|
||||
act: async p => { await studioSection(p, 'Travel') },
|
||||
act: async p => {
|
||||
await studioSection(p, 'Travel')
|
||||
await expect(sidePanel(p).locator('.st-travel-card').first()).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: sidePanel,
|
||||
target: p => sidePanel(p).locator('.st-travel-grid').first(),
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-travel-card').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-text-effects': {
|
||||
guide: guide('studio-text-effects'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
await markTextTarget(p)
|
||||
},
|
||||
target: p => textTarget(p),
|
||||
act: async p => {
|
||||
await textTarget(p).click()
|
||||
await expect(inspector(p).getByRole('button', { name: 'Effects' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
const head = inspector(p).getByRole('button', { name: 'Effects' })
|
||||
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click()
|
||||
await head.scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('.st-section').filter({ has: p.getByRole('button', { name: 'Effects' }) }),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-background': {
|
||||
guide: guide('studio-background'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('.st-section').filter({ hasText: 'Page background' }).first(),
|
||||
},
|
||||
{
|
||||
target: p => inspector(p).locator('.st-paint-tabs').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-layers': {
|
||||
guide: guide('studio-layers'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => railButton(p, 'Layers'),
|
||||
act: async p => {
|
||||
await studioSection(p, 'Layers')
|
||||
await expect(sidePanel(p).locator('.st-layer').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-layer').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"preview": "vite preview",
|
||||
@@ -41,9 +41,7 @@
|
||||
"shots:promote": "node e2e/screenshots/promote.mjs",
|
||||
"e2e:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"postinstall": "node scripts/patch-maplibre.mjs",
|
||||
"predev": "node scripts/patch-maplibre.mjs"
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
@@ -94,7 +92,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"modern-screenshot": "4.7.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.3.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 385,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"@typescript-eslint/no-non-null-assertion": 382,
|
||||
"@typescript-eslint/no-misused-promises": 848,
|
||||
"no-console": 88,
|
||||
"complexity": 222,
|
||||
"complexity": 220,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 556,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
@@ -20,7 +20,7 @@
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
"react-refresh/only-export-components": 45,
|
||||
"react-refresh/only-export-components": 44,
|
||||
"unused-disable-directive": 6,
|
||||
"max-depth": 21,
|
||||
"react-hooks/void-use-memo": 1,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1931,
|
||||
"@typescript-eslint/no-non-null-assertion": 1927,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
},
|
||||
{
|
||||
"feature": "AI assistant chat",
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts", "src/components/Assistant/useAssistantStatus.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
|
||||
},
|
||||
@@ -661,9 +661,24 @@
|
||||
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Demo account locks",
|
||||
"hooks": ["src/components/Demo/useDemoLock.ts"],
|
||||
"desktop": [
|
||||
"src/components/Settings/AccountDemoLock.tsx",
|
||||
"src/components/Settings/DisplaySettingsTab.tsx",
|
||||
"src/components/Settings/IntegrationsTab.tsx",
|
||||
"src/components/Settings/ai/AiConnectionSection.tsx"
|
||||
],
|
||||
"mobile": [
|
||||
"src/mobile/screens/settings/MAiConnectionSection.tsx",
|
||||
"src/mobile/screens/settings/MSettingsGeneral.tsx",
|
||||
"src/mobile/screens/settings/MSettingsMcp.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Account",
|
||||
"hooks": ["src/components/Settings/useAccountSettings.ts"],
|
||||
"hooks": ["src/components/Settings/AccountDemoLock.tsx", "src/components/Settings/useAccountSettings.ts"],
|
||||
"desktop": ["src/components/Settings/AccountTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
|
||||
},
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
"scripts/check-gl-split.mjs",
|
||||
"scripts/check-page-pattern.mjs",
|
||||
"scripts/generate-icons.mjs",
|
||||
"scripts/patch-maplibre.mjs",
|
||||
"src/App.test.tsx",
|
||||
"src/App.tsx",
|
||||
"src/App.versionHandover.test.tsx",
|
||||
@@ -409,7 +408,6 @@
|
||||
"src/components/Journey/usePhotoZoom.ts",
|
||||
"src/components/Layout/BottomNav.test.tsx",
|
||||
"src/components/Layout/BottomNav.tsx",
|
||||
"src/components/Layout/DemoBanner.tsx",
|
||||
"src/components/Layout/InAppNotificationBell.test.tsx",
|
||||
"src/components/Layout/InAppNotificationBell.tsx",
|
||||
"src/components/Layout/MobileTopBar.test.tsx",
|
||||
|
||||
@@ -146,6 +146,13 @@ export const DYNAMIC_ALLOWED = [
|
||||
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
|
||||
'sticker the library holds against en',
|
||||
},
|
||||
{
|
||||
file: 'components/Studio/shapes.ts',
|
||||
template: 'journey.studio.shapeName.${shape}',
|
||||
because:
|
||||
'shapeName takes a BookShapeId; shared/src/book/book-shape-catalogue.spec.ts holds an en name for every ' +
|
||||
'shape of BOOK_SHAPE_GROUPS',
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"admin.ts": 126,
|
||||
"admin.ts": 112,
|
||||
"atlas.ts": 9,
|
||||
"backup.ts": 10,
|
||||
"budget.ts": 36,
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
||||
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
||||
"src/components/Studio/StudioShell.tsx": 1,
|
||||
"src/components/Studio/TravelElements.tsx": 1,
|
||||
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
|
||||
"src/components/Studio/travel/BadgeIcon.tsx": 1,
|
||||
"src/components/Todo/useTodoList.ts": 1,
|
||||
"src/components/shared/ConfirmDialog.tsx": 1,
|
||||
"src/components/shared/CountryFlag.tsx": 1,
|
||||
|
||||
@@ -150,7 +150,6 @@
|
||||
"src/pages/resetPassword/useResetPassword.ts",
|
||||
"src/pages/settings/useSettings.ts",
|
||||
"src/pages/sharedTrip/useSharedTrip.ts",
|
||||
"src/pages/tripPlanner/useBookingEdits.ts",
|
||||
"src/pages/tripPlanner/usePlaceFormOpeners.ts",
|
||||
"src/pages/tripPlanner/usePlannerDialogs.ts",
|
||||
"src/pages/tripPlanner/useTripPlanner.ts",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const packagePath = require.resolve('maplibre-gl/package.json')
|
||||
const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
|
||||
|
||||
if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
|
||||
|
||||
// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
|
||||
const loops = [
|
||||
['of elem.attributes)', 'of Array.from(elem.attributes))'],
|
||||
['of e.attributes)', 'of Array.from(e.attributes))'],
|
||||
]
|
||||
|
||||
for (const filename of [
|
||||
'src/util/dom.ts',
|
||||
'dist/maplibre-gl.js',
|
||||
'dist/maplibre-gl-dev.js',
|
||||
'dist/maplibre-gl-csp.js',
|
||||
'dist/maplibre-gl-csp-dev.js',
|
||||
]) {
|
||||
const target = join(dirname(packagePath), filename)
|
||||
const code = readFileSync(target, 'utf8')
|
||||
const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
|
||||
if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
|
||||
const [before, after] = loop
|
||||
if (code.split(before).length === 1 && code.split(after).length === 2) continue
|
||||
if (code.split(before).length !== 2 || code.includes(after)) {
|
||||
throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
|
||||
}
|
||||
writeFileSync(target, code.replace(before, after))
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"components/Admin/AdminPluginsPanel.tsx": 2,
|
||||
"components/Admin/plugins/pluginMenu.ts": 2,
|
||||
"components/Budget/BudgetPanelMemberChips.tsx": 6,
|
||||
"components/Budget/CostsPanel.tsx": 3,
|
||||
"components/Budget/ReceiptPreviewModal.tsx": 2,
|
||||
@@ -59,8 +59,6 @@
|
||||
"components/Studio/StudioSavedLayouts.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 4,
|
||||
"components/Studio/TravelPreview.tsx": 1,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
|
||||
"components/SystemNotices/releaseNotice.css": 2,
|
||||
"components/Vacay/VacayCalendar.tsx": 3,
|
||||
"components/Vacay/VacayMonthCard.tsx": 7,
|
||||
"components/Vacay/VacaySettings.tsx": 1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"components/Collections/CollectionMap.tsx": 1,
|
||||
"components/Studio/LineHandles.tsx": 1,
|
||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||
"components/Studio/inspector/FocalPad.tsx": 1,
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
"e2e/help/fixtures.ts": 2291,
|
||||
"src/api/client.ts": 1873,
|
||||
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1399,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapView.tsx": 1385,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/RouteCalculator.ts": 1076,
|
||||
"src/components/Map/RouteCalculator.ts": 1063,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2953,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2936,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"src/components/Studio/TravelElements.tsx": 1738,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
@@ -26,5 +26,5 @@
|
||||
"src/pages/atlas/useAtlas.ts": 1211,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 2009
|
||||
"src/styles/studio.css": 1918
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"App.tsx": 11,
|
||||
"components/Admin/AdminPluginsPanel.tsx": 2,
|
||||
"components/Admin/DefaultUserSettingsTab.tsx": 1,
|
||||
"components/Admin/plugins/pluginMenu.ts": 2,
|
||||
"components/Atlas/AtlasCountryPlaces.tsx": 6,
|
||||
"components/BackgroundTasks/BackgroundTasksWidget.tsx": 4,
|
||||
"components/Budget/BudgetPanel.helpers.ts": 2,
|
||||
@@ -51,8 +51,7 @@
|
||||
"components/Journey/journeyMapPopup.ts": 17,
|
||||
"components/Journey/moodConfig.ts": 5,
|
||||
"components/Layout/BottomNav.tsx": 3,
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 8,
|
||||
"components/Layout/InAppNotificationBell.tsx": 3,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
@@ -72,7 +71,6 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 8,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -107,7 +105,6 @@
|
||||
"components/Studio/TravelElements.tsx": 5,
|
||||
"components/Studio/printSheets.ts": 5,
|
||||
"components/Studio/sidebar/contentDrops.ts": 2,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
|
||||
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
|
||||
"components/SystemNotices/SystemNoticeModal.tsx": 107,
|
||||
"components/Todo/TodoListPanel.tsx": 6,
|
||||
@@ -208,7 +205,7 @@
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
|
||||
"mobile/screens/settings/MSettings.tsx": 2,
|
||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 21,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 20,
|
||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||
"mobile/screens/settings/MSettingsMap.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 30,
|
||||
@@ -295,7 +292,6 @@
|
||||
"pages/FilesPage.tsx": 11,
|
||||
"pages/ForgotPasswordPage.tsx": 18,
|
||||
"pages/HelpPage.tsx": 21,
|
||||
"pages/InAppNotificationsPage.tsx": 9,
|
||||
"pages/JoinTripPage.tsx": 4,
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
|
||||
@@ -44,9 +44,10 @@
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"components/Studio/StudioElementsPanel.tsx": 2,
|
||||
"components/Studio/StudioSwatches.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 6,
|
||||
"components/Studio/TravelElements.tsx": 3,
|
||||
"components/Studio/peerColour.ts": 1,
|
||||
"components/Studio/printSheets.ts": 4,
|
||||
"components/Studio/travel/badgeLook.ts": 2,
|
||||
"components/Trips/TripMemberAvatar.tsx": 2,
|
||||
"components/Vacay/VacayPersons.tsx": 1,
|
||||
"components/shared/DialogShell.tsx": 2,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type AiApiKeyConnectRequest,
|
||||
type AiAuthStartResponse,
|
||||
type AiChatgptFinishRequest,
|
||||
type AiClaudeConnectorResponse,
|
||||
type AiConnectionScope,
|
||||
type AiConnectionView,
|
||||
type AiInstanceConnectionRequest,
|
||||
@@ -230,10 +229,8 @@ export const aiAdminApi = {
|
||||
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
|
||||
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
|
||||
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
|
||||
setModel: (model: string): Promise<AiAdminConnectionResponse> =>
|
||||
apiClient.patch('/admin/ai/connection/model', { model }).then((r) => r.data),
|
||||
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
|
||||
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const claudeConnectorApi = {
|
||||
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
|
||||
};
|
||||
|
||||
@@ -692,6 +692,7 @@ describe('client > request payloads', () => {
|
||||
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
|
||||
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
|
||||
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })
|
||||
expect((await traceOne(() => adminApi.pluginActivate('koffi', false, true))).body).toEqual({ acceptTrekRange: true })
|
||||
})
|
||||
|
||||
it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => {
|
||||
|
||||
@@ -574,9 +574,9 @@ export const adminApi = {
|
||||
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
||||
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
|
||||
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
||||
pluginInstall: (id: string, opts?: Pick<PluginInstallRequest, 'version' | 'constraint' | 'withDependencies'>) =>
|
||||
pluginInstall: (id: string, opts?: Omit<PluginInstallRequest, 'id'>) =>
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean, accept?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, { consent: consent || undefined, acceptTrekRange: accept || undefined }).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
// `version` pins the exact version to install (the rollback path); omitted, the server
|
||||
// resolves the newest TREK-compatible version itself.
|
||||
|
||||
@@ -712,4 +712,33 @@ describe('AddonManager', () => {
|
||||
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
|
||||
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-045: finds an addon by name or description and shows the ones on or off', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'x-lists', name: 'Todo List', description: 'Track tasks', type: 'trip', enabled: true }),
|
||||
buildAddon({ id: 'x-costs', name: 'Budget', description: 'Split costs', type: 'trip', enabled: false }),
|
||||
buildAddon({ id: 'x-holiday', name: 'Vacay', description: 'Holiday planner', type: 'global', enabled: true }),
|
||||
])
|
||||
);
|
||||
render(<AddonManager />);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'costs');
|
||||
expect(screen.getByText('Budget')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Todo List')).toBeNull();
|
||||
expect(screen.queryByText('Vacay')).toBeNull();
|
||||
|
||||
await user.clear(screen.getByRole('searchbox', { name: 'Search addons' }));
|
||||
await user.click(screen.getByRole('button', { name: 'On' }));
|
||||
expect(screen.getByText('Todo List')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vacay')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Budget')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Off' }));
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'nothing like this');
|
||||
expect(screen.getByText('No addon matches the search or the filter.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { useState, type ComponentType } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Search } from 'lucide-react'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/documentProviderIconMap'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
@@ -76,6 +76,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
const [query, setQuery] = useState('')
|
||||
const [view, setView] = useState<AddonView>('all')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -194,10 +196,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
}
|
||||
|
||||
/* One column per type, side by side, instead of three stacked sections. Stacked, each
|
||||
section opened its own set of columns and the tall tiles (Collab, Journey) set a
|
||||
section's height while its neighbours ran out early. Side by side the column's
|
||||
head carries the type, so the tiles need no badge of their own. */
|
||||
/* A card per type, the way every other admin tab sets its cards: Trip in the
|
||||
left column, Global and Integration in the right one, each card with the
|
||||
dialogs' head band carrying the type, its count and what it means. Above
|
||||
them one card says how many are on and finds an addon by name. */
|
||||
const groups = [
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ key: 'global', addons: globalAddons },
|
||||
@@ -214,68 +216,145 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Puzzle}
|
||||
title={t('admin.addons.title')}
|
||||
hint={subtitle}
|
||||
badge={totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
>
|
||||
{addons.length === 0 ? (
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
return (
|
||||
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
|
||||
<div className="divide-y divide-edge-faint">
|
||||
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
|
||||
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
|
||||
{g.addons.map(tile)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
/** Whether an addon is shown under the search and the filter. */
|
||||
const shown = (addon: Addon) => {
|
||||
if (view === 'on' && !addon.enabled) return false
|
||||
if (view === 'off' && addon.enabled) return false
|
||||
const q = query.trim().toLocaleLowerCase()
|
||||
if (!q) return true
|
||||
const label = getAddonLabel(t, addon)
|
||||
return `${label.name} ${label.description ?? ''}`.toLocaleLowerCase().includes(q)
|
||||
}
|
||||
|
||||
const card = (g: (typeof groups)[number]) => {
|
||||
const meta = TYPE_META[g.key]
|
||||
const all = g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons
|
||||
const visible = g.addons.filter(shown)
|
||||
const ai = g.key === 'integration' && aiAddons.some(shown)
|
||||
if (!visible.length && !ai) return null
|
||||
const on = all.filter(a => a.enabled).length
|
||||
return (
|
||||
<SettingsCard
|
||||
key={g.key}
|
||||
icon={meta.icon}
|
||||
title={t(meta.labelKey)}
|
||||
hint={t(meta.hintKey)}
|
||||
badge={(
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: on, total: all.length })}>
|
||||
<span className="inline-flex"><StatusPill tone={on > 0 ? 'success' : 'neutral'}>{on}/{all.length}</StatusPill></span>
|
||||
</Tooltip>
|
||||
)}
|
||||
bodyClassName="p-3"
|
||||
>
|
||||
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{/* The AI addons share a connection and a settings dialog, so they head the card as one tile. */}
|
||||
{ai && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
|
||||
{visible.map(tile)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
const [first, ...rest] = groups
|
||||
const cards = { start: first ? card(first) : null, end: rest.map(card).filter(Boolean) }
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsCard
|
||||
icon={Puzzle}
|
||||
title={t('admin.addons.title')}
|
||||
hint={subtitle}
|
||||
badge={totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
>
|
||||
{addons.length === 0
|
||||
? <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
: <AddonToolbar groups={groups.map(g => ({ key: g.key, addons: g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons }))} query={query} onQuery={setQuery} view={view} onView={setView} t={t} />}
|
||||
</SettingsCard>
|
||||
|
||||
{addons.length > 0 && (cards.start || cards.end.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
|
||||
<div className="min-w-0">{cards.start}</div>
|
||||
<div className="min-w-0">{cards.end}</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="m-0 rounded-2xl border border-dashed border-edge-faint px-4 py-8 text-center text-content-muted" style={fs(13, 'body')}>
|
||||
{t('admin.addons.noMatch')}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The head band of one type's column: icon tile, the type, its count and what it means. */
|
||||
function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
|
||||
label: string
|
||||
hint: string
|
||||
addons: Addon[]
|
||||
type AddonView = 'all' | 'on' | 'off'
|
||||
|
||||
/**
|
||||
* The head card's body: a tile per type with how many of it are on, then a
|
||||
* search over the addons' names and descriptions and a switch between all,
|
||||
* the ones on and the ones off.
|
||||
*/
|
||||
function AddonToolbar({ groups, query, onQuery, view, onView, t }: {
|
||||
groups: { key: string; addons: Addon[] }[]
|
||||
query: string
|
||||
onQuery: (q: string) => void
|
||||
view: AddonView
|
||||
onView: (v: AddonView) => void
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}) {
|
||||
const enabled = addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
|
||||
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill>{enabled}/{addons.length}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* Says what the type means: the only place that still explains it. */}
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
const Icon = meta.icon
|
||||
const on = g.addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div key={g.key} className="flex items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-secondary text-content-secondary">
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
|
||||
<span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
|
||||
</span>
|
||||
<span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<label className="relative flex min-w-0 flex-1 items-center">
|
||||
<Search size={14} className="pointer-events-none absolute start-3 text-content-faint" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => onQuery(e.target.value)}
|
||||
placeholder={t('admin.addons.search')}
|
||||
aria-label={t('admin.addons.search')}
|
||||
className="h-9 w-full rounded-[10px] border border-edge-faint bg-surface-card ps-8 pe-3 text-content outline-none transition-colors placeholder:text-content-faint focus:border-accent"
|
||||
style={fs(13, 'body')}
|
||||
/>
|
||||
</label>
|
||||
<div className="inline-flex flex-none gap-1 rounded-[10px] bg-surface-tertiary p-1" role="group" aria-label={t('admin.addons.filter')}>
|
||||
{(['all', 'on', 'off'] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => onView(v)}
|
||||
aria-pressed={view === v}
|
||||
className={`h-7 rounded-[8px] px-3 font-medium transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t(`admin.addons.filter.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function AddonSubRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
@@ -26,6 +27,8 @@ export default function AddonSubRow({
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
/** A small pill after the title, e.g. the beta mark on the assistant. */
|
||||
badge?: ReactNode
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
@@ -34,8 +37,11 @@ export default function AddonSubRow({
|
||||
{icon
|
||||
? <span className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}>{icon}</span>
|
||||
: <span className="w-6 flex-none" aria-hidden />}
|
||||
<span className={`min-w-0 flex-1 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
|
||||
{title}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className={`min-w-0 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
|
||||
{title}
|
||||
</span>
|
||||
{badge}
|
||||
</span>
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
|
||||
</li>
|
||||
|
||||
@@ -489,10 +489,10 @@ describe('AdminPluginsPanel — TREK-version compatibility', () => {
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
}
|
||||
|
||||
it('blocks Install when no published version runs on this TREK, and says why', async () => {
|
||||
it('marks a plugin no published version runs on, and still lets the admin install it', async () => {
|
||||
await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
|
||||
const btn = await screen.findByRole('button', { name: /^incompatible$/i })
|
||||
expect(btn).toBeDisabled()
|
||||
expect(await screen.findByText('Not confirmed for TREK 3.3.0 yet')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^install$/i })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('offers the newest version that DOES run here rather than a dead button', async () => {
|
||||
@@ -549,9 +549,11 @@ function panelRoot(container: HTMLElement): HTMLElement {
|
||||
}
|
||||
|
||||
/** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */
|
||||
async function pickFilter(trigger: RegExp, option: RegExp) {
|
||||
fireEvent.click(await screen.findByTitle(trigger))
|
||||
fireEvent.click(await screen.findByRole('button', { name: option }))
|
||||
/** The toolbar's one Filter button; a pick in its menu leaves the menu open. */
|
||||
const filterButton = (): HTMLElement => screen.getByRole('button', { name: /^Filter/ })
|
||||
async function pickFilter(option: RegExp) {
|
||||
if ((await screen.findByRole('button', { name: /^Filter/ })).getAttribute('aria-expanded') !== 'true') fireEvent.click(filterButton())
|
||||
fireEvent.click(await screen.findByRole('menuitemradio', { name: option }))
|
||||
}
|
||||
|
||||
/** The enable toggle of a named installed row. */
|
||||
@@ -610,9 +612,9 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
await pickFilter(/^Type: All types$/, /^Widget$/)
|
||||
await pickFilter(/^Widget$/)
|
||||
|
||||
expect(await screen.findByTitle('Type: Widget')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('menuitemradio', { name: 'Widget' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByText('Countdown')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Gotify')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -626,15 +628,15 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('OnOne')
|
||||
|
||||
await pickFilter(/^Status: All$/, /^Off$/)
|
||||
await pickFilter(/^Off$/)
|
||||
expect(await screen.findByText('OffOne')).toBeInTheDocument()
|
||||
expect(screen.queryByText('OnOne')).not.toBeInTheDocument()
|
||||
|
||||
await pickFilter(/^Status: Off$/, /^Error$/)
|
||||
await pickFilter(/^Error$/)
|
||||
expect(await screen.findByText('BadOne')).toBeInTheDocument()
|
||||
expect(screen.getByText('boom')).toBeInTheDocument()
|
||||
|
||||
await pickFilter(/^Status: Error$/, /^Update available$/)
|
||||
await pickFilter(/^Update available$/)
|
||||
expect(await screen.findByText('OnOne')).toBeInTheDocument()
|
||||
expect(screen.queryByText('BadOne')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -644,11 +646,10 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
await pickFilter(/^Sort: Name$/, /^Updates first$/)
|
||||
expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument()
|
||||
|
||||
await pickFilter(/^Updates first$/)
|
||||
expect(filterButton()).toHaveTextContent('Updates first')
|
||||
await clickDiscover()
|
||||
await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument())
|
||||
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-008: rescan re-pulls the registry past its cache and confirms with a toast', async () => {
|
||||
@@ -1663,15 +1664,16 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — security footer', () => {
|
||||
it('FE-COMP-PLUGINS-PANEL-048: the containment note expands into its sections', async () => {
|
||||
describe('AdminPluginsPanel — security dialog', () => {
|
||||
it('FE-COMP-PLUGINS-PANEL-048: the shield in the header opens how plugins are contained', async () => {
|
||||
panelWith([])
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('No plugins installed yet.')
|
||||
|
||||
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('How plugins are contained — and the limits'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'How plugins are secured' }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'How plugins are secured' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument()
|
||||
expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument()
|
||||
expect(screen.getByText('What "Signed" means')).toBeInTheDocument()
|
||||
@@ -1700,9 +1702,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
row({ id: 'c-page', name: 'Gamma Page', description: 'a page', type: 'page', enabled: 1, status: 'error', last_error: 'boom' }),
|
||||
]
|
||||
|
||||
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, filter: RegExp, option: RegExp) => {
|
||||
await user.click(screen.getByTitle(filter))
|
||||
await user.click(screen.getByRole('button', { name: option }))
|
||||
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, option: RegExp) => {
|
||||
if (filterButton().getAttribute('aria-expanded') !== 'true') await user.click(filterButton())
|
||||
await user.click(screen.getByRole('menuitemradio', { name: option }))
|
||||
}
|
||||
|
||||
it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => {
|
||||
@@ -1734,7 +1736,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await pickFilter(user, /^Type:/, /^Widget$/)
|
||||
await pickFilter(user, /^Widget$/)
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Alpha Widget')).toBeInTheDocument()
|
||||
@@ -1746,15 +1748,15 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await pickFilter(user, /^Status:/, /^Active$/)
|
||||
await pickFilter(user, /^Active$/)
|
||||
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
|
||||
expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument()
|
||||
|
||||
await pickFilter(user, /^Status:/, /^Off$/)
|
||||
await pickFilter(user, /^Off$/)
|
||||
await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
|
||||
|
||||
await pickFilter(user, /^Status:/, /^Error$/)
|
||||
await pickFilter(user, /^Error$/)
|
||||
await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -1766,13 +1768,13 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
await screen.findByText('Alpha Widget')
|
||||
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Updates first$/)
|
||||
await pickFilter(user, /^Updates first$/)
|
||||
await waitFor(() => {
|
||||
const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent)
|
||||
expect(names[0]).toBe('Gamma Page')
|
||||
})
|
||||
|
||||
await pickFilter(user, /^Status:/, /^Update available$/)
|
||||
await pickFilter(user, /^Update available$/)
|
||||
await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Gamma Page')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1783,11 +1785,10 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Updates first$/)
|
||||
expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument()
|
||||
|
||||
await pickFilter(user, /^Updates first$/)
|
||||
expect(filterButton()).toHaveTextContent('Updates first')
|
||||
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
||||
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
|
||||
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
|
||||
})
|
||||
|
||||
it('FE-W5PLG-007: the registry sorts by downloads and by review date', async () => {
|
||||
@@ -1802,19 +1803,19 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
||||
await screen.findByText('Old Tool')
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Most downloads$/)
|
||||
await pickFilter(user, /^Most downloads$/)
|
||||
await waitFor(() => {
|
||||
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
||||
expect(names[0]).toBe('New Tool')
|
||||
})
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Recently updated$/)
|
||||
await pickFilter(user, /^Recently updated$/)
|
||||
await waitFor(() => {
|
||||
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
||||
expect(names[0]).toBe('New Tool')
|
||||
})
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Name$/)
|
||||
await pickFilter(user, /^Name$/)
|
||||
await waitFor(() => {
|
||||
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
||||
expect(names[0]).toBe('New Tool')
|
||||
@@ -1833,7 +1834,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
||||
await screen.findByText('Mapper')
|
||||
|
||||
await pickFilter(user, /^Type:/, /^Page$/)
|
||||
await pickFilter(user, /^Page$/)
|
||||
await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Poster')).toBeInTheDocument()
|
||||
|
||||
@@ -1962,12 +1963,12 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
const { container } = render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await user.click(screen.getByTitle(/^Type:/))
|
||||
expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument()
|
||||
await user.click(filterButton())
|
||||
expect(screen.getByRole('menuitemradio', { name: /^Widget$/ })).toBeInTheDocument()
|
||||
|
||||
const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement
|
||||
fireEvent.click(layer)
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: /^Widget$/ })).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.queryByRole('menuitemradio', { name: /^Widget$/ })).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2004,12 +2005,11 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await openDiscover(user)
|
||||
await user.click(screen.getByTitle(/^Sort:/))
|
||||
await user.click(screen.getByRole('button', { name: /^Most downloads$/ }))
|
||||
expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument()
|
||||
|
||||
await user.click(filterButton())
|
||||
await user.click(screen.getByRole('menuitemradio', { name: /^Most downloads$/ }))
|
||||
expect(filterButton()).toHaveTextContent('Most downloads')
|
||||
await user.click(screen.getByRole('tab', { name: /Installed/ }))
|
||||
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
|
||||
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
|
||||
})
|
||||
|
||||
it('FE-W5PLG-017: an outrun release offers the newest compatible version instead of a dead button', async () => {
|
||||
@@ -2026,9 +2026,8 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
|
||||
expect(offer).not.toBeDisabled()
|
||||
expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0'))
|
||||
|
||||
const dead = screen.getByRole('button', { name: /^Incompatible$/ })
|
||||
expect(dead).toBeDisabled()
|
||||
expect(dead.getAttribute('title')).toBeTruthy()
|
||||
// Nothing fits: no dead button any more, but a card that says so before anything installs.
|
||||
expect(screen.getByText(/^Not confirmed for TREK .+ yet$/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => {
|
||||
@@ -2556,7 +2555,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
|
||||
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
|
||||
it('FE-PLG-BYPASS-002: offers Install for an incompatible entry and asks before installing', async () => {
|
||||
let posted: unknown = null
|
||||
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
|
||||
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||
@@ -2566,7 +2565,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
|
||||
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
|
||||
const btn = await screen.findByRole('button', { name: /^install$/i })
|
||||
expect(btn).toBeEnabled()
|
||||
fireEvent.click(btn)
|
||||
|
||||
@@ -2585,7 +2584,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
|
||||
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install$/i }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import {
|
||||
Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database,
|
||||
Globe, Info, History, PauseCircle, Puzzle, Bot, Loader2,
|
||||
ArrowUpCircle, Github, ExternalLink, Check, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, CircleDot, Database,
|
||||
Globe, History, Puzzle, Bot, Loader2,
|
||||
} from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { useInstanceSettings } from './useInstanceSettings'
|
||||
@@ -23,15 +22,17 @@ import {
|
||||
SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill,
|
||||
SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON,
|
||||
} from '../Settings/settingsKit'
|
||||
import { POPOVER } from '../Packing/packingPopoverStyles'
|
||||
import {
|
||||
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, deriveDeps, fingerprint, formatCompactCount,
|
||||
installOffer, isRegistrySourced, parseJson, signatureBlockView, sortLabel, statusLabel,
|
||||
type PluginDep, type PluginRow, type RegistryItem, type SigSubject, type SortKey,
|
||||
type StatusFilter, type T, type TypeFilter, type VersionInfo, type VersionMismatch,
|
||||
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, fingerprint, formatCompactCount,
|
||||
installOffer, signatureBlockView,
|
||||
type PluginDep, type PluginRow, type RegistryItem, type SigSubject,
|
||||
type T, type VersionInfo, type VersionMismatch,
|
||||
} from './plugins/pluginModel'
|
||||
import { usePluginDetail } from './plugins/usePluginDetail'
|
||||
import { usePluginsAdmin } from './plugins/usePluginsAdmin'
|
||||
import PluginFilterMenu from './plugins/PluginFilterMenu'
|
||||
import PluginInstalledRow from './plugins/PluginInstalledRow'
|
||||
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
|
||||
|
||||
/**
|
||||
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
|
||||
@@ -42,31 +43,13 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
|
||||
* tile; the security section at the bottom explains the model honestly.
|
||||
*/
|
||||
|
||||
// Runtime health → dot colour on the icon tile.
|
||||
const HEALTH: Record<string, string> = {
|
||||
active: 'bg-success',
|
||||
starting: 'bg-info animate-pulse',
|
||||
error: 'bg-danger',
|
||||
inactive: 'bg-content-faint/60',
|
||||
disabled: 'bg-warning',
|
||||
incompatible: 'bg-warning',
|
||||
}
|
||||
|
||||
/** The head band of a dialog that warns before something happens: a faint wash of the warning tone. */
|
||||
const WARN_TINT = 'color-mix(in srgb, var(--warning) 10%, transparent)'
|
||||
/** The eyebrow over a block, the size the dialogs' sections use. */
|
||||
const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
/** A capability or dependency chip on an installed row. */
|
||||
const CHIP = 'inline-flex max-w-full items-center gap-1.5 rounded-full border px-2 py-[2px] font-medium'
|
||||
const CHIP_NEUTRAL = 'border-edge-faint bg-surface-secondary text-content-secondary'
|
||||
const CHIP_NET = 'border-transparent bg-info-soft text-info'
|
||||
const CHIP_WARN = 'border-transparent bg-warning-soft text-warning'
|
||||
/** A small action inside a row or a card: the kit's button, a size down. */
|
||||
const SMALL_BUTTON = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
|
||||
const SMALL_PRIMARY = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 py-1.5 font-semibold text-accent-text hover:opacity-90 disabled:cursor-default disabled:bg-surface-tertiary disabled:text-content-faint'
|
||||
/** A line of state under a row's name: a refused update, a paused one, an error. */
|
||||
const ROW_NOTE = 'mt-1.5 flex min-w-0 items-center gap-1.5'
|
||||
|
||||
/** A pill that explains itself on hover. */
|
||||
function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
|
||||
return (
|
||||
@@ -76,19 +59,6 @@ function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewedBadge({ t }: { t: T }) {
|
||||
return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
|
||||
}
|
||||
|
||||
/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
|
||||
function SideloadedBadge({ t }: { t: T }) {
|
||||
return (
|
||||
<HintPill hint={t('admin.plugins.sideloadedHint')}>
|
||||
<StatusPill tone="warning" icon={<UploadCloud size={11} />}>{t('admin.plugins.sideloaded')}</StatusPill>
|
||||
</HintPill>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed / Unsigned, for registry plugins only (see isRegistrySourced).
|
||||
*
|
||||
@@ -113,15 +83,6 @@ function TrustBadge({ signed, t }: { signed: boolean; t: T }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */
|
||||
function DevLinkBadge({ t }: { t: T }) {
|
||||
return (
|
||||
<HintPill hint={t('admin.plugins.devLinkHint')}>
|
||||
<StatusPill icon={<Link2 size={11} />}>{t('admin.plugins.devLinkBadge')}</StatusPill>
|
||||
</HintPill>
|
||||
)
|
||||
}
|
||||
|
||||
function TypeBadge({ type, t }: { type: string; t: T }) {
|
||||
return (
|
||||
<span className="inline-flex flex-none items-center rounded-full bg-surface-tertiary px-2 py-[3px] font-geist font-bold uppercase tracking-[.06em] text-content-muted" style={fs(9.5)}>
|
||||
@@ -145,6 +106,7 @@ export default function AdminPluginsPanel() {
|
||||
sort, statusFilter, toggle, typeFilter, uninstallConfirmed, updatable, updateAll, updateAvailable, uploadInputRef,
|
||||
uploadPlugin, versionPicker, view,
|
||||
} = usePluginsAdmin()
|
||||
const [securityOpen, setSecurityOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative mb-24 sm:mb-0"
|
||||
@@ -181,6 +143,9 @@ export default function AdminPluginsPanel() {
|
||||
) : undefined}
|
||||
action={ready ? (
|
||||
<>
|
||||
<BandButton label={t('admin.plugins.security.short')} onClick={() => setSecurityOpen(true)}>
|
||||
<ShieldCheck size={15} strokeWidth={2} />
|
||||
</BandButton>
|
||||
<BandButton label={t('admin.plugins.upload')} onClick={pickUpload} disabled={busy === '__upload'}>
|
||||
<UploadCloud size={15} strokeWidth={2} />
|
||||
</BandButton>
|
||||
@@ -242,31 +207,9 @@ export default function AdminPluginsPanel() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<FilterMenu id="type" label={t('admin.plugins.filterType')} value={typeFilter} menu={menu} setMenu={setMenu} icon={<SlidersHorizontal size={14} />}
|
||||
options={[
|
||||
['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')],
|
||||
['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')],
|
||||
['trip-page', t('admin.plugins.type.trip-page')],
|
||||
]}
|
||||
valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)}
|
||||
onPick={v => setTypeFilter(v as TypeFilter)} />
|
||||
|
||||
{view === 'installed' && (
|
||||
<FilterMenu id="status" label={t('admin.plugins.filterStatus')} value={statusFilter} menu={menu} setMenu={setMenu} icon={<CircleDot size={14} />}
|
||||
options={[
|
||||
['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')],
|
||||
['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')],
|
||||
]}
|
||||
valueLabel={statusLabel(statusFilter, t)}
|
||||
onPick={v => setStatusFilter(v as StatusFilter)} />
|
||||
)}
|
||||
|
||||
<FilterMenu id="sort" label={t('admin.plugins.sortBy')} value={sort} menu={menu} setMenu={setMenu} icon={<ArrowUpDown size={14} />}
|
||||
options={view === 'discover'
|
||||
? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]]
|
||||
: [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]}
|
||||
valueLabel={sortLabel(sort, t)}
|
||||
onPick={v => setSort(v as SortKey)} />
|
||||
<PluginFilterMenu t={t} view={view} typeFilter={typeFilter} setTypeFilter={setTypeFilter}
|
||||
statusFilter={statusFilter} setStatusFilter={setStatusFilter} sort={sort} setSort={setSort}
|
||||
menu={menu} setMenu={setMenu} />
|
||||
</div>
|
||||
|
||||
{view === 'discover' ? (
|
||||
@@ -299,7 +242,7 @@ export default function AdminPluginsPanel() {
|
||||
) : (
|
||||
<SettingRows>
|
||||
{shownInstalled.map(p => (
|
||||
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||
<PluginInstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
|
||||
newerIncompatible={newerIncompatible(p)}
|
||||
blocked={blockIsCurrent(p, latest[p.id])}
|
||||
@@ -320,7 +263,7 @@ export default function AdminPluginsPanel() {
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<SecurityInfo t={t} />
|
||||
{securityOpen && <PluginSecurityDialog t={t} onClose={() => setSecurityOpen(false)} />}
|
||||
|
||||
{/* Registry detail dialog */}
|
||||
{detailFor && (
|
||||
@@ -438,268 +381,6 @@ function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: (
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
|
||||
function useFollowTrigger(open: boolean) {
|
||||
const [, reanchor] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onMove = () => reanchor(n => n + 1)
|
||||
window.addEventListener('scroll', onMove, true)
|
||||
window.addEventListener('resize', onMove)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onMove, true)
|
||||
window.removeEventListener('resize', onMove)
|
||||
}
|
||||
}, [open])
|
||||
}
|
||||
|
||||
function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: {
|
||||
id: string; label: string; valueLabel: string
|
||||
options: Array<[string, string]>; onPick: (v: string) => void; value: string
|
||||
menu: string | null; setMenu: (v: string | null) => void; icon?: ReactNode
|
||||
}) {
|
||||
const open = menu === id
|
||||
const active = value !== options[0]?.[0]
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
// Portaled like the row menu: the card around the toolbar clips what overflows it.
|
||||
useFollowTrigger(open)
|
||||
return (
|
||||
<div className="relative flex-none">
|
||||
<button type="button" ref={btnRef} onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`} aria-expanded={open}
|
||||
className={`inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
|
||||
active ? 'border-[color:var(--text-primary)]' : 'border-edge'}`}
|
||||
style={fs(12.5, 'body')}>
|
||||
<span className="flex-none text-content-faint">{icon}</span>
|
||||
<span>{label}: </span>
|
||||
<span className="font-semibold text-content">{valueLabel}</span>
|
||||
<ChevronDown size={13} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div className="min-w-[200px] max-w-[calc(100vw-2rem)]" style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}>
|
||||
{options.map(([v, lbl]) => (
|
||||
<button type="button" key={v} onClick={() => { onPick(v); setMenu(null) }}
|
||||
className={`${MENU_ITEM} ${value === v ? 'font-semibold text-content' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
|
||||
<span className="min-w-0 flex-1 truncate text-start">{lbl}</span>
|
||||
<Check size={14} className={`flex-none text-content-muted ${value === v ? 'opacity-100' : 'opacity-0'}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: {
|
||||
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
|
||||
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
|
||||
onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void
|
||||
}) {
|
||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
||||
const deps = deriveDeps(p, t)
|
||||
const menuOpen = menu === `row:${p.id}`
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const working = busy === p.id
|
||||
const on = p.enabled === 1 && p.status !== 'error'
|
||||
|
||||
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
|
||||
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
|
||||
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
|
||||
// Re-anchor while the page moves under it.
|
||||
useFollowTrigger(menuOpen)
|
||||
|
||||
const hostCount = p.egressHostCount ?? 0
|
||||
|
||||
return (
|
||||
<div className={`group relative flex items-start gap-3 px-3.5 py-3 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}>
|
||||
<div className="relative mt-0.5 flex-none">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
|
||||
</span>
|
||||
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
|
||||
<span className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>{p.name}</span>
|
||||
{p.version && <span className="font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>v{p.version}</span>}
|
||||
{p.reviewed_at && <ReviewedBadge t={t} />}
|
||||
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
|
||||
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
|
||||
{/* Registry plugins only — a sideloaded/dev-linked plugin already says something
|
||||
strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */}
|
||||
{isRegistrySourced(p.source_repo) && <TrustBadge signed={!!p.signed} t={t} />}
|
||||
</div>
|
||||
{p.description && <p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>{p.description}</p>}
|
||||
{/* A refused update leaves a WORKING plugin pinned at its old version — so this is
|
||||
its own row state, not an error state, and it persists until it is resolved
|
||||
rather than dying with the toast that first reported it. */}
|
||||
{blocked && p.updateBlock && (
|
||||
<div className={`${ROW_NOTE} text-warning`} style={fs(11.5)}>
|
||||
<ShieldAlert size={13} className="flex-none" />
|
||||
<span className="truncate">{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}</span>
|
||||
<button type="button" onClick={onReviewBlock}
|
||||
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80">
|
||||
{t('admin.plugins.reviewBlock')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* A newer version this TREK can't run — informational, never a button: the fix
|
||||
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
|
||||
{newerIncompatible && (
|
||||
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
|
||||
<Info size={13} className="flex-none" />
|
||||
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Held: the admin deliberately installed a non-latest version, so updates are
|
||||
paused rather than nagged — resuming is one click, right where the pause shows. */}
|
||||
{p.updateHold && (
|
||||
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
|
||||
<PauseCircle size={13} className="flex-none" />
|
||||
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
|
||||
<button type="button" onClick={onResume} disabled={working}
|
||||
className="flex-none font-semibold text-content-secondary underline underline-offset-2 hover:opacity-80 disabled:opacity-50">
|
||||
{t('admin.plugins.resumeUpdates')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{p.status === 'error' && p.last_error ? (
|
||||
<div className={`${ROW_NOTE} text-danger`} style={fs(11.5)}>
|
||||
<AlertTriangle size={13} className="flex-none" /><span className="truncate">{p.last_error}</span>
|
||||
</div>
|
||||
) : (caps.length > 0 || p.operatorEgress) && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{caps.map((c, i) => (
|
||||
<span key={i} className={`${CHIP} ${c.net ? CHIP_NET : CHIP_NEUTRAL}`} style={fs(11)}>
|
||||
<c.icon size={12} className={c.net ? 'text-info' : 'text-content-muted'} /><span className="truncate">{c.label}</span>
|
||||
</span>
|
||||
))}
|
||||
{/* This plugin talks to a service only the OPERATOR can name (a self-hosted
|
||||
Gotify/ntfy), so its manifest can't list the host — the admin adds it.
|
||||
Actionable, and warning-toned until at least one host exists, because
|
||||
until then the plugin cannot reach anything and looks silently broken. */}
|
||||
{p.operatorEgress && (
|
||||
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
|
||||
<button type="button" onClick={onEgress}
|
||||
className={`${CHIP} ${hostCount > 0 ? CHIP_NET : CHIP_WARN} hover:opacity-80`} style={fs(11)}>
|
||||
<Globe size={12} />
|
||||
{hostCount > 0
|
||||
? t('admin.plugins.allowedHosts.count', { n: hostCount })
|
||||
: t('admin.plugins.allowedHosts.add')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{deps.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{deps.map((d, i) => (
|
||||
<span key={i} className={`${CHIP} ${d.blocked || d.warn ? CHIP_WARN : CHIP_NEUTRAL}`} style={fs(11)}>
|
||||
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} /><span className="truncate">{d.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none items-center gap-2 self-center">
|
||||
{hasUpdate && (
|
||||
<button type="button" onClick={onUpdate} disabled={working}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
|
||||
style={fs(11.5)}>
|
||||
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
|
||||
</button>
|
||||
)}
|
||||
<span className={`min-w-[42px] text-end font-medium ${on ? 'text-content-secondary' : 'text-content-faint'}`} style={fs(12)}>
|
||||
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
|
||||
</span>
|
||||
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
|
||||
<div className="relative">
|
||||
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
|
||||
<button type="button" ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
aria-expanded={menuOpen} aria-label={t('admin.table.actions')}
|
||||
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}>
|
||||
<MoreHorizontal size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{menuOpen && createPortal(
|
||||
<div data-testid={`plugin-row-menu-${p.id}`} style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
|
||||
className="min-w-[200px]">
|
||||
{p.enabled === 1 && (
|
||||
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
|
||||
)}
|
||||
{/* A plugin gets the item if it declares scope:'instance' fields OR actions —
|
||||
an action-only plugin still needs the dialog to run its buttons. */}
|
||||
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
|
||||
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
{/* Only a plugin that DECLARED operatorEgress gets the item — an admin must
|
||||
never be invited to widen egress for a plugin that didn't ask for it
|
||||
(same rule the row's egress chip follows). */}
|
||||
{p.operatorEgress && (
|
||||
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
|
||||
)}
|
||||
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
|
||||
{isRegistrySourced(p.source_repo) && (
|
||||
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
|
||||
)}
|
||||
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
|
||||
<>
|
||||
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
||||
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
|
||||
<span className="flex w-4 flex-none justify-center text-content-muted"><Github size={14} /></span> {t('admin.plugins.sourceRepo')}
|
||||
</a>
|
||||
<a href={`https://github.com/${p.source_repo}/issues`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
||||
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
|
||||
<span className="flex w-4 flex-none justify-center text-content-muted"><CircleDot size={14} /></span> {t('admin.plugins.reportIssue')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
|
||||
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={onUninstall} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tallest the ⋯ menu gets (6 items + divider + padding). Drives the flip. */
|
||||
const ROW_MENU_MAX_H = 260
|
||||
|
||||
/** Pin a portaled menu to the right edge of its trigger, flipping up when the bottom is tight. */
|
||||
function anchorMenu(trigger: HTMLElement | null): CSSProperties {
|
||||
const r = trigger?.getBoundingClientRect()
|
||||
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 }
|
||||
const spaceBelow = window.innerHeight - r.bottom
|
||||
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow
|
||||
return {
|
||||
position: 'fixed',
|
||||
right: Math.max(8, window.innerWidth - r.right),
|
||||
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
|
||||
zIndex: 100,
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry of a popover menu, in the packing popovers' shape. */
|
||||
const MENU_ITEM = 'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary'
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }: { icon: ReactNode; label: string; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
|
||||
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>{icon}</span> {label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
return (
|
||||
@@ -751,6 +432,16 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<StatusPill icon={<ShieldCheck size={11} className="text-success" />}>{t('admin.plugins.reviewed')}</StatusPill>
|
||||
</span>
|
||||
)}
|
||||
{/* Says up front that this one has not been confirmed for the TREK it would run on,
|
||||
so "Install" never reads as a plain install; the dialog explains the risk. */}
|
||||
{!installed && offer.warn && (
|
||||
// Solid, not the soft pill "Reviewed" uses: it has to stand out on any screenshot.
|
||||
<span className="absolute start-2.5 top-2.5 inline-flex max-w-[62%] items-center gap-1 rounded-full bg-warning px-2 py-[3px] font-geist font-semibold text-[color:var(--surface-card)] shadow-md"
|
||||
style={fs(11)}>
|
||||
<AlertTriangle size={11} className="flex-none" />
|
||||
<span className="truncate">{t('admin.plugins.untestedChip', { host: offer.warn.hostVersion })}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute -bottom-4 start-3.5 z-[1] grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-card shadow-sm">
|
||||
<PluginIcon name={item.icon} size={20} className="text-content-secondary" />
|
||||
</span>
|
||||
@@ -829,8 +520,8 @@ function Tile({ children, warn }: { children: ReactNode; warn?: boolean }) {
|
||||
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
|
||||
* component rather than ConfirmDialog because the notice mode has no cancel button.
|
||||
*/
|
||||
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
|
||||
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
|
||||
function RangeBypassDialog({ copy: { title, body, confirm, confirmLabel }, onConfirm, onClose, t }: {
|
||||
copy: { title: string; body: string; confirm: boolean; confirmLabel: string }; onConfirm: () => void; onClose: () => void; t: T
|
||||
}) {
|
||||
return (
|
||||
<DialogShell
|
||||
@@ -844,7 +535,7 @@ function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose,
|
||||
{confirm ? (
|
||||
<>
|
||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={onConfirm}>{t('admin.plugins.installAnyway')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={onConfirm}>{confirmLabel}</DialogButton>
|
||||
</>
|
||||
) : (
|
||||
<DialogButton variant="primary" onClick={onClose}>{t('common.ok')}</DialogButton>
|
||||
@@ -1495,45 +1186,3 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
// Footer: a plain-language note on what "Reviewed" means, plus a collapsible
|
||||
// panel that lays out how plugins are contained, the limits, and the worst case.
|
||||
function SecurityInfo({ t }: { t: T }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const sections: Array<[string, string]> = [
|
||||
['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'],
|
||||
['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'],
|
||||
['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'],
|
||||
['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'],
|
||||
['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'],
|
||||
['admin.plugins.security.signedTitle', 'admin.plugins.security.signedBody'],
|
||||
['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'],
|
||||
]
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Lock}
|
||||
title={(
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
|
||||
className="inline-flex max-w-full items-center gap-1.5 text-start font-bold text-content hover:opacity-80">
|
||||
<span className="truncate">{t('admin.plugins.security.title')}</span>
|
||||
<ChevronDown size={15} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheck size={14} className="mt-0.5 flex-none text-content-faint" />
|
||||
<SettingsHint>{t('admin.plugins.reviewedMeaning')}</SettingsHint>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||
{sections.map(([h, b]) => (
|
||||
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
||||
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>{t(h as never)}</h4>
|
||||
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>{t(b as never)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -102,6 +102,13 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h5') {
|
||||
return (
|
||||
<h5 key={idx} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(12.5, 'body')}>
|
||||
{block.text}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h4') {
|
||||
return (
|
||||
<h4 key={idx} className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={fs(10)}>
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function AiAddonTile({
|
||||
icon={<Icon size={14} />}
|
||||
title={label.name}
|
||||
description={label.description}
|
||||
badge={addon.id === 'ai_assistant' ? <BetaBadge label={t('assistant.setup.beta')} /> : undefined}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => onToggle(addon)}
|
||||
/>
|
||||
@@ -93,3 +94,16 @@ export default function AiAddonTile({
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** The assistant is still young; its switch says so with a small accent pill. */
|
||||
function BetaBadge({ label }: { label: string }): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full bg-accent px-[7px] py-[2px] font-geist font-bold uppercase leading-none tracking-[.08em] text-accent-text shadow-sm"
|
||||
style={fs(9)}
|
||||
>
|
||||
<Sparkles size={9} strokeWidth={2.6} aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { openrouterReturnCode } from '../../Settings/ai/aiConnectModel';
|
||||
import type { Addon } from './addonModel';
|
||||
import { usesConnection } from './aiAddons';
|
||||
|
||||
@@ -11,7 +12,7 @@ import { usesConnection } from './aiAddons';
|
||||
*/
|
||||
export function useAiAddonTile(aiAddons: Addon[]) {
|
||||
const [params] = useSearchParams();
|
||||
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
|
||||
const [open, setOpen] = useState(() => openrouterReturnCode(params) !== undefined);
|
||||
const canSetUp = usesConnection(aiAddons);
|
||||
return {
|
||||
enabledCount: aiAddons.filter((a) => a.enabled).length,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone.
|
||||
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-008: the AI assistant's settings and connection for everyone.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
|
||||
import { MASKED_SETTING_VALUE } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
@@ -14,6 +16,7 @@ const repo = vi.hoisted(() => ({
|
||||
saveAdminSettings: vi.fn(),
|
||||
adminConnection: vi.fn(),
|
||||
saveAdminConnection: vi.fn(),
|
||||
setAdminModel: vi.fn(),
|
||||
removeAdminConnection: vi.fn(),
|
||||
adminModels: vi.fn(),
|
||||
}));
|
||||
@@ -59,6 +62,8 @@ async function renderLoaded() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
// The settings show while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.status.mockRejectedValue(new Error('not needed'));
|
||||
repo.adminSettings.mockResolvedValue(SETTINGS);
|
||||
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
||||
@@ -153,6 +158,29 @@ describe('useAiAdminConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("FE-HOOK-AI-ADMIN-008: a shared ChatGPT plan's model is switched on its own, and the answer shows (#2783)", async () => {
|
||||
const plan = (model: string): AiAdminConnectionResponse => ({
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'chatgpt',
|
||||
model,
|
||||
hint: null,
|
||||
status: 'ok',
|
||||
host: 'api.openai.com',
|
||||
baseUrl: null,
|
||||
},
|
||||
prefill: null,
|
||||
});
|
||||
repo.adminConnection.mockResolvedValue(plan('gpt-6.1-sol'));
|
||||
repo.setAdminModel.mockResolvedValue(plan('gpt-6.1-sol-mini'));
|
||||
const { result } = await renderLoaded();
|
||||
await act(async () => result.current.instance.setModel('gpt-6.1-sol-mini'));
|
||||
await waitFor(() => expect(repo.setAdminModel).toHaveBeenCalledWith('gpt-6.1-sol-mini'));
|
||||
expect(repo.saveAdminConnection).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(result.current.instance.live?.model).toBe('gpt-6.1-sol-mini'));
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => {
|
||||
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE);
|
||||
const { result } = await renderLoaded();
|
||||
|
||||
@@ -124,9 +124,16 @@ function useInstanceConnection(onChanged: () => void) {
|
||||
}
|
||||
};
|
||||
|
||||
/** Another model on the same connection: the stored key is kept by sending the mask in its place. */
|
||||
/**
|
||||
* Another model on the same connection: the stored key is kept by sending the mask in its place.
|
||||
* A shared ChatGPT plan has a sign-in, not a key, so its model is switched on its own (#2783).
|
||||
*/
|
||||
const setModel = (model: string) => {
|
||||
if (!live || live.kind === 'chatgpt') return;
|
||||
if (!live) return;
|
||||
if (live.kind === 'chatgpt') {
|
||||
void act(() => aiConnectionsRepo.setAdminModel(model), 'common.saved');
|
||||
return;
|
||||
}
|
||||
const request: AiInstanceConnectionRequest =
|
||||
live.kind === 'custom'
|
||||
? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) }
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Check, ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { POPOVER } from '../../Packing/packingPopoverStyles';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
|
||||
import { sortLabel, type SortKey, type StatusFilter, type T, type TypeFilter } from './pluginModel';
|
||||
|
||||
const MENU_ID = 'filters';
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
value: string;
|
||||
options: Array<[string, string]>;
|
||||
onPick: (v: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type, status and order of the plugin list behind one button instead of three, so the
|
||||
* toolbar stays search plus one control. The button counts the filters that narrow the
|
||||
* list (the order does not narrow it) and names the order it is in.
|
||||
*/
|
||||
export default function PluginFilterMenu({
|
||||
t,
|
||||
view,
|
||||
typeFilter,
|
||||
setTypeFilter,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
sort,
|
||||
setSort,
|
||||
menu,
|
||||
setMenu,
|
||||
}: {
|
||||
t: T;
|
||||
view: 'installed' | 'discover';
|
||||
typeFilter: TypeFilter;
|
||||
setTypeFilter: (v: TypeFilter) => void;
|
||||
statusFilter: StatusFilter;
|
||||
setStatusFilter: (v: StatusFilter) => void;
|
||||
sort: SortKey;
|
||||
setSort: (v: SortKey) => void;
|
||||
menu: string | null;
|
||||
setMenu: (v: string | null) => void;
|
||||
}) {
|
||||
const open = menu === MENU_ID;
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
useFollowTrigger(open);
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: t('admin.plugins.filterType'),
|
||||
value: typeFilter,
|
||||
options: [
|
||||
['all', t('admin.plugins.allTypes')],
|
||||
['widget', t('admin.plugins.type.widget')],
|
||||
['integration', t('admin.plugins.type.integration')],
|
||||
['page', t('admin.plugins.type.page')],
|
||||
['trip-page', t('admin.plugins.type.trip-page')],
|
||||
],
|
||||
onPick: (v) => setTypeFilter(v as TypeFilter),
|
||||
},
|
||||
...(view === 'installed'
|
||||
? [
|
||||
{
|
||||
title: t('admin.plugins.filterStatus'),
|
||||
value: statusFilter,
|
||||
options: [
|
||||
['all', t('admin.plugins.allStatuses')],
|
||||
['on', t('admin.plugins.status.active')],
|
||||
['off', t('admin.plugins.stateOff')],
|
||||
['update', t('admin.plugins.filterUpdate')],
|
||||
['err', t('admin.plugins.status.error')],
|
||||
] as Array<[string, string]>,
|
||||
onPick: (v: string) => setStatusFilter(v as StatusFilter),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t('admin.plugins.sortBy'),
|
||||
value: sort,
|
||||
options:
|
||||
view === 'discover'
|
||||
? [
|
||||
['name', t('admin.plugins.sortName')],
|
||||
['recent', t('admin.plugins.sortRecent')],
|
||||
['downloads', t('admin.plugins.sortDownloads')],
|
||||
]
|
||||
: [
|
||||
['name', t('admin.plugins.sortName')],
|
||||
['recent', t('admin.plugins.sortRecent')],
|
||||
['updates', t('admin.plugins.sortUpdates')],
|
||||
],
|
||||
onPick: (v) => setSort(v as SortKey),
|
||||
},
|
||||
];
|
||||
const narrowing = (typeFilter !== 'all' ? 1 : 0) + (view === 'installed' && statusFilter !== 'all' ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="relative flex-none">
|
||||
<button
|
||||
type="button"
|
||||
ref={btnRef}
|
||||
onClick={() => setMenu(open ? null : MENU_ID)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={`inline-flex h-9 items-center gap-2 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
|
||||
narrowing ? 'border-[color:var(--text-primary)]' : 'border-edge'
|
||||
}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<SlidersHorizontal size={14} className="flex-none text-content-faint" />
|
||||
<span className="font-semibold text-content">{t('admin.plugins.filters')}</span>
|
||||
{narrowing > 0 && (
|
||||
<span
|
||||
className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-accent px-1.5 font-geist font-bold tabular-nums text-accent-text"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{narrowing}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-content-faint">{sortLabel(sort, t)}</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
className="w-[240px] max-w-[calc(100vw-2rem)]"
|
||||
style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}
|
||||
>
|
||||
{sections.map((s, i) => (
|
||||
<div key={s.title} className={i > 0 ? 'mt-1.5 border-t border-edge-faint pt-1.5' : undefined}>
|
||||
<p
|
||||
className="m-0 px-2.5 pb-1 pt-1 font-geist font-bold uppercase tracking-[.08em] text-content-faint"
|
||||
style={fs(10)}
|
||||
>
|
||||
{s.title}
|
||||
</p>
|
||||
{s.options.map(([v, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={s.value === v}
|
||||
key={v}
|
||||
onClick={() => s.onPick(v)}
|
||||
className={`${MENU_ITEM} ${s.value === v ? 'font-semibold text-content' : 'text-content-secondary'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-start">{label}</span>
|
||||
<Check
|
||||
size={14}
|
||||
className={`flex-none text-content-muted ${s.value === v ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpCircle,
|
||||
Bug,
|
||||
CircleDot,
|
||||
Github,
|
||||
Globe,
|
||||
History,
|
||||
Info,
|
||||
KeyRound,
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
PauseCircle,
|
||||
RotateCw,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import { useRef, type ComponentType, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { POPOVER } from '../../Packing/packingPopoverStyles';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import PluginIcon from '../../shared/PluginIcon';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { deriveCaps } from '../pluginCaps';
|
||||
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
|
||||
import { deriveDeps, isRegistrySourced, parseJson, type PluginRow, type T } from './pluginModel';
|
||||
|
||||
// Runtime health → dot colour on the icon tile.
|
||||
const HEALTH: Record<string, string> = {
|
||||
active: 'bg-success',
|
||||
starting: 'bg-info animate-pulse',
|
||||
error: 'bg-danger',
|
||||
inactive: 'bg-content-faint/60',
|
||||
disabled: 'bg-warning',
|
||||
incompatible: 'bg-warning',
|
||||
};
|
||||
|
||||
/** Every fact about a plugin wears the same chip; only its icon carries a colour. */
|
||||
const FACT =
|
||||
'inline-flex max-w-full items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] font-medium text-content-secondary';
|
||||
|
||||
type Tone = 'warning' | 'danger' | 'neutral';
|
||||
const NOTICE_LOOK: Record<Tone, string> = {
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
danger: 'bg-danger-soft text-danger',
|
||||
neutral: 'bg-surface-secondary text-content-muted',
|
||||
};
|
||||
|
||||
/** A state of the plugin worth reading on its own line: what it means, and the one action that fixes it. */
|
||||
interface Notice {
|
||||
key: string;
|
||||
tone: Tone;
|
||||
icon: ComponentType<{ size?: number; className?: string }>;
|
||||
text: string;
|
||||
action?: { label: string; onClick: () => void; disabled?: boolean };
|
||||
}
|
||||
|
||||
function Fact({
|
||||
icon,
|
||||
iconClass = 'text-content-muted',
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
iconClass?: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const chip = (
|
||||
<span className={FACT} style={fs(11)}>
|
||||
<span className={`flex flex-none ${iconClass}`}>{icon}</span>
|
||||
<span className="truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
return hint ? (
|
||||
<Tooltip label={hint}>
|
||||
<span className="inline-flex max-w-full">{chip}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
chip
|
||||
);
|
||||
}
|
||||
|
||||
function NoticeLine({ n }: { n: Notice }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-0 items-center gap-2 rounded-[10px] px-2.5 py-1.5 ${NOTICE_LOOK[n.tone]}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<n.icon size={13} className="flex-none" />
|
||||
<span className="min-w-0 flex-1 truncate">{n.text}</span>
|
||||
{n.action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={n.action.onClick}
|
||||
disabled={n.action.disabled}
|
||||
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80 disabled:opacity-50"
|
||||
>
|
||||
{n.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
danger,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>
|
||||
{icon}
|
||||
</span>{' '}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface InstalledRowProps {
|
||||
p: PluginRow;
|
||||
t: T;
|
||||
busy: string | null;
|
||||
menu: string | null;
|
||||
setMenu: (v: string | null) => void;
|
||||
hasUpdate: boolean;
|
||||
latestVer?: string;
|
||||
newerIncompatible: { version: string; range: string } | null;
|
||||
blocked: boolean;
|
||||
onToggle: () => void;
|
||||
onUpdate: () => void;
|
||||
onRestart: () => void;
|
||||
onChangeVersion: () => void;
|
||||
onResume: () => void;
|
||||
onReviewBlock: () => void;
|
||||
onErrors: () => void;
|
||||
onEgress: () => void;
|
||||
onSettings: () => void;
|
||||
onUninstall: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One installed plugin, read top to bottom: its name and version, what it does, the
|
||||
* facts about it as one row of alike chips (what it adds, whether it was reviewed and
|
||||
* signed, where it may connect), then each state that needs attention on a line of its
|
||||
* own with the action that resolves it. The controls stay on the right.
|
||||
*/
|
||||
export default function PluginInstalledRow(props: InstalledRowProps) {
|
||||
const { p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked } = props;
|
||||
const caps = deriveCaps(
|
||||
parseJson<string[]>(p.permissions, []),
|
||||
parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}),
|
||||
t
|
||||
);
|
||||
const deps = deriveDeps(p, t);
|
||||
const menuOpen = menu === `row:${p.id}`;
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const working = busy === p.id;
|
||||
const hostCount = p.egressHostCount ?? 0;
|
||||
|
||||
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
|
||||
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
|
||||
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
|
||||
useFollowTrigger(menuOpen);
|
||||
|
||||
const notices: Notice[] = [];
|
||||
if (p.status === 'error' && p.last_error) {
|
||||
notices.push({ key: 'error', tone: 'danger', icon: AlertTriangle, text: p.last_error });
|
||||
}
|
||||
// A refused update leaves a WORKING plugin pinned at its old version: its own state,
|
||||
// kept until resolved rather than dying with the toast that first reported it.
|
||||
if (blocked && p.updateBlock) {
|
||||
notices.push({
|
||||
key: 'block',
|
||||
tone: 'warning',
|
||||
icon: ShieldAlert,
|
||||
text: t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code }),
|
||||
action: { label: t('admin.plugins.reviewBlock'), onClick: props.onReviewBlock },
|
||||
});
|
||||
}
|
||||
for (const [i, d] of deps.entries()) {
|
||||
if (d.blocked || d.warn) notices.push({ key: `dep${i}`, tone: 'warning', icon: d.icon, text: d.label });
|
||||
}
|
||||
// Held: the admin deliberately installed a non-latest version, so updates are paused
|
||||
// rather than nagged — resuming is one click, right where the pause shows.
|
||||
if (p.updateHold) {
|
||||
notices.push({
|
||||
key: 'hold',
|
||||
tone: 'neutral',
|
||||
icon: PauseCircle,
|
||||
text: t('admin.plugins.updatesHeld', { version: p.version ?? '' }),
|
||||
action: { label: t('admin.plugins.resumeUpdates'), onClick: props.onResume, disabled: working },
|
||||
});
|
||||
}
|
||||
// A newer version this TREK can't run: informational, never a button, the fix is a TREK upgrade.
|
||||
if (newerIncompatible) {
|
||||
notices.push({
|
||||
key: 'newer',
|
||||
tone: 'neutral',
|
||||
icon: Info,
|
||||
text: t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range }),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-start gap-3 px-3.5 py-3.5 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="relative mt-0.5 flex-none">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
|
||||
</span>
|
||||
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>
|
||||
{p.name}
|
||||
</span>
|
||||
{p.version && (
|
||||
<span className="flex-none font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>
|
||||
v{p.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{p.description && (
|
||||
<p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>
|
||||
{p.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{caps.map((c, i) => (
|
||||
<Fact key={i} icon={<c.icon size={12} />} iconClass={c.net ? 'text-info' : 'text-content-muted'}>
|
||||
{c.label}
|
||||
</Fact>
|
||||
))}
|
||||
{deps
|
||||
.filter((d) => !d.blocked && !d.warn)
|
||||
.map((d, i) => (
|
||||
<Fact key={`d${i}`} icon={<d.icon size={12} />}>
|
||||
{d.label}
|
||||
</Fact>
|
||||
))}
|
||||
{p.reviewed_at && (
|
||||
<Fact icon={<ShieldCheck size={12} />} iconClass="text-success">
|
||||
{t('admin.plugins.reviewed')}
|
||||
</Fact>
|
||||
)}
|
||||
{p.source_repo === 'local:upload' && (
|
||||
<Fact icon={<UploadCloud size={12} />} iconClass="text-warning" hint={t('admin.plugins.sideloadedHint')}>
|
||||
{t('admin.plugins.sideloaded')}
|
||||
</Fact>
|
||||
)}
|
||||
{p.source_repo === 'local:link' && (
|
||||
<Fact icon={<Link2 size={12} />} hint={t('admin.plugins.devLinkHint')}>
|
||||
{t('admin.plugins.devLinkBadge')}
|
||||
</Fact>
|
||||
)}
|
||||
{/* Registry plugins only: a sideloaded or dev-linked plugin already says something stronger. */}
|
||||
{isRegistrySourced(p.source_repo) &&
|
||||
(p.signed ? (
|
||||
<Fact icon={<KeyRound size={12} />} hint={t('admin.plugins.signedHint')}>
|
||||
{t('admin.plugins.signed')}
|
||||
</Fact>
|
||||
) : (
|
||||
<Fact icon={<ShieldAlert size={12} />} iconClass="text-warning" hint={t('admin.plugins.unsignedHint')}>
|
||||
{t('admin.plugins.unsigned')}
|
||||
</Fact>
|
||||
))}
|
||||
{/* A service only the operator can name (a self-hosted Gotify/ntfy): the admin adds
|
||||
its host, and until one exists the plugin reaches nothing, so it asks for it. */}
|
||||
{p.operatorEgress && (
|
||||
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
|
||||
<button type="button" onClick={props.onEgress} className={`${FACT} hover:opacity-80`} style={fs(11)}>
|
||||
<Globe size={12} className={hostCount > 0 ? 'text-info' : 'text-warning'} />
|
||||
{hostCount > 0
|
||||
? t('admin.plugins.allowedHosts.count', { n: hostCount })
|
||||
: t('admin.plugins.allowedHosts.add')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notices.length > 0 && (
|
||||
<div className="mt-2.5 flex flex-col gap-1.5">
|
||||
{notices.map((n) => (
|
||||
<NoticeLine key={n.key} n={n} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none items-center gap-2 self-center">
|
||||
{hasUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onUpdate}
|
||||
disabled={working}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`min-w-[42px] text-end font-medium ${p.enabled === 1 ? 'text-content-secondary' : 'text-content-faint'}`}
|
||||
style={fs(12)}
|
||||
>
|
||||
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
|
||||
</span>
|
||||
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={props.onToggle} />
|
||||
<div className="relative">
|
||||
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
|
||||
<button
|
||||
type="button"
|
||||
ref={menuBtnRef}
|
||||
data-testid={`plugin-row-menu-btn-${p.id}`}
|
||||
onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label={t('admin.table.actions')}
|
||||
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}
|
||||
>
|
||||
<MoreHorizontal size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{menuOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
data-testid={`plugin-row-menu-${p.id}`}
|
||||
style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
|
||||
className="min-w-[200px]"
|
||||
>
|
||||
{p.enabled === 1 && (
|
||||
<MenuItem
|
||||
icon={<RotateCw size={14} />}
|
||||
label={t('admin.plugins.restart')}
|
||||
onClick={props.onRestart}
|
||||
/>
|
||||
)}
|
||||
{/* Instance fields OR actions: an action-only plugin still needs the dialog to run its buttons. */}
|
||||
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
|
||||
<MenuItem
|
||||
icon={<SlidersHorizontal size={14} />}
|
||||
label={t('admin.plugins.instanceSettings')}
|
||||
onClick={props.onSettings}
|
||||
/>
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={props.onErrors} />
|
||||
{/* Only a plugin that DECLARED operatorEgress: an admin is never invited to widen egress for one that did not ask. */}
|
||||
{p.operatorEgress && (
|
||||
<MenuItem
|
||||
icon={<Globe size={14} />}
|
||||
label={t('admin.plugins.allowedHosts')}
|
||||
onClick={props.onEgress}
|
||||
/>
|
||||
)}
|
||||
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
|
||||
{isRegistrySourced(p.source_repo) && (
|
||||
<MenuItem
|
||||
icon={<History size={14} />}
|
||||
label={t('admin.plugins.changeVersion')}
|
||||
onClick={props.onChangeVersion}
|
||||
/>
|
||||
)}
|
||||
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
|
||||
<>
|
||||
<a
|
||||
href={`https://github.com/${p.source_repo}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => setMenu(null)}
|
||||
className={`${MENU_ITEM} text-content-secondary`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="flex w-4 flex-none justify-center text-content-muted">
|
||||
<Github size={14} />
|
||||
</span>{' '}
|
||||
{t('admin.plugins.sourceRepo')}
|
||||
</a>
|
||||
<a
|
||||
href={`https://github.com/${p.source_repo}/issues`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => setMenu(null)}
|
||||
className={`${MENU_ITEM} text-content-secondary`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="flex w-4 flex-none justify-center text-content-muted">
|
||||
<CircleDot size={14} />
|
||||
</span>{' '}
|
||||
{t('admin.plugins.reportIssue')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
|
||||
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={props.onUninstall} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
fs,
|
||||
} from '../../shared/DialogShell';
|
||||
import type { T } from './pluginModel';
|
||||
|
||||
/**
|
||||
* How plugins are contained and where that ends, opened from the shield in the panel's
|
||||
* header. It used to sit under the list as a card of its own, which pushed a wall of
|
||||
* text into the page every admin scrolls past.
|
||||
*/
|
||||
export default function PluginSecurityDialog({ t, onClose }: { t: T; onClose: () => void }) {
|
||||
const sections: Array<[string, string]> = [
|
||||
[t('admin.plugins.security.isolationTitle'), t('admin.plugins.security.isolationBody')],
|
||||
[t('admin.plugins.security.permsTitle'), t('admin.plugins.security.permsBody')],
|
||||
[t('admin.plugins.security.limitsTitle'), t('admin.plugins.security.limitsBody')],
|
||||
[t('admin.plugins.security.worstTitle'), t('admin.plugins.security.worstBody')],
|
||||
[t('admin.plugins.security.reviewedTitle'), t('admin.plugins.security.reviewedBody')],
|
||||
[t('admin.plugins.security.signedTitle'), t('admin.plugins.security.signedBody')],
|
||||
[t('admin.plugins.security.trustTitle'), t('admin.plugins.security.trustBody')],
|
||||
];
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy="plugin-security-title"
|
||||
width="detail"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<ShieldCheck size={20} className="text-content-secondary" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId="plugin-security-title"
|
||||
title={t('admin.plugins.security.short')}
|
||||
sub={t('admin.plugins.reviewedMeaning')}
|
||||
subWraps
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={onClose}>
|
||||
{t('common.ok')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||
{sections.map(([h, b]) => (
|
||||
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
||||
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{h}
|
||||
</h4>
|
||||
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>
|
||||
{b}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
|
||||
/** The tallest a row's ⋯ menu gets; below that it opens upwards instead. */
|
||||
const ROW_MENU_MAX_H = 260;
|
||||
|
||||
/**
|
||||
* Where a portaled menu sits: right-aligned under its trigger, or above it when the
|
||||
* window has no room below. Fixed, because the cards around the toolbar and the rows
|
||||
* clip whatever overflows them.
|
||||
*/
|
||||
export function anchorMenu(trigger: HTMLElement | null): CSSProperties {
|
||||
const r = trigger?.getBoundingClientRect();
|
||||
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 };
|
||||
const spaceBelow = window.innerHeight - r.bottom;
|
||||
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow;
|
||||
return {
|
||||
position: 'fixed',
|
||||
right: Math.max(8, window.innerWidth - r.right),
|
||||
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
|
||||
zIndex: 100,
|
||||
};
|
||||
}
|
||||
|
||||
/** One entry of a popover menu, in the packing popovers' shape. */
|
||||
export const MENU_ITEM =
|
||||
'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary';
|
||||
|
||||
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
|
||||
export function useFollowTrigger(open: boolean) {
|
||||
const [, reanchor] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMove = () => reanchor((n) => n + 1);
|
||||
window.addEventListener('scroll', onMove, true);
|
||||
window.addEventListener('resize', onMove);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onMove, true);
|
||||
window.removeEventListener('resize', onMove);
|
||||
};
|
||||
}, [open]);
|
||||
}
|
||||
@@ -111,8 +111,14 @@ describe('pluginModel', () => {
|
||||
blocked: false,
|
||||
version: '0.9.0',
|
||||
});
|
||||
expect(installOffer(incompatible, t)).toMatchObject({ blocked: true, label: 'admin.plugins.incompatible' });
|
||||
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, label: 'admin.plugins.installAnyway' });
|
||||
// Nothing fits this TREK: still an Install, behind the admin's own warning for this plugin.
|
||||
expect(installOffer(incompatible, t)).toMatchObject({
|
||||
blocked: false,
|
||||
label: 'admin.plugins.install',
|
||||
warn: { acceptedByAdmin: true, trekRange: '>=9', hostVersion: '4.0.0' },
|
||||
});
|
||||
// TREK_PLUGINS_IGNORE_TREK_RANGE lets every plugin through, so the warning is not the admin's own choice.
|
||||
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, warn: { acceptedByAdmin: false } });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PLUGIN-MODEL-008: deriveDeps marks the blockers among the declared dependencies', () => {
|
||||
|
||||
@@ -298,7 +298,8 @@ export function installOffer(
|
||||
title,
|
||||
};
|
||||
}
|
||||
return { blocked: true, label: t('admin.plugins.incompatible'), title };
|
||||
// No version fits this TREK: the admin may still install this one plugin at their own risk.
|
||||
return bypassOffer(item, t, title, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-PLUGINS-HOOK-001 to -012: the plugin admin logic behind both admin shells.
|
||||
// FE-ADMIN-PLUGINS-HOOK-001 to -014: the plugin admin logic behind both admin shells.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
@@ -266,4 +266,36 @@ describe('usePluginsAdmin', () => {
|
||||
expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled');
|
||||
expect(result.current.confirmUninstall).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PLUGINS-HOOK-013: switching on a plugin outside its TREK range asks first, then runs it anyway', async () => {
|
||||
serve([
|
||||
row({ enabled: 0, dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0' }),
|
||||
]);
|
||||
vi.spyOn(adminApi, 'pluginActivate').mockResolvedValue({});
|
||||
const { result } = await mounted();
|
||||
act(() => result.current.toggle(result.current.plugins[0] as PluginRow));
|
||||
expect(adminApi.pluginActivate).not.toHaveBeenCalled();
|
||||
expect(result.current.bypass.warning).toMatchObject({
|
||||
acceptedByAdmin: true,
|
||||
action: 'activate',
|
||||
trekRange: '>=3.2.0 <4.0.0',
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.bypass.confirm();
|
||||
});
|
||||
await waitFor(() => expect(adminApi.pluginActivate).toHaveBeenCalledWith('alpha', false, true));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PLUGINS-HOOK-014: installing behind the admin warning sends their consent for this plugin', async () => {
|
||||
serve([], [entry({ id: 'beta', name: 'Beta' })]);
|
||||
vi.spyOn(adminApi, 'pluginInstall').mockResolvedValue({ id: 'beta', version: '1.0.0', trekRangeBypassed: null });
|
||||
const { result } = await mounted();
|
||||
const warn = { name: 'Beta', trekRange: '<5.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: null };
|
||||
act(() => result.current.install('beta', undefined, warn));
|
||||
expect(adminApi.pluginInstall).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
result.current.bypass.confirm();
|
||||
});
|
||||
await waitFor(() => expect(adminApi.pluginInstall).toHaveBeenCalledWith('beta', { acceptTrekRange: true }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,9 +317,14 @@ export function usePluginsAdmin() {
|
||||
act(
|
||||
id,
|
||||
() =>
|
||||
adminApi.pluginInstall(id, version ? { version } : undefined).then((r) => {
|
||||
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
|
||||
}),
|
||||
adminApi
|
||||
.pluginInstall(id, {
|
||||
...(version ? { version } : {}),
|
||||
...(warn?.acceptedByAdmin ? { acceptTrekRange: true } : {}),
|
||||
})
|
||||
.then((r) => {
|
||||
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
|
||||
}),
|
||||
t('admin.plugins.installed')
|
||||
)
|
||||
);
|
||||
@@ -392,10 +397,10 @@ export function usePluginsAdmin() {
|
||||
}
|
||||
};
|
||||
|
||||
const attemptActivate = (p: PluginRow) => {
|
||||
const attemptActivate = (p: PluginRow, acceptTrekRange = false) => {
|
||||
const cascaded = autoEnabledDeps(p);
|
||||
return adminApi
|
||||
.pluginActivate(p.id)
|
||||
.pluginActivate(p.id, false, acceptTrekRange)
|
||||
.then(() => {
|
||||
toast.success(t('admin.plugins.activated'));
|
||||
if (cascaded.length)
|
||||
@@ -415,12 +420,29 @@ export function usePluginsAdmin() {
|
||||
void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated'));
|
||||
return;
|
||||
}
|
||||
setBusy(p.id);
|
||||
setMenu(null);
|
||||
attemptActivate(p).finally(() => {
|
||||
setBusy(null);
|
||||
refresh();
|
||||
});
|
||||
const run = (acceptTrekRange: boolean) => {
|
||||
setBusy(p.id);
|
||||
setMenu(null);
|
||||
attemptActivate(p, acceptTrekRange).finally(() => {
|
||||
setBusy(null);
|
||||
refresh();
|
||||
});
|
||||
};
|
||||
// Outside the TREK versions its author declared: the admin may run it anyway, after
|
||||
// reading what that risks. The choice holds for this version of the plugin only.
|
||||
if (p.dependencyStatus === 'hostIncompatible') {
|
||||
const warn: RangeWarning = {
|
||||
name: p.name,
|
||||
trekRange: p.trekRange ?? null,
|
||||
hostVersion: p.hostVersion ?? '?',
|
||||
acceptedByAdmin: true,
|
||||
onConfirm: null,
|
||||
action: 'activate',
|
||||
};
|
||||
bypass.guard(warn, () => run(true));
|
||||
return;
|
||||
}
|
||||
run(false);
|
||||
};
|
||||
|
||||
// Download a missing/outdated plugin dependency (latest compatible for its range,
|
||||
|
||||
@@ -16,15 +16,23 @@ describe('rangeBypass helpers', () => {
|
||||
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
|
||||
})
|
||||
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
|
||||
// An admin's own "run it anyway" for this one plugin says so, not "version checks off".
|
||||
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
|
||||
warn: true, label: 'admin.plugins.dep.trekAccepted:{"range":">=3.2.0 <4.0.0","host":"5.0.0"}',
|
||||
})
|
||||
expect(bypassChip({ trekRange: null, hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
|
||||
label: 'admin.plugins.dep.trekAcceptedUnknown',
|
||||
})
|
||||
})
|
||||
|
||||
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
|
||||
it('bypassOffer: an enabled Install that carries what the dialog must say first', () => {
|
||||
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
|
||||
expect(offer).toEqual({
|
||||
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
|
||||
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
|
||||
blocked: false, label: 'admin.plugins.install', title: 'why',
|
||||
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false, onConfirm: null },
|
||||
})
|
||||
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
|
||||
expect(bypassOffer({ name: 'X' }, t, 'why', true).warn).toMatchObject({ acceptedByAdmin: true })
|
||||
})
|
||||
|
||||
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
|
||||
@@ -32,10 +40,25 @@ describe('rangeBypass helpers', () => {
|
||||
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
|
||||
confirm: true, title: 'admin.plugins.rangeBypass.title',
|
||||
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
|
||||
confirmLabel: 'admin.plugins.installAnyway',
|
||||
})
|
||||
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
|
||||
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
|
||||
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
|
||||
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","range":null,"host":"3.3.0"}',
|
||||
confirmLabel: 'admin.plugins.installAnyway',
|
||||
})
|
||||
})
|
||||
|
||||
it('rangeWarningCopy: an admin choosing for one plugin gets the short title, their own body and the right button', () => {
|
||||
const mine = { name: 'G', trekRange: '>=4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: () => {} }
|
||||
expect(rangeWarningCopy(mine, t)).toEqual({
|
||||
confirm: true, title: 'admin.plugins.rangeBypass.titleAdmin',
|
||||
body: 'admin.plugins.rangeBypass.bodyAdmin:{"name":"G","range":">=4.0.0","host":"5.0.0"}',
|
||||
confirmLabel: 'admin.plugins.installAnyway',
|
||||
})
|
||||
expect(rangeWarningCopy({ ...mine, trekRange: null, action: 'activate' }, t)).toMatchObject({
|
||||
body: 'admin.plugins.rangeBypass.bodyAdminUnknown:{"name":"G","range":null,"host":"5.0.0"}',
|
||||
confirmLabel: 'admin.plugins.activateAnyway',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,21 @@ import { useTranslation } from '../../i18n'
|
||||
|
||||
type T = (k: string, p?: Record<string, unknown>) => string
|
||||
|
||||
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
|
||||
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
|
||||
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
|
||||
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
|
||||
/**
|
||||
* The server's "this only went through because the version check was set aside" marker:
|
||||
* by an admin for this one plugin (`acceptedByAdmin`), or for every plugin through
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE.
|
||||
*/
|
||||
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string; acceptedByAdmin?: boolean }
|
||||
/**
|
||||
* What the range-bypass warning needs to say, and what to do if the admin accepts (null = a
|
||||
* notice). `action` names what the confirm button does: install (the default) or activate.
|
||||
*/
|
||||
export interface RangeWarning extends TrekRangeBypass {
|
||||
name: string
|
||||
onConfirm: (() => void) | null
|
||||
action?: 'install' | 'activate'
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip a row carries while it runs outside its declared range on the operator's
|
||||
@@ -16,29 +27,42 @@ export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm:
|
||||
*/
|
||||
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
|
||||
if (!b) return null
|
||||
const label = b.acceptedByAdmin
|
||||
? b.trekRange
|
||||
? t('admin.plugins.dep.trekAccepted', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekAcceptedUnknown')
|
||||
: b.trekRange
|
||||
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekBypassedUnknown')
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
label: b.trekRange
|
||||
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekBypassedUnknown'),
|
||||
label,
|
||||
blocked: false,
|
||||
warn: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Install button for a registry entry the server flagged incompatible, once the
|
||||
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
|
||||
* regardless, so the button says exactly that — and carries what the warning has to say
|
||||
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
|
||||
* The Install button for a registry entry the server flagged incompatible: the server will
|
||||
* take the newest version regardless, so the button says exactly that — and carries what
|
||||
* the warning has to say first. `byAdmin` is the admin's own "install anyway" for this one
|
||||
* plugin; without it the operator set TREK_PLUGINS_IGNORE_TREK_RANGE for every plugin.
|
||||
* `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
|
||||
*/
|
||||
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
|
||||
return {
|
||||
blocked: false,
|
||||
label: t('admin.plugins.installAnyway'),
|
||||
title,
|
||||
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
|
||||
export function bypassOffer(
|
||||
item: { name: string; trek?: string | null; hostVersion?: string },
|
||||
t: T,
|
||||
title: string,
|
||||
byAdmin = false
|
||||
) {
|
||||
const warn: RangeWarning = {
|
||||
name: item.name,
|
||||
trekRange: item.trek ?? null,
|
||||
hostVersion: item.hostVersion ?? '?',
|
||||
acceptedByAdmin: byAdmin,
|
||||
onConfirm: null,
|
||||
}
|
||||
return { blocked: false, label: t('admin.plugins.install'), title, warn }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,14 +72,29 @@ export function bypassOffer(item: { name: string; trek?: string | null; hostVers
|
||||
* whole point: the admin is accepting that the author never vouched for this TREK and
|
||||
* that, in rare cases, that can cost them data.
|
||||
*/
|
||||
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
|
||||
export function rangeWarningCopy(
|
||||
w: RangeWarning,
|
||||
t: T
|
||||
): { title: string; body: string; confirm: boolean; confirmLabel: string } {
|
||||
const confirm = w.onConfirm !== null
|
||||
const vars = { name: w.name, range: w.trekRange, host: w.hostVersion }
|
||||
const body = w.acceptedByAdmin
|
||||
? w.trekRange
|
||||
? t('admin.plugins.rangeBypass.bodyAdmin', vars)
|
||||
: t('admin.plugins.rangeBypass.bodyAdminUnknown', vars)
|
||||
: w.trekRange
|
||||
? t('admin.plugins.rangeBypass.body', vars)
|
||||
: t('admin.plugins.rangeBypass.bodyUnknown', vars)
|
||||
const title = w.acceptedByAdmin
|
||||
? t('admin.plugins.rangeBypass.titleAdmin')
|
||||
: confirm
|
||||
? t('admin.plugins.rangeBypass.title')
|
||||
: t('admin.plugins.rangeBypass.noticeTitle')
|
||||
return {
|
||||
confirm,
|
||||
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
|
||||
body: w.trekRange
|
||||
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
|
||||
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
|
||||
title,
|
||||
body,
|
||||
confirmLabel: w.action === 'activate' ? t('admin.plugins.activateAnyway') : t('admin.plugins.installAnyway'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
|
||||
import type { AssistantMessage as StoredMessage } from '@trek/shared';
|
||||
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
@@ -16,7 +16,7 @@ import AssistantShareDialog from './AssistantShareDialog';
|
||||
import AssistantSources from './AssistantSources';
|
||||
import AssistantToolSteps from './AssistantToolSteps';
|
||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
import { type MessageBlock, messageBlocks, tailStart } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import AssistantCardBlock from './cards/AssistantCardBlock';
|
||||
import { useAssistantShare } from './useAssistantShare';
|
||||
@@ -108,6 +108,28 @@ function Question({ message }: { message: StoredMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One block of an answer, in the shape its kind takes. */
|
||||
function AnswerBlock({
|
||||
block,
|
||||
message,
|
||||
writing,
|
||||
streaming,
|
||||
}: {
|
||||
block: MessageBlock;
|
||||
message: StoredMessage;
|
||||
writing: boolean;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
if (block.kind === 'text') return <AssistantMarkdown text={block.text} writing={streaming} />;
|
||||
if (block.kind === 'steps') return <AssistantToolSteps steps={block.steps} />;
|
||||
if (block.kind === 'places') return <AssistantPlaceStrip found={block.found} message={message} />;
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources sources={block.sources} />;
|
||||
if (block.kind === 'remembered') return <AssistantRemembered note={block.note} live={writing} />;
|
||||
if (block.kind === 'cards') return <AssistantCardBlock block={block} message={message} />;
|
||||
return <AssistantChangeCard items={block.items} />;
|
||||
}
|
||||
|
||||
function Answer({ message }: { message: StoredMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
@@ -119,25 +141,16 @@ function Answer({ message }: { message: StoredMessage }) {
|
||||
const share = useAssistantShare(tripId, message, writing);
|
||||
const last = blocks[blocks.length - 1];
|
||||
const hasText = blocks.some((b) => b.kind === 'text');
|
||||
const tail = tailStart(blocks);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
|
||||
{blocks.map((block) => {
|
||||
if (block.kind === 'text') {
|
||||
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
|
||||
}
|
||||
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
|
||||
if (block.kind === 'places') {
|
||||
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
||||
}
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
|
||||
if (block.kind === 'remembered') {
|
||||
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
|
||||
}
|
||||
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
|
||||
return <AssistantChangeCard key={block.key} items={block.items} />;
|
||||
})}
|
||||
{blocks.map((block, i) => (
|
||||
<Fragment key={block.key}>
|
||||
{i === tail && i > 0 && <span data-assistant-tail aria-hidden="true" className="-mb-2.5 block h-0" />}
|
||||
<AnswerBlock block={block} message={message} writing={writing} streaming={writing && block === last} />
|
||||
</Fragment>
|
||||
))}
|
||||
{/* Nothing below says the answer goes on: no words being written, no step running. */}
|
||||
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
|
||||
<p role="status" className="m-0 text-caption">
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function AssistantSurface({
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
onOpenTripChat,
|
||||
journal,
|
||||
}: AssistantSurfaceProps) {
|
||||
const runtime = useAssistantRuntime(tripId);
|
||||
// The phone is always compact; a window is what its width says.
|
||||
@@ -43,8 +44,9 @@ export default function AssistantSurface({
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
onOpenTripChat,
|
||||
journal,
|
||||
}),
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat, journal]
|
||||
);
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
|
||||
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
@@ -15,13 +15,15 @@ import { useAssistantAttach } from './useAssistantAttach';
|
||||
import { useFollowSelectedDay } from './useAssistantFocus';
|
||||
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
|
||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||
import { useRevealAnswerTail } from './useRevealAnswerTail';
|
||||
|
||||
/**
|
||||
* The chat itself: the messages, and under them the line that says what went
|
||||
* wrong, the privacy note and the composer, held at the bottom while the
|
||||
* messages scroll. A new question moves up to the top of the view, so its
|
||||
* answer reads from its first line instead of chasing the newest one. A
|
||||
* booking file or a place of the trip can be dropped anywhere on it.
|
||||
* answer reads from its first line instead of chasing the newest one, and once
|
||||
* the answer is written its end comes into view. A booking file or a place of
|
||||
* the trip can be dropped anywhere on it.
|
||||
*/
|
||||
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,6 +33,8 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
|
||||
useFollowSelectedDay(tripId);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
useRevealAnswerTail(viewport, isRunning);
|
||||
|
||||
const phone = variant === 'fullscreen';
|
||||
const attach = useAssistantAttach(tripId);
|
||||
@@ -52,7 +56,11 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
<ThreadPrimitive.Viewport
|
||||
ref={viewport}
|
||||
turnAnchor="top"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4"
|
||||
>
|
||||
<AuiIf condition={(s) => s.thread.isEmpty}>
|
||||
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
|
||||
{mentionable && (
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* FE-AI-WELCOME-001 to FE-AI-WELCOME-002
|
||||
*
|
||||
* The empty chat asks what fits the chat it is: a journey's conversation
|
||||
* shown for its journal asks about the journal, the same conversation in TREK
|
||||
* Studio about the book, and each tells a reader what they may not change.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import AssistantWelcome from './AssistantWelcome';
|
||||
import { AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
const JOURNAL_KEY = -12;
|
||||
|
||||
function welcome(canEdit: boolean, journal?: boolean) {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: JOURNAL_KEY, variant: 'window', journal }}>
|
||||
<AssistantWelcome suggestions={[]} canEdit={canEdit} withSuggestions={false} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AssistantWelcome', () => {
|
||||
it("FE-AI-WELCOME-001: a journey's chat shown for its journal asks about the journal", () => {
|
||||
welcome(false, true);
|
||||
expect(screen.getByText('What should go into the journal?')).toBeInTheDocument();
|
||||
expect(screen.getByText(/You can only read this journal/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-WELCOME-002: the same chat in the Studio asks about the book', () => {
|
||||
welcome(true);
|
||||
expect(screen.getByText('What should the book look like?')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/You can only/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||