* fix(plugins): unknown column

* fix(mcp): reuse MCP sessions instead of creating one per tool call

The /mcp CORS layer never set exposedHeaders, so Access-Control-Expose-Headers
was absent and browser-context MCP clients (Claude Desktop connectors,
Claude.ai, MCP Inspector) could not read Mcp-Session-Id off the initialize
response. Unable to echo it back, every request looked like a fresh initialize:
one McpServer and one session per tool call, until the per-user cap returned a
429 and the integration died until the container was restarted.

The idle sweep was not at fault — it expires on lastActivity with a 1h default,
so sessions born seconds apart are nowhere near expiry, hence 'cleaned 0'.

- expose Mcp-Session-Id, MCP-Protocol-Version and WWW-Authenticate
- evict a user's least-recently-active session at the cap rather than
  refusing the request, so a client that cannot persist its session id (or a
  proxy that strips the header) can never wedge the server
- close the McpServer/transport orphaned by every session-less non-initialize
  POST, which was leaked: never mapped, never swept, never closed
- return the cap error as JSON-RPC so clients surface the real reason
- warn on session-less POSTs to make a header-stripping proxy diagnosable

* chore(deps): declare @modelcontextprotocol/sdk ^1.29.0

Matches the version already resolved in the lockfile; no dependency-tree change.

* docs(mcp): add the reverse proxy specs for MCP

* fix(plugins): stop the row ⋯ menu being clipped by the sidebar

The menu was an in-flow `absolute` div, and its ancestor (PageSidebar) is
`overflow-hidden` — which clips absolutely-positioned descendants regardless
of z-index. With enough plugins installed a row sits low enough that its menu
runs past the sidebar's bottom edge and gets chopped, taking Delete with it,
so the plugin could no longer be uninstalled from the UI.

Portal the menu to <body> and position it `fixed` against the ⋯ button,
flipping upward when the bottom is tight and re-anchoring on scroll/resize.
A fixed child of <body> has no overflow ancestor, so nothing can clip it.

Closes #1523

* feat(plugins): surface author-signature status and add a scoped re-trust override

TREK has always verified an author's Ed25519 signature and TOFU-pinned the key on
first install, but none of it was ever shown: a successfully-installed UNSIGNED
plugin looked identical to a signed one, and a signature-refused update left the
plugin quietly pinned at its old version with the reason dying in a toast.

Give the four refusal conditions machine-readable codes (SIGNATURE_MISSING /
_INCOMPLETE / _KEY_CHANGED / _INVALID), persist a refusal on the plugin row so the
admin list keeps showing it, and badge Signed/Unsigned in the list and in Discover.

Only SIGNATURE_KEY_CHANGED is overridable — an author can legitimately rotate a key;
a signature that does not verify means the bytes are not what the author signed, and
there is no story where waving that through is right. The override re-pins and
updates in ONE call (POST :id/retrust): a re-pin that waited for a follow-up /update
would leave the plugin pinned to a key no install had ever been verified against if
that second call never came. The artifact must still verify under the new key, so a
re-trust moves the pin from one verified key to another.

assertRetrustable re-derives the condition server-side, so the UI hiding the button
is a convenience, not the control, and it echoes back the full key the admin was
shown so a re-key since the dialog rendered is refused. The rotation is written to
the admin audit log with both fingerprints — after an incident, "which key did we
move from, and to what?" is the question a single key cannot answer.

* fix(plugins): let a plugin's frame reach the hosts an admin added for it

The frame's connect-src was built from the manifest's http:outbound grants alone, but
the child's egress guard is the UNION of those and the hosts an admin added after
install for an operatorEgress plugin (a self-hosted Gotify, an ntfy — hosts the author
cannot know in advance). So such a plugin WITH A UI could call the operator's host from
its server and was CSP-blocked in its own iframe.

Match the frame to the child. The admin consented to these hosts at install and the
child already reaches them, so this widens no trust boundary that isn't already crossed.
Both sources stay validated on the way in, and the interpolation filter is unchanged.

* fix(plugins): report a rejected events.emit on the plugin's own log stream

The host can reject an emit (an undeclared event name, a rate limit). The rejection must
not escape — a detached rejection crashes the child and terminally disables the plugin
over one bad emit — but swallowing it silently left an author with no way to discover
that `emits` was missing from their manifest. Surface it as a warning instead.

* feat(plugin-sdk): expose the raw request body for webhook signature checks

A webhook author must run their HMAC over the exact bytes the sender signed. `body` is
the PARSED value, and re-serializing it will not reproduce those bytes — key order,
whitespace and unicode escaping all differ — so the signature never matches. Document
`rawBodyBase64`, which the host already sets on auth:false routes.

* fix(plugin-sdk): refuse to overwrite a released artifact, and keep the packed zip

A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes
of a release already in the registry breaks the checksum for everyone who installed that
version — they can no longer install or update it. The old code blanket-caught every
`gh release create` failure (auth, network, a bad repo) and turned it into a --clobber
upload. Probe for the release explicitly and refuse unless --force.

Also stop deleting plugin.zip on the way out. It is the exact bytes the release and the
entry's sha256 pin were computed from; a re-pack on another machine or SDK version can
differ (CRLF, walk order), so anyone re-running `entry`/`sign` afterwards must hash THAT
file, not a rebuild.

* fix(plugin-sdk): don't fail submit when the fork already has an upstream remote

`gh repo clone` of a fork may already have wired `upstream`, in which case a bare
`remote add` exits non-zero and took the whole submit down. Set it either way.

* fix(plugin-sdk): make the dev server behave like the host

Three ways `dev` lied to an author about how their plugin would run in production:

- ctx.db: one try/catch wrapped both the node:sqlite probe AND opening the database, so
  an mkdir/permission failure silently degraded to the in-memory stub — which swallows
  every write while reporting success. A db:own plugin "worked" in dev and persisted
  nothing. Probe separately: fail loudly on a real error, degrade only on old Node, and
  say plainly that the stub discards writes.
- notificationChannel: the host fires it with no acting user and hands the recipient's
  decrypted settings in as a separate `config` argument — send(msg, config, ctx) /
  test(config, ctx). Firing it like an ordinary hook passed `ctx` where `config` belongs,
  so a channel plugin read its settings off ctx and was broken in production.
- /preview pinned tripId 42 while the scaffold seeds trip 1, so the widget's first
  trek:invoke hit assertMember(42) and 500'd. Preview against a trip that exists.

* fix(plugin-sdk): reject unknown flags, wire up create's new flags, and add --help

`parse()` accepts any --x, so a flag a command does not read was silently dropped:
`create --template notification-channel` cheerfully scaffolded a blank plugin. Silently
ignoring an author's explicit instruction is worse than refusing it, so unknown flags are
now an error, and create actually forwards --template/--egress/--required-addons.

A bare --permissions used to split the string "true" into a permission literally named
`true`; listFlag() now treats a valueless flag as absent.

Since an unknown flag is now fatal, `--help` has to exist: it is intercepted before the
flag check and prints usage on stdout with exit 0.

* chore(plugins): drop three unused eslint-disable directives

The no-console rule is not enabled for these files, so the directives were dead and
eslint reports them as unused.

* fix(plugin-sdk): bring preflight back in step with the registry's gates

preflight exists to tell an author what TREK-Plugins' CI will say before they open the
PR. The registry now verifies author signatures, and preflight didn't — so it drifted
into the one failure mode it must never have: a false green. An author trusts a green.

- Verify the signature against the artifact bytes. preflight already downloads them for
  the sha256 check, so this costs one call. Without it, signing with the wrong key (or
  re-packing after signing) sails through and is caught at review.
- Check the signature SHAPE (checkSignatureShape): a key with no signed version, a
  signature with no key, a malformed key or signature. TREK refuses to install a
  half-signed entry, so such an entry is dead on arrival.
- Default apiVersion to 1 before comparing. It is OPTIONAL in the manifest — install/
  manifest.ts and `entry` both default it — so a manifest that legally omits it was
  failing preflight with "manifest apiVersion undefined != entry 1" while the registry
  passed it. A false RED, which teaches authors to ignore the tool.
- Check requiredAddons/pluginDependencies parity, and operatorEgress without an
  http:outbound grant.

The verifier is a port of the host's install/verify-signature.ts (the registry has its
own port); sign.ts's verifyArtifact only understands the bare key/signature pair the SDK
itself emits and cannot judge a minisign key. A test pins all three to the same verdicts.

* feat(plugins): enforce compatibility range

* chore: bump sdk version

* test(e2e): repair the trip-creation specs

create-trip and trip-planner have been failing for a while — long enough for
three separate UI changes to drift past them, which nothing caught because CI
runs vitest only and never invokes Playwright.

Each failure was masking the next:

- The release-notice modal greets a freshly seeded user and its backdrop
  swallowed the click on .add-trip-card. Added a shared dismissSystemNotices()
  helper (the X only shows on the notice's last page, so it has to page through
  first).
- .modal-backdrop no longer exists — the class was namespaced to
  .trek-modal-backdrop so content blockers stop hiding it.
- input[type=text].first() is no longer the Title field: the cover-image search
  inputs now sit above it, so the specs were typing the trip name into the photo
  search box and creating an untitled trip that never matched getByText(title).

* fix(planner): gate drag & drop on pointer type, not viewport width (#1432)

The 3.2.1 fix disabled drag on "mobile", but nothing in the client has ever
detected touch — "mobile" was inferred from viewport width, at four independent
breakpoints. A tablet is a coarse-pointer device at a *desktop* width, so an
iPad (820-1366px) fell on the wrong side of all four, which is why iPhone was
fixed and iPad was not:

- useTripPlanner's isMobile (<768px) is what disarmed `draggable`, so on iPad
  rows stayed draggable and a scroll swipe became an HTML5 drag.
- TripPlannerPage hardcoded isMobile={false} on the desktop PlacesSidebar, so
  its drop handlers and the drop-to-import overlay could never be disabled —
  that overlay is the reported symptom.
- The arrow-button reorder fallback was revealed only below 767px, leaving iPad
  with no drag *and* no fallback.
- touchDragPolyfill loaded drag-drop-touch at >=1024px, synthesising drags from
  touchmove — on a landscape iPad that re-armed the very gesture hijack 3.2.1
  removed.

Adds useIsTouch() ((pointer: coarse)) as a signal separate from isMobile: layout
stays width-driven, so the iPad keeps the desktop two-pane planner, while every
drag affordance is gated on isMobile || isTouch. The reorder arrows now show on
coarse pointers, and the polyfill loads only on hybrid laptops
((pointer: fine) and (any-pointer: coarse)), which also removes the #1440
phantom-dblclick map zoom on tablets.

Guarded by unit tests plus an e2e spec on real WebKit in an iPad Pro 11 context
— the engine matters, since every browser on iPadOS is WebKit underneath, which
is why the reporter hit this in all three they tried.

* test(planner): guard the day-plan reorder arrows against phantom clicks

The hover rule that reveals the reorder arrows had been dead since the
TypeScript migration: it targeted `.place-row:hover .reorder-btns`, and neither
class exists — the component renders `.reorder-buttons` inside an unclassed row.
The buttons sat at opacity:0 on desktop with nothing to reveal them.

That was not merely invisible. opacity:0 still hit-tests, so every itinerary row
and day note carried an invisible, fully clickable target that silently
reordered the trip:

  opacity: "0", visibility: "visible", pointerEvents: "auto"
  elementFromPoint(centre) -> BUTTON, inside .reorder-buttons

The repair — pointer-events: none while hidden, plus a working
.dp-row:hover/:focus-within reveal (focus-within so the buttons are also
reachable by keyboard, which the file's JS-hover pattern cannot do) — lives in
index.css and DayPlanSidebar.tsx. Both files also carry the #1432 drag gating,
so they went with that commit rather than being split mid-file; this commit is
the regression guard for them.

E2E rather than unit, because jsdom does not evaluate :hover. Against the old
CSS it fails on exactly the right assertion: "hidden arrows must not swallow
clicks" — expected false, received true.

* fix(pdf): keep the header gap on day-header overflow pages (#1531)

The repeated <thead> day header (#1471) carried no gap before the first
card on overflow pages: the 12px sat in .day-body's block-start padding,
which a fragmented box only paints on its first fragment. Move it to the
thead cell so it repeats with the header; the day's first page renders
pixel-identically.

* fix(budget): keep "no one paid yet" when editing an expense (#1533)

The ExpenseModal payer initializer fell back to the current user whenever the
edited item had no payer, so reopening an expense saved with "no one paid yet"
silently reselected "You" — and re-saving then recorded the current user as the
payer, corrupting the balances. An absent payer on an existing expense is a
deliberate value, so only a brand-new expense defaults to me.
This commit is contained in:
jubnl
2026-07-13 12:40:00 +02:00
committed by GitHub
parent e60fbe7dd6
commit ebc98f0a3f
114 changed files with 5148 additions and 425 deletions
@@ -1,5 +1,5 @@
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { server } from '../../../tests/helpers/msw/server'
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
@@ -119,3 +119,366 @@ describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => {
expect(screen.queryByText(/hosts you add/i)).not.toBeInTheDocument()
})
})
/**
* #1523. The row's ⋯ menu used to be an in-flow `absolute` div, and PageSidebar — the
* panel's ancestor — is `overflow-hidden`. On the lower rows of a long plugin list the
* menu was clipped mid-way, taking Delete with it: the plugin became uninstallable from
* the UI. It must escape every overflow ancestor, and flip up when the bottom is tight.
*/
describe('AdminPluginsPanel — row ⋯ menu is never clipped (#1523)', () => {
const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: false })
const realRect = HTMLButtonElement.prototype.getBoundingClientRect
afterEach(() => { HTMLButtonElement.prototype.getBoundingClientRect = realRect })
/** Put the ⋯ button wherever we want in an 800px-tall viewport. */
function stubTriggerAt(top: number) {
window.innerHeight = 800
window.innerWidth = 1200
HTMLButtonElement.prototype.getBoundingClientRect = function () {
return { top, bottom: top + 34, left: 1100, right: 1134, width: 34, height: 34, x: 1100, y: top, toJSON: () => ({}) } as DOMRect
}
}
async function openRowMenu() {
mockList(withRepo)
const { container } = render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
return { container, menu: screen.getByTestId('plugin-row-menu-trek-gotify') }
}
it('FE-COMP-PLUGINS-MENU-001: renders every action, including Delete', async () => {
stubTriggerAt(100)
await openRowMenu()
for (const label of [/restart/i, /error log/i, /allowed hosts/i, /source repository/i, /report an issue/i, /delete/i]) {
expect(screen.getByText(label)).toBeInTheDocument()
}
})
it('FE-COMP-PLUGINS-MENU-002: is portaled out of the panel, so no overflow ancestor can clip it', async () => {
stubTriggerAt(100)
const { container, menu } = await openRowMenu()
// THE regression guard: living inside the panel is exactly what got it clipped.
expect(container.contains(menu)).toBe(false)
expect(menu.parentElement).toBe(document.body)
expect(menu.style.position).toBe('fixed')
})
it('FE-COMP-PLUGINS-MENU-003: hangs below the ⋯ when there is room', async () => {
stubTriggerAt(100)
const { menu } = await openRowMenu()
expect(menu.style.top).toBe('138px') // trigger bottom (134) + 4
expect(menu.style.bottom).toBe('')
expect(menu.style.right).toBe('66px') // viewport (1200) - trigger right (1134)
})
it('FE-COMP-PLUGINS-MENU-004: flips upward for a row near the bottom — the #1523 case', async () => {
stubTriggerAt(700) // 66px of room below: the six-item menu would run off-screen
const { menu } = await openRowMenu()
expect(menu.style.bottom).toBe('104px') // viewport (800) - trigger top (700) + 4
expect(menu.style.top).toBe('')
})
})
/**
* Signature status (#plugins). TREK has always verified author signatures and TOFU-pinned
* the key — and never showed any of it, so a successfully-installed UNSIGNED plugin looked
* identical to a signed one, forever.
*
* The two tests that matter most here are the ones guarding the override: a re-trust is
* offered for a ROTATED key (benign explanation) and for NOTHING else. A signature that
* doesn't verify means the bytes are not what the author signed, and there is no story
* where the right answer is letting the admin wave it through.
*/
function registryEntry(over: Record<string, unknown> = {}) {
return {
id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify',
type: 'integration', latest: '2.0.0', minTrekVersion: null, reviewedAt: null,
screenshotUrl: null, signed: true, authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
...over,
}
}
function mockPanel(p: Record<string, unknown>, entry: Record<string, unknown> | null = registryEntry()) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [p] })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : [])),
)
}
describe('AdminPluginsPanel — signature badges', () => {
it('FE-COMP-PLUGINS-SIG-001: a registry plugin with a pinned key reads as Signed', async () => {
mockPanel(plugin({ source_repo: 'acme/gotify', signed: true, keyFingerprint: 'AAAAAAAA…BBBBBBBB' }))
render(<AdminPluginsPanel />)
expect(await screen.findByText('Signed')).toBeInTheDocument()
expect(screen.queryByText('Unsigned')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-SIG-002: a registry plugin with no key reads as Unsigned', async () => {
mockPanel(plugin({ source_repo: 'acme/gotify', signed: false, keyFingerprint: null }))
render(<AdminPluginsPanel />)
expect(await screen.findByText('Unsigned')).toBeInTheDocument()
})
// The precedence rule. `signed` derives from the pinned key, sideloaded from source_repo
// — so they are NOT mutually exclusive in the data, and a sideloaded plugin genuinely has
// no key. Rendering "Unsigned" NEXT TO "Sideloaded" would double up on a plugin whose
// badge already says something strictly stronger, diluting the amber into wallpaper.
it('FE-COMP-PLUGINS-SIG-003: a sideloaded plugin shows Sideloaded and NO trust badge', async () => {
mockPanel(plugin({ source_repo: 'local:upload', signed: false }))
render(<AdminPluginsPanel />)
expect(await screen.findByText('Sideloaded')).toBeInTheDocument()
expect(screen.queryByText('Unsigned')).not.toBeInTheDocument()
expect(screen.queryByText('Signed')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-SIG-004: a dev-linked plugin shows Dev-Link and NO trust badge', async () => {
mockPanel(plugin({ source_repo: 'local:link', signed: false }))
render(<AdminPluginsPanel />)
expect(await screen.findByText('Dev-Link')).toBeInTheDocument()
expect(screen.queryByText('Unsigned')).not.toBeInTheDocument()
})
})
describe('AdminPluginsPanel — a refused update', () => {
const blocked = (code: string) =>
plugin({
source_repo: 'acme/gotify', signed: true, keyFingerprint: 'OLDKEYaa…aaaaaaaa',
updateBlock: { code, detail: 'the signing key changed', version: '2.0.0' },
})
it('FE-COMP-PLUGINS-SIG-005: the row keeps showing WHY, instead of the reason dying with a toast', async () => {
mockPanel(blocked('SIGNATURE_KEY_CHANGED'))
render(<AdminPluginsPanel />)
expect(await screen.findByText(/update blocked/i)).toBeInTheDocument()
})
// The block describes the version that was REFUSED. Once the registry offers a newer one,
// it describes an artifact nobody is being offered anymore — so it reads as stale and the
// admin can simply re-attempt.
it('FE-COMP-PLUGINS-SIG-006: the block goes quiet once a NEWER version is on offer', async () => {
mockPanel(blocked('SIGNATURE_KEY_CHANGED'), registryEntry({ latest: '3.0.0' }))
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
await waitFor(() => expect(screen.queryByText(/update blocked/i)).not.toBeInTheDocument())
})
it('FE-COMP-PLUGINS-SIG-007: Review opens the re-trust dialog for a ROTATED key', async () => {
mockPanel(blocked('SIGNATURE_KEY_CHANGED'))
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
// Both fingerprints, so the admin can compare them against what the author tells them.
expect(await screen.findByText(/key it was installed with/i)).toBeInTheDocument()
expect(screen.getByText(/key it is offering now/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument()
})
// D2, at the UI. An invalid signature means the bytes are not what the author signed.
// There is no override — not a disabled button, not one behind a confirm. The ABSENCE of
// an escape hatch is the feature. (The server refuses it too; this is belt and braces.)
it('FE-COMP-PLUGINS-SIG-008: an INVALID signature offers NO re-trust affordance at all', async () => {
mockPanel(blocked('SIGNATURE_INVALID'))
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
await screen.findByText(/do not match the author's signature/i)
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
// ...and it does not even show the key comparison, which would imply a choice exists.
expect(screen.queryByText(/key it is offering now/i)).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-SIG-009: an unsigned-downgrade refusal offers no override either', async () => {
mockPanel(blocked('SIGNATURE_MISSING'))
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
await screen.findByText(/ships no signature/i)
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-SIG-010: confirming a re-trust re-pins AND updates in ONE call', async () => {
let body: unknown = null
mockPanel(blocked('SIGNATURE_KEY_CHANGED'))
server.use(
http.post('*/api/admin/plugins/trek-gotify/retrust', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
}),
)
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i }))
// The FULL key goes back, not the fingerprint: the server's equality check is exact, so
// it can refuse if the entry was re-keyed again since this dialog rendered.
await waitFor(() =>
expect(body).toEqual({ version: '2.0.0', publicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }),
)
// No follow-up /update: a re-pin that waited for a second call would leave the plugin
// pinned to a key no install had ever verified against if that call never came.
})
})
describe('AdminPluginsPanel — update consent', () => {
it('FE-COMP-PLUGINS-SIG-011: says an unsigned update is untied to its author, and still activates in one click', async () => {
let activated = false
mockPanel(plugin({ source_repo: 'acme/gotify', signed: false }), registryEntry({ signed: false }))
server.use(
http.post('*/api/admin/plugins/trek-gotify/update', () =>
HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] }),
),
http.post('*/api/admin/plugins/trek-gotify/activate', () => { activated = true; return HttpResponse.json({ status: 'active' }) }),
)
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
fireEvent.click(await screen.findByRole('button', { name: /update to|2\.0\.0/i }))
// Informs — it does not block. No checkbox, no second click.
expect(await screen.findByText(/nothing ties this version to its author/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /approve & turn on/i }))
await waitFor(() => expect(activated).toBe(true))
})
// The warning used to be read ONLY off the registry entry, so an unreachable registry left
// it undefined and the pill silently vanished — at the exact moment the admin was widening
// what unsigned code may do. The installed row carries an authoritative `signed` from the
// server on every list call; degrade to that rather than to silence.
//
// Consent is reached here by turning a plugin back ON after an update widened its
// permissions (409 CONSENT_REQUIRED) — which is the path that still works with the registry
// down, precisely because it needs nothing from the registry.
it('FE-COMP-PLUGINS-SIG-015: the unsigned warning survives an unreachable registry', async () => {
server.use(
http.get('*/api/admin/plugins', () =>
HttpResponse.json({
enabled: true, devLink: false,
plugins: [plugin({ source_repo: 'acme/gotify', signed: false, enabled: 0, status: 'inactive', operatorEgress: false })],
})),
// The registry is down: `regById` stays empty, so the entry's `signed` is unknowable.
http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ error: 'registry unreachable' }, { status: 500 })),
http.post('*/api/admin/plugins/trek-gotify/activate', () =>
HttpResponse.json({ error: 'consent required', code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips'], newEgress: [] }, { status: 409 })),
)
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /enable plugin/i }))
// Falls back to the installed row's `signed: false` rather than going quiet.
expect(await screen.findByText(/nothing ties this version to its author/i)).toBeInTheDocument()
})
})
/**
* A signature refusal must reach the dialog even when the plugin has NO installed row —
* which is every fresh install from Discover, and every dependency being downloaded.
*
* Routing the refusal off the installed list meant those two paths silently fell back to a
* generic toast: the admin met SIGNATURE_INVALID for the first time on the one path where the
* dialog explaining it never opened. A fresh install has no pinned key, so it can only ever
* be _INVALID / _INCOMPLETE — never a rotation — and both are non-overridable, so the dialog
* must explain and offer nothing.
*/
describe('AdminPluginsPanel — a refusal with no installed row', () => {
it('FE-COMP-PLUGINS-SIG-013: a fresh install refused for an INVALID signature opens the dialog, not a toast', async () => {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([registryEntry()])),
http.post('*/api/admin/plugins/install', () =>
HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })),
)
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /discover/i }))
fireEvent.click(await screen.findByRole('button', { name: /^install$/i }))
// The dialog, named after the plugin — which it can only know from the REGISTRY entry,
// there being no installed row to read a name off.
expect(await screen.findByText(/gotify's signature could not be verified/i)).toBeInTheDocument()
await screen.findByText(/do not match the author's signature/i)
// Non-overridable, and no key comparison — showing one would imply a choice exists.
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
expect(screen.queryByText(/key it is offering now/i)).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-SIG-014: a refusal while downloading a DEPENDENCY opens the dialog too', async () => {
const parent = plugin({ id: 'trek-parent', name: 'Parent', source_repo: 'acme/parent', enabled: 0, status: 'inactive', operatorEgress: false })
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [parent] })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([registryEntry()])),
// Turning it on reveals the missing dependency…
http.post('*/api/admin/plugins/trek-parent/activate', () =>
HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-gotify', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })),
// …and downloading it is refused on its signature.
http.post('*/api/admin/plugins/install', () =>
HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })),
)
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /enable plugin/i }))
fireEvent.click(await screen.findByRole('button', { name: /download/i }))
// Named after the DEPENDENCY, not the parent — it is the dependency's author whose
// signature did not verify, and saying "Parent" here would point the admin at the wrong
// plugin entirely.
expect(await screen.findByText(/gotify's signature could not be verified/i)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
})
})
describe('AdminPluginsPanel — a block never outlives the registry relationship', () => {
// The server clears the block on sideload/dev-link. This is the belt: even if a stale
// block somehow reached the client, a plugin whose code the admin supplied by hand must
// never claim an update was blocked over an author signing key.
it('FE-COMP-PLUGINS-SIG-012: a sideloaded plugin never shows an update block', async () => {
mockPanel(plugin({
source_repo: 'local:upload', signed: false,
updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'the signing key changed', version: '2.0.0' },
}))
render(<AdminPluginsPanel />)
await screen.findByText('Sideloaded')
expect(screen.queryByText(/update blocked/i)).not.toBeInTheDocument()
})
})
/**
* TREK-version compatibility. The SERVER owns the semver — a second implementation in the
* browser would eventually disagree with the install gate and offer a button that 400s —
* so the panel only renders the verdict the API hands it (`compatible`, `latestCompatible`).
*/
describe('AdminPluginsPanel — TREK-version compatibility', () => {
/** Discover cards for a plugin that is NOT installed — an installed one just reads "Installed". */
async function openDiscover(entry: Record<string, unknown>) {
mockPanel(plugin({ id: 'something-else' }), registryEntry(entry))
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByText('Discover'))
}
it('blocks Install when no published version runs on this TREK, and says why', 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()
})
it('offers the newest version that DOES run here rather than a dead button', async () => {
await openDiscover({ latest: '2.0.0', trek: '>=3.4.0', hostVersion: '3.3.0', compatible: false, latestCompatible: '1.5.0' })
const btn = await screen.findByRole('button', { name: /^install 1\.5\.0$/i })
expect(btn).toBeEnabled()
})
it('installs normally when the latest version fits', async () => {
await openDiscover({ trek: '>=3.2.0 <4.0.0', hostVersion: '3.3.0', compatible: true, latestCompatible: '2.0.0' })
expect(await screen.findByRole('button', { name: /^install$/i })).toBeEnabled()
})
it('an installed plugin the server has outgrown shows the blocker on its card', async () => {
// Same amber chip machinery as a disabled addon / missing dependency — the admin sees
// one "here is why this cannot turn on" surface, not a new concept per blocker.
mockPanel(plugin({
dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0', enabled: 0, status: 'inactive',
}))
render(<AdminPluginsPanel />)
expect(await screen.findByText(/needs trek >=3\.2\.0 <4\.0\.0/i)).toBeInTheDocument()
})
})
+437 -35
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react'
import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent } from 'react'
import { createPortal } from 'react-dom'
import {
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass,
BookOpen, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText,
@@ -24,7 +25,7 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
interface PluginDep { id: string; version: string }
interface VersionMismatch { id: string; wanted: string; installed: string }
type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin'
type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible'
interface PluginDependencies { requiredAddons: string[]; pluginDependencies: PluginDep[] }
interface DependencyIssues { disabledAddons: string[]; missing: PluginDep[]; versionMismatch: VersionMismatch[] }
@@ -49,6 +50,17 @@ interface PluginRow {
dependencies?: PluginDependencies
dependencyStatus?: DependencyStatus
dependencyIssues?: DependencyIssues
/** The TREK versions the plugin says it supports; null if it never declared any. */
trekRange?: string | null
/** The TREK this server is running — the server does the semver, the client just shows it. */
hostVersion?: string
/** The author's signature was verified and their key pinned at install. False means the
* bytes matched the registry's sha256 and nothing more — one fewer guarantee. */
signed?: boolean
/** Short form of the pinned key, for eyeballing against what the author reads out. */
keyFingerprint?: string | null
/** Why an update was refused, if one was. `version` is the version that was refused. */
updateBlock?: { code: string; detail: string | null; version: string | null } | null
}
interface RegistryItem {
id: string
@@ -60,11 +72,22 @@ interface RegistryItem {
type: string
latest: string | null
minTrekVersion: string | null
/** The latest version's declared TREK range; null on a legacy registry entry. */
trek?: string | null
hostVersion?: string
/** Whether the LATEST version can be installed on this TREK. Computed server-side. */
compatible?: boolean
/** Newest installable version — the latest, an older fallback, or null if none fits. */
latestCompatible?: string | null
reviewedAt: string | null
downloadCount?: number | null
screenshotUrl: string | null
requiredAddons?: string[]
pluginDependencies?: PluginDep[]
/** The latest version ships an author signature and the entry declares a key. */
signed?: boolean
/** The full key — public, and carried in full because re-trust compares it exactly. */
authorPublicKey?: string | null
}
interface RegistryDetail extends RegistryItem {
size: number | null
@@ -90,6 +113,49 @@ interface ActivateErr {
}
}
/** The server's error envelope. Reading `code` — not the message text — is what lets the
* UI tell a rotated key (overridable) from a signature that doesn't verify (never). */
function errBody(e: unknown): { error?: string; code?: string } {
return (e as { response?: { data?: { error?: string; code?: string } } })?.response?.data ?? {}
}
/** The ONE signature refusal an admin may override, because a rotation has a benign
* explanation. SIGNATURE_INVALID / _MISSING / _INCOMPLETE mean the bytes are not what the
* author signed — those get an explanation and no override button at all. */
const RETRUSTABLE = 'SIGNATURE_KEY_CHANGED'
const SIGNATURE_CODES = [RETRUSTABLE, 'SIGNATURE_INVALID', 'SIGNATURE_MISSING', 'SIGNATURE_INCOMPLETE']
/**
* What the signature dialog needs to talk about a plugin — deliberately NOT a PluginRow.
*
* A refusal can land on a plugin that is not installed (a fresh install from Discover, or a
* dependency being downloaded), and those have no row. Keying the dialog off PluginRow meant
* the lookup missed and the refusal silently degraded to a toast — on the very path where an
* admin most often meets these codes for the first time. Name + pinned fingerprint is all the
* dialog ever reads, and both a row and a registry entry can supply that.
*/
type SigSubject = { id: string; name: string; keyFingerprint: string | null }
/**
* Is a recorded update block still describing the version on offer?
*
* Once the registry offers something NEWER than the version that was refused, the block
* describes an artifact nobody is being offered anymore — so it reads as stale and the
* admin can simply re-attempt (the next install re-verifies and either succeeds or
* re-blocks with fresh values). When the registry is unreachable we can't prove staleness,
* so the block stands: silently dropping the last thing we knew would be the worse failure.
*/
function blockIsCurrent(p: PluginRow, latestVer?: string): boolean {
if (!p.updateBlock) return false
// A block describes a refused REGISTRY update. Once a plugin is sideloaded or dev-linked
// it has left the registry trust model — the running code is whatever the admin supplied,
// and a block about an author signing key says nothing about it. The server clears the
// block on both paths; this makes it impossible to render a stale one regardless.
if (!isRegistrySourced(p.source_repo)) return false
if (!latestVer || !p.updateBlock.version) return true
return latestVer === p.updateBlock.version
}
type T = (k: string, p?: Record<string, unknown>) => string
type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page'
type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err'
@@ -186,10 +252,21 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean }
// A plugin's declared dependencies as chips — a required addon (amber when that
// addon is disabled) or a plugin dependency (amber when missing / version-mismatched).
// addon is disabled), a plugin dependency (amber when missing / version-mismatched),
// or the TREK version itself (amber when this server has outgrown the plugin's range,
// which is the one blocker the admin cannot fix by flipping something else on).
function deriveDeps(p: PluginRow, t: T): DepChip[] {
const out: DepChip[] = []
const issues = p.dependencyIssues
if (p.dependencyStatus === 'hostIncompatible') {
out.push({
icon: AlertTriangle,
label: p.trekRange
? t('admin.plugins.dep.trekIncompatible', { range: p.trekRange, host: p.hostVersion ?? '?' })
: t('admin.plugins.dep.trekUnknown'),
blocked: true,
})
}
for (const a of p.dependencies?.requiredAddons ?? []) {
out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
}
@@ -200,6 +277,26 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
return out
}
/**
* What the Install button may do for a registry item.
*
* The server already decided compatibility — it owns the semver, and a second
* implementation here would eventually disagree with the install gate and offer a button
* that 400s. This only picks the wording. Note the middle case: when the newest release
* has outrun this TREK but an older one still fits, offer THAT version rather than a dead
* grey button. The plugin is perfectly usable, just not at its newest.
*/
function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: string; label: string; title?: string } {
if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') }
const title = item.trek
? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' })
: t('admin.plugins.dep.trekUnknown')
if (item.latestCompatible) {
return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title }
}
return { blocked: true, label: t('admin.plugins.incompatible'), title }
}
function PluginIcon({ name, size = 20, className }: { name: string | null; size?: number; className?: string }) {
const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks
return <Icon size={size} className={className} />
@@ -224,6 +321,46 @@ function SideloadedBadge({ t }: { t: T }) {
)
}
/**
* Whether a plugin came from the REGISTRY (as opposed to a manual upload or a dev-link).
*
* This is the precedence rule for the trust badges, and it is load-bearing: `signed`
* derives from the pinned author key while sideloaded/dev-linked derive from source_repo,
* so the states are NOT mutually exclusive in the data. A sideloaded plugin genuinely has
* no pinned key, and a naive render would put "Unsigned" *and* "Sideloaded" side by side.
* The source badge wins — it already says something strictly stronger, and doubling up
* dilutes the amber into wallpaper, which is exactly what makes a warning worthless.
*/
function isRegistrySourced(sourceRepo: string | null | undefined): boolean {
return !!sourceRepo && sourceRepo !== 'local:upload' && sourceRepo !== 'local:link'
}
/**
* Signed / Unsigned, for registry plugins only (see isRegistrySourced).
*
* Signed is a quiet neutral tick, NOT a green celebration; unsigned is an amber note, NOT
* a red alarm. Roughly two thirds of the live registry is unsigned, so an alarming
* treatment would fire on most of the catalog and teach admins to ignore it — and the
* honest delta is small: sha256 proves the bytes are what the REGISTRY vouches for, a
* signature proves they came from the AUTHOR. Unsigned is one fewer guarantee, not "unsafe".
*/
function TrustBadge({ signed, t }: { signed: boolean; t: T }) {
if (signed) {
return (
<span className="inline-flex items-center gap-1 px-1.5 py-[2px] rounded-md text-[11px] font-medium bg-surface-tertiary text-content-muted border border-edge-secondary"
title={t('admin.plugins.signedHint')}>
<KeyRound size={11} /> {t('admin.plugins.signed')}
</span>
)
}
return (
<span className="inline-flex items-center gap-1 px-1.5 py-[2px] rounded-md text-[11px] font-medium bg-warning-soft text-warning border border-warning/25"
title={t('admin.plugins.unsignedHint')}>
<ShieldAlert size={11} /> {t('admin.plugins.unsigned')}
</span>
)
}
/** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */
function DevLinkBadge({ t }: { t: T }) {
return (
@@ -255,6 +392,13 @@ export default function AdminPluginsPanel() {
const [view, setView] = useState<'installed' | 'discover'>('installed')
const [registry, setRegistry] = useState<RegistryItem[] | null>(null)
const [latest, setLatest] = useState<Record<string, string>>({})
// The registry entry per id — the re-trust dialog needs the NEW author key from it (in
// full: the server's equality check is exact), and the Discover/consent copy needs `signed`.
const [regById, setRegById] = useState<Record<string, RegistryItem>>({})
// Open when a signature check refused an install/update. Carries the code, so the dialog
// knows whether an override may even be offered.
const [signatureBlock, setSignatureBlock] = useState<{ subject: SigSubject; code: string; detail: string | null } | null>(null)
const [retrusting, setRetrusting] = useState(false)
const [detailFor, setDetailFor] = useState<RegistryItem | null>(null)
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null)
@@ -288,6 +432,16 @@ export default function AdminPluginsPanel() {
const uploadInputRef = useRef<HTMLInputElement>(null)
const dragDepth = useRef(0)
// Index the registry once per fetch: the version map the update badges read, plus the
// whole entry (author key + signed flag) the trust badges and re-trust dialog need.
const indexRegistry = (items: RegistryItem[]) => {
const vers: Record<string, string> = {}
const byId: Record<string, RegistryItem> = {}
items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest })
setLatest(vers)
setRegById(byId)
}
const refresh = () => {
// Keep the app-wide active-plugin store in sync so widget/hero/tab consumers
// (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5).
@@ -298,13 +452,7 @@ export default function AdminPluginsPanel() {
setDevLink(!!d.devLink)
setPlugins(d.plugins || [])
if ((d.plugins || []).length) {
adminApi.pluginBrowse()
.then((items: RegistryItem[]) => {
const map: Record<string, string> = {}
items.forEach((i) => { if (i.latest) map[i.id] = i.latest })
setLatest(map)
})
.catch(() => {})
adminApi.pluginBrowse().then(indexRegistry).catch(() => {})
}
})
.catch(() => setError(true))
@@ -312,16 +460,43 @@ export default function AdminPluginsPanel() {
}
useEffect(refresh, [])
// A signature refusal is not an ordinary "action failed": it is the one class of error
// where WHICH failure it was decides what the admin may do about it. Route it to the
// dialog off the CODE, never off the message text. Returns true when handled.
//
// The subject is resolved from wherever it can be — the installed row, else the registry
// entry (a plugin being INSTALLED has no row yet), else the bare id. A signature code
// ALWAYS opens the dialog: falling back to a toast is what used to hide an invalid
// signature on a fresh install behind the same treatment as a network blip.
const routeSignatureError = (id: string, code?: string, detail?: string): boolean => {
if (!code || !SIGNATURE_CODES.includes(code)) return false
const row = plugins.find(p => p.id === id)
const subject: SigSubject = row
? { id, name: row.name, keyFingerprint: row.keyFingerprint ?? null }
// Not installed: no pinned key exists, so SIGNATURE_KEY_CHANGED cannot arise and the
// dialog shows the no-override explanation — which is exactly right.
: { id, name: regById[id]?.name ?? id, keyFingerprint: null }
setSignatureBlock({ subject, code, detail: detail ?? null })
return true
}
const act = async (id: string, fn: () => Promise<unknown>, ok: string) => {
setBusy(id); setMenu(null)
try { await fn(); toast.success(ok) }
catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) }
catch (e) {
const { error, code } = errBody(e)
if (!routeSignatureError(id, code, error)) toast.error(error || t('admin.plugins.actionError'))
}
finally { setBusy(null); refresh() }
}
const openDiscover = () => {
setView('discover')
if (!registry) adminApi.pluginBrowse().then(setRegistry).catch(() => setRegistry([]))
if (!registry) {
adminApi.pluginBrowse()
.then((items: RegistryItem[]) => { setRegistry(items); indexRegistry(items) })
.catch(() => setRegistry([]))
}
}
// The rescan/reload button rediscovers locally-installed plugins AND force-pulls
// the remote registry (bypassing the 30-min server cache + GitHub's CDN), so a
@@ -330,9 +505,7 @@ export default function AdminPluginsPanel() {
await adminApi.pluginRescan()
const items: RegistryItem[] = await adminApi.pluginBrowse(true)
setRegistry(items)
const map: Record<string, string> = {}
items.forEach((i) => { if (i.latest) map[i.id] = i.latest })
setLatest(map)
indexRegistry(items)
}, t('admin.plugins.rescanned'))
// Sideload a plugin archive (installs INACTIVE — the admin still consents on activation).
@@ -393,7 +566,7 @@ export default function AdminPluginsPanel() {
}
const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version))
const install = (id: string) => act(id, () => adminApi.pluginInstall(id), t('admin.plugins.installed'))
const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
// same busy/toast/refresh loop as uploadPlugin; the server gates it.
@@ -470,7 +643,13 @@ export default function AdminPluginsPanel() {
if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
return attemptActivate(parent)
})
.catch((e: ActivateErr) => onActivateError(parent, e))
// The DEPENDENCY is what's being downloaded, so a signature refusal here is about the
// dependency's author, not the parent's — route it under depId before falling through
// to the activation-error handling, which knows nothing about signature codes.
.catch((e: ActivateErr) => {
const { error, code } = errBody(e)
if (!routeSignatureError(depId, code, error)) onActivateError(parent, e)
})
.finally(() => { setBusy(null); refresh() })
}
@@ -481,10 +660,33 @@ export default function AdminPluginsPanel() {
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
})
.catch(e => toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')))
.catch(e => {
const { error, code } = errBody(e)
if (!routeSignatureError(p.id, code, error)) toast.error(error || t('admin.plugins.actionError'))
})
.finally(() => { setBusy(null); refresh() })
}
// Confirm a key rotation: re-pin the new key AND update, in one server call. There is no
// follow-up /update — a re-pin that waited for a second call would leave the plugin
// pinned to a key no install had ever been verified against if that call never came.
const confirmRetrust = (id: string, version: string, publicKey: string) => {
setRetrusting(true)
adminApi.pluginRetrust(id, version, publicKey)
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
setSignatureBlock(null)
// A re-trusted update widening permissions still needs consent — re-trusting a
// signing key says nothing about what the new code is allowed to do. Re-trust only
// ever fires for an INSTALLED plugin, so the row is there to consent against.
const p = plugins.find(x => x.id === id)
if (p && !r.activated && (r.newPermissions.length > 0 || r.newEgress.length > 0)) {
setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
} else toast.success(t('admin.plugins.retrusted'))
})
.catch(e => toast.error(errBody(e).error || t('admin.plugins.actionError')))
.finally(() => { setRetrusting(false); refresh() })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
@@ -691,8 +893,13 @@ export default function AdminPluginsPanel() {
) : shownInstalled.map(p => (
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
blocked={blockIsCurrent(p, latest[p.id])}
onToggle={() => toggle(p)}
onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)}
onReviewBlock={() => setSignatureBlock({
subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null },
code: p.updateBlock!.code, detail: p.updateBlock!.detail,
})}
onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)}
onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} />
))}
@@ -791,9 +998,26 @@ export default function AdminPluginsPanel() {
message={t('admin.plugins.uninstallBody')}
/>
{signatureBlock && (
<SignatureBlockDialog
data={signatureBlock} entry={regById[signatureBlock.subject.id]} busy={retrusting} t={t}
onRetrust={(version, publicKey) => confirmRetrust(signatureBlock.subject.id, version, publicKey)}
onClose={() => setSignatureBlock(null)}
/>
)}
{consentQueue[0] && (
<UpdateConsentDialog
data={consentQueue[0]} t={t}
// Prefer the REGISTRY's flag — consent is about the code being installed, not the
// code running now — but fall back to the installed row's, which the server always
// sends. Reading only the registry meant an unreachable registry left `regById`
// empty and the warning silently vanished at the exact moment an admin was widening
// what unsigned code may do.
unsigned={
isRegistrySourced(consentQueue[0].plugin.source_repo) &&
(regById[consentQueue[0].plugin.id]?.signed ?? consentQueue[0].plugin.signed) === false
}
onApprove={async () => {
const c = consentQueue[0]; setConsentQueue(qq => qq.slice(1))
// consent:true — the ONLY path that may widen a plugin's granted rights.
@@ -886,14 +1110,33 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
)
}
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggle, onUpdate, onRestart, onErrors, onEgress, onUninstall }: {
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked, onToggle, onUpdate, onRestart, onReviewBlock, onErrors, onEgress, onUninstall }: {
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
hasUpdate: boolean; latestVer?: string
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onEgress: () => void; onUninstall: () => void
hasUpdate: boolean; latestVer?: string; blocked: boolean
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onReviewBlock: () => void
onErrors: () => void; onEgress: () => 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)
// 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.
const [, reanchor] = useState(0)
useEffect(() => {
if (!menuOpen) 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)
}
}, [menuOpen])
return (
<div className="group relative flex items-center gap-3 sm:gap-4 px-2.5 sm:px-3 py-3.5 rounded-2xl hover:bg-surface-secondary transition-colors">
<div className="relative shrink-0">
@@ -911,8 +1154,24 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
{p.reviewed_at && <ReviewedBadge t={t} compact />}
{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="text-[12.5px] text-content-muted mt-0.5 truncate">{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="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-warning">
<ShieldAlert size={13} className="shrink-0" />
<span className="truncate">{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}</span>
<button onClick={onReviewBlock}
className="shrink-0 font-semibold underline underline-offset-2 hover:opacity-80 transition-opacity">
{t('admin.plugins.reviewBlock')}
</button>
</div>
)}
{p.status === 'error' && p.last_error ? (
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-danger">
<AlertTriangle size={13} className="shrink-0" /><span className="truncate">{p.last_error}</span>
@@ -970,12 +1229,13 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
</span>
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
<div className="relative">
<button onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
<button ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
className="w-[34px] h-[34px] grid place-items-center rounded-lg text-content-faint hover:text-content hover:bg-surface-tertiary transition-colors">
<MoreHorizontal size={17} />
</button>
{menuOpen && (
<div className="absolute top-10 right-0 z-30 min-w-[180px] p-1.5 rounded-xl border border-edge bg-surface-card shadow-elevated">
{menuOpen && createPortal(
<div data-testid={`plugin-row-menu-${p.id}`} style={anchorMenu(menuBtnRef.current)}
className="min-w-[180px] p-1.5 rounded-xl border border-edge bg-surface-card shadow-elevated">
{p.enabled === 1 && (
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
)}
@@ -995,7 +1255,8 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
)}
<div className="my-1 border-t border-edge-secondary" />
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={onUninstall} />
</div>
</div>,
document.body,
)}
</div>
</div>
@@ -1003,6 +1264,23 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
)
}
/** 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,
}
}
function MenuItem({ icon, label, onClick, danger }: { icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) {
return (
<button onClick={onClick}
@@ -1030,7 +1308,7 @@ function Screenshot({ url, className, iconSize = 28 }: { url: string | null; cla
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: {
items: RegistryItem[] | null
onInstall: (id: string) => void
onInstall: (id: string, version?: string) => void
onOpenDetail: (item: RegistryItem) => void
busy: string | null
t: T
@@ -1048,6 +1326,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5 sm:gap-4 px-4 sm:px-6 pb-5 pt-1">
{items.map(item => {
const installed = installedIds.has(item.id)
const offer = installOffer(item, t)
return (
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }}
@@ -1068,17 +1347,21 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<span className="text-sm font-semibold tracking-[-.006em] text-content truncate">{item.name}</span>
<span className="text-[11.5px] text-content-faint mt-0.5">{item.author}</span>
<p className="text-xs text-content-muted mt-2 line-clamp-2 flex-1">{item.description}</p>
<div className="flex items-center gap-2 mt-3">
<div className="flex items-center gap-2 mt-3 flex-wrap">
<TypeBadge type={item.type} t={t} />
{/* Everything in Discover is registry-sourced, so the badge always applies. */}
<TrustBadge signed={!!item.signed} t={t} />
{item.latest && <span className="text-[10.5px] text-content-faint tabular-nums">v{item.latest}</span>}
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
<span className="inline-flex items-center gap-1 text-[10.5px] text-content-faint tabular-nums" title={t('admin.plugins.downloads')}>
<Download size={11} /> {formatCompactCount(item.downloadCount)}
</span>
)}
<button onClick={e => { e.stopPropagation(); onInstall(item.id) }} disabled={busy === item.id || installed}
<button onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
disabled={busy === item.id || installed || offer.blocked}
title={installed ? undefined : offer.title}
className="ml-auto text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors">
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
{installed ? t('admin.plugins.installed') : offer.label}
</button>
</div>
</div>
@@ -1106,7 +1389,7 @@ function PermLabel({ perm, t }: { perm: string; t: T }) {
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: {
item: RegistryItem; installed: boolean; busy: string | null
onInstall: (id: string) => void; onClose: () => void; t: T; locale: string
onInstall: (id: string, version?: string) => void; onClose: () => void; t: T; locale: string
}) {
const [detail, setDetail] = useState<RegistryDetail | null>(null)
const [failed, setFailed] = useState(false)
@@ -1124,6 +1407,9 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
const repoUrl = `https://github.com/${item.repo}`
const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
// The detail fetch carries the same compat verdict as the browse list; prefer it once
// it lands (it is keyed to the same entry) and fall back to the grid item until then.
const offer = installOffer(detail ?? item, t)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
@@ -1145,15 +1431,24 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
</div>
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
</div>
<button onClick={() => onInstall(item.id)} disabled={busy === item.id || installed}
<button onClick={() => onInstall(item.id, offer.version)}
disabled={busy === item.id || installed || offer.blocked}
title={installed ? undefined : offer.title}
className="self-end text-[13px] font-semibold px-3 sm:px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors shrink-0">
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
{installed ? t('admin.plugins.installed') : offer.label}
</button>
</div>
<div className="px-4 sm:px-5 pt-4 pb-5">
<p className="text-[13.5px] text-content-secondary leading-relaxed">{item.description}</p>
{failed && <p className="text-xs text-danger mt-3">{t('admin.plugins.detailError')}</p>}
{/* The reason the button is blocked (or offering an older version) — a tooltip
alone would leave a touch user with a dead button and no explanation. */}
{offer.title && !installed && (
<p className="flex items-start gap-1.5 text-xs text-warning bg-warning-soft border border-warning/25 rounded-lg px-2.5 py-2 mt-3">
<AlertTriangle size={13} className="shrink-0 mt-[1px]" /> {offer.title}
</p>
)}
{manifest && (
<div className="mt-5">
@@ -1220,7 +1515,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mt-2.5">
{item.latest && <Meta k={t('admin.plugins.metaVersion')} v={`v${item.latest}`} />}
{sizeKb && <Meta k={t('admin.plugins.metaSize')} v={`${sizeKb} KB`} />}
{item.minTrekVersion && <Meta k={t('admin.plugins.metaRequires')} v={`TREK ${item.minTrekVersion}+`} />}
{/* The range, not just its lower bound: "TREK 3.2.0+" reads as "and anything
newer", which is exactly the claim a `<4.0.0` upper bound denies. */}
{(item.trek || item.minTrekVersion) && (
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
)}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
@@ -1254,8 +1553,101 @@ function Meta({ k, v }: { k: string; v: string }) {
return <div><div className="text-[12px] text-content-faint">{k}</div><div className="text-[12.5px] font-medium text-content mt-0.5">{v}</div></div>
}
function UpdateConsentDialog({ data, t, onApprove, onLater }: {
/**
* A signature check refused an install/update.
*
* The override lives here, and it is scoped to exactly one code. A rotated key has a
* benign explanation — the author rotated it, or lost it and made a new one. A signature
* that does NOT verify does not: it means the bytes are not what the author signed, which
* is corruption or an attack, and there is no story where the right answer is "let the
* admin wave it through". So SIGNATURE_INVALID / _MISSING / _INCOMPLETE get a clear
* explanation and no override button AT ALL — not a disabled one, not one behind a
* confirm. The absence of an escape hatch is the feature.
*
* (The server enforces this too — it re-derives the condition and refuses anything but a
* changed key. This dialog is the convenience, not the control.)
*/
function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
data: { subject: SigSubject; code: string; detail: string | null }
entry: RegistryItem | undefined
busy: boolean; t: T
onRetrust: (version: string, publicKey: string) => void
onClose: () => void
}) {
const canRetrust = data.code === RETRUSTABLE
const newKey = entry?.authorPublicKey ?? null
const version = entry?.latest ?? null
// Only offer the override when we actually hold the new key + version to send: the
// request carries both, and the server compares the key exactly.
const offerRetrust = canRetrust && !!newKey && !!version
const bodyKey = data.code === RETRUSTABLE ? 'admin.plugins.sig.keyChangedBody'
: data.code === 'SIGNATURE_MISSING' ? 'admin.plugins.sig.missingBody'
: data.code === 'SIGNATURE_INCOMPLETE' ? 'admin.plugins.sig.incompleteBody'
: 'admin.plugins.sig.invalidBody'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldAlert size={18} className="text-warning" /></div>
<div>
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.sig.title', { name: data.subject.name })}</h3>
<p className="text-xs text-content-muted mt-1">{t(bodyKey as never)}</p>
</div>
</div>
<div className="p-5 space-y-4">
{/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the
new one back to the author over the phone. The full key travels in the request. */}
{canRetrust && (
<div className="space-y-2">
<KeyRow label={t('admin.plugins.sig.pinnedKey')} value={data.subject.keyFingerprint ?? '—'} />
<KeyRow label={t('admin.plugins.sig.newKey')} value={fingerprint(newKey) ?? '—'} highlight />
<p className="text-[11.5px] text-content-muted leading-relaxed pt-1">{t('admin.plugins.sig.confirmOutOfBand')}</p>
</div>
)}
{!canRetrust && data.detail && (
<p className="text-xs text-content-faint font-mono break-all bg-surface-tertiary rounded-lg px-3 py-2">{data.detail}</p>
)}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
<button onClick={onClose}
className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
{offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')}
</button>
{offerRetrust && (
<button onClick={() => onRetrust(version, newKey)} disabled={busy}
className="text-xs font-semibold px-4 py-2 rounded-lg bg-warning text-white hover:opacity-90 transition-opacity disabled:opacity-50">
{t('admin.plugins.sig.retrustConfirm')}
</button>
)}
</div>
</div>
</div>
)
}
function KeyRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-edge-secondary bg-surface-tertiary px-3 py-2">
<span className="text-[11.5px] text-content-muted shrink-0">{label}</span>
<code className={`font-mono text-[12px] break-all ${highlight ? 'text-warning font-semibold' : 'text-content'}`}>{value}</code>
</div>
)
}
/** Client-side twin of the server's fingerprint: head…tail of the base64 payload. Display
* only — every equality check that matters happens server-side against the full key. */
function fingerprint(key: string | null): string | null {
if (!key) return null
const payload = key.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('untrusted comment')).pop()
if (!payload) return null
return payload.length <= 20 ? payload : `${payload.slice(0, 8)}…${payload.slice(-8)}`
}
function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
data: { plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }
unsigned: boolean
t: T; onApprove: () => void; onLater: () => void
}) {
return (
@@ -1269,6 +1661,15 @@ function UpdateConsentDialog({ data, t, onApprove, onLater }: {
</div>
</div>
<div className="p-5 space-y-4">
{/* The admin is about to widen what this code may do — so say, right here, that
nothing ties this code to its author. One line, no checkbox, no extra click:
this informs, it does not block. */}
{unsigned && (
<p className="flex items-start gap-2 text-xs text-warning bg-warning-soft border border-warning/25 rounded-lg px-3 py-2">
<ShieldAlert size={13} className="mt-0.5 shrink-0" />
<span>{t('admin.plugins.sig.consentUnsigned')}</span>
</p>
)}
{data.newPermissions.length > 0 && (
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.updateNewPermissions')}</h4>
@@ -1358,6 +1759,7 @@ function SecurityInfo({ t }: { t: T }) {
['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 (
@@ -222,6 +222,54 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(posted!.payers).toEqual([])
})
it('keeps "no one paid yet" when reopening a payer-less expense (#1533)', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
let put: Record<string, unknown> | null = null
const item = {
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }),
id: 5,
total_price: 120,
payers: [],
members: [{ user_id: 1, username: 'alice', paid: 0 }, { user_id: 2, username: 'bob', paid: 0 }],
}
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.put('/api/trips/1/budget/5', async ({ request }) => {
put = await request.json() as Record<string, unknown>
return HttpResponse.json({ item })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Hotel')
await user.click(screen.getByTitle('Edit'))
// Nobody paid this expense — reopening it must not silently reselect "You".
expect(await screen.findByRole('button', { name: 'No one paid yet' })).toBeInTheDocument()
// …and saving an untouched edit must not assign the current user as payer.
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toBeTruthy())
expect(put!.payers).toEqual([])
})
it('still defaults a brand-new expense to "You" as the payer', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument()
})
it('exports the expenses as a CSV download (#1500)', async () => {
// Display in the trip's own currency so FX conversion is an identity.
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
+4 -2
View File
@@ -989,10 +989,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [participants, setParticipants] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
// Payer state: 0 represents "Nobody (planning entry)"
// Payer state: 0 represents "Nobody (planning entry)". On an existing expense a
// missing payer is a deliberate choice, so only a brand-new one defaults to me.
const [payerId, setPayerId] = useState<number>(() => {
const existingPayer = (editing?.payers || []).find(p => p.amount > 0)
return existingPayer ? existingPayer.user_id : me
if (existingPayer) return existingPayer.user_id
return editing ? 0 : me
})
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
+12
View File
@@ -170,6 +170,18 @@ describe('downloadTripPDF', () => {
expect(srcdoc).toContain('Colosseum')
})
it('FE-COMP-TRIPPDF-005c: the gap under the day header lives in the repeated thead cell (#1531)', async () => {
await downloadTripPDF(richArgs)
const iframe = getIframe()
const srcdoc = iframe!.srcdoc
// The thead is repeated on every overflow page, so the spacing below the header bar
// must be declared on its cell...
expect(srcdoc).toContain('.day-header > tr > td { padding-bottom: 12px; }')
// ...and not as a block-start padding on .day-body, which the print engine only paints
// on the first fragment of the (fragmented) body cell.
expect(srcdoc).toContain('.day-body { padding: 0 28px 6px; }')
})
it('FE-COMP-TRIPPDF-006: escHtml prevents XSS in trip title', async () => {
const args = {
...minimalArgs,
+5 -1
View File
@@ -495,7 +495,11 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
.day-title { font-size: 13px; font-weight: 600; color: #fff; flex: 1; }
.day-date { font-size: 9px; color: rgba(255,255,255,0.45); }
.day-cost { font-size: 9px; font-weight: 600; color: rgba(255,255,255,0.65); }
.day-body { padding: 12px 28px 6px; }
/* The gap under the header bar must sit inside the repeated <thead> cell: a block-start
padding on .day-body is only painted on the box's first fragment, so overflow pages
would render their first card flush against the bar (#1531). */
.day-header > tr > td { padding-bottom: 12px; }
.day-body { padding: 0 28px 6px; }
/* accommodation info */
.day-accommodations-overview { font-size: 12px; }
@@ -96,9 +96,12 @@ interface DayPlanSidebarProps {
onScrollTopChange?: (top: number) => void
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
showRouteToolsWhenExpanded?: boolean
/** Mobile: drag & drop reorder is disabled (touch-scroll hijack, #1432); the
* grip handle is hidden and the arrow reorder buttons take over instead. */
isMobile?: boolean
/** Coarse primary pointer. Drag & drop reorder is disabled here (it hijacks the
* touch-scroll gesture, #1432); the grip handle is hidden and the arrow reorder
* buttons take over instead. Distinct from isMobile, which is width-based — a
* tablet is a wide touch device and needs both. */
isTouch?: boolean
}
/**
@@ -144,7 +147,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onScrollTopChange,
showRouteToolsWhenExpanded = false,
isMobile = false,
isTouch = false,
} = props
const dragDisabled = isMobile || isTouch
const toast = useToast()
const { t, language, locale } = useTranslation()
const ctxMenu = useContextMenu()
@@ -1027,6 +1032,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onScrollTopChange,
showRouteToolsWhenExpanded,
isMobile,
dragDisabled,
toast,
t,
language,
@@ -1193,6 +1199,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onScrollTopChange,
showRouteToolsWhenExpanded,
isMobile,
dragDisabled,
toast,
t,
language,
@@ -1657,9 +1664,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
return (
<React.Fragment key={`place-${assignment.id}`}>
<div
draggable={canEditDays && !isMobile}
className="dp-row"
draggable={canEditDays && !dragDisabled}
onDragStart={e => {
if (!canEditDays || isMobile) { e.preventDefault(); return }
if (!canEditDays || dragDisabled) { e.preventDefault(); return }
e.dataTransfer.setData('assignmentId', String(assignment.id))
e.dataTransfer.setData('fromDayId', String(day.id))
e.dataTransfer.effectAllowed = 'move'
@@ -1754,7 +1762,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
opacity: isDraggingThis ? 0.4 : 1,
}}
>
{canEditDays && !isMobile && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>}
<div
@@ -2014,9 +2022,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
if (dropTargetRef.current !== key) setDropTargetKey(key)
}}
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile}
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg && !dragDisabled}
onDragStart={e => {
if (!canEditDays || spanPhase === 'middle' || res.__leg || isMobile) { e.preventDefault(); return }
if (!canEditDays || spanPhase === 'middle' || res.__leg || dragDisabled) { e.preventDefault(); return }
// setData is required for the drag to start reliably (Firefox) and
// matches how place/note items initiate their drag.
e.dataTransfer.setData('reservationId', String(res.id))
@@ -2065,7 +2073,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
}}
>
{canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile && (
{canEditDays && spanPhase !== 'middle' && !res.__leg && !dragDisabled && (
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
@@ -2182,8 +2190,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
return (
<React.Fragment key={`note-${note.id}`}>
<div
draggable={canEditDays && !isMobile}
onDragStart={e => { if (!canEditDays || isMobile) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
className="dp-row"
draggable={canEditDays && !dragDisabled}
onDragStart={e => { if (!canEditDays || dragDisabled) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => {
@@ -2251,7 +2260,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
}}
>
{canEditDays && !isMobile && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>}
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
@@ -572,4 +572,36 @@ describe('Google Maps list import', () => {
);
});
});
});
// #1432: a tablet is a touch device at a desktop width. Before the fix, isTouch didn't
// exist and drag was gated on width alone, so on an iPad the rows stayed draggable and a
// scroll swipe started an HTML5 drag, which raised the drop-to-import overlay instead of
// scrolling. These cases pin the desktop-width + coarse-pointer combination.
describe('touch device at desktop width (#1432)', () => {
const touchProps = { ...defaultProps, isMobile: false, isTouch: true };
it('FE-PLANNER-SIDEBAR-044: place rows are not draggable', () => {
const place = buildPlace({ id: 7, name: 'Tablet Place' });
render(<PlacesSidebar {...touchProps} places={[place]} />);
const placeRow = screen.getByText('Tablet Place').closest('div[draggable]')!;
expect(placeRow.getAttribute('draggable')).toBe('false');
});
it('FE-PLANNER-SIDEBAR-045: dragging over the sidebar does not raise the drop-to-import overlay', () => {
const place = buildPlace({ id: 7, name: 'Tablet Place' });
const { container } = render(<PlacesSidebar {...touchProps} places={[place]} />);
fireEvent.dragEnter(container.firstChild as HTMLElement);
expect(screen.queryByText('Drop to import')).not.toBeInTheDocument();
});
it('FE-PLANNER-SIDEBAR-046: a mouse-driven desktop keeps drag and the drop-to-import overlay', () => {
const place = buildPlace({ id: 7, name: 'Desktop Place' });
const { container } = render(<PlacesSidebar {...defaultProps} isTouch={false} places={[place]} />);
const placeRow = screen.getByText('Desktop Place').closest('div[draggable]')!;
expect(placeRow.getAttribute('draggable')).toBe('true');
fireEvent.dragEnter(container.firstChild as HTMLElement);
expect(screen.getByText('Drop to import')).toBeInTheDocument();
});
});
@@ -17,19 +17,20 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
sidebarDragOver, handleSidebarDragEnter, handleSidebarDragOver, handleSidebarDragLeave, handleSidebarDrop,
selectMode, filtered, t, dayPickerPlace, listImportOpen,
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
ctxMenu, isMobile, isTouch, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
collectionsEnabled, saveToListOpen, setSaveToListOpen,
} = S
const dragDisabled = isMobile || isTouch
return (
<div
onDragEnter={isMobile ? undefined : handleSidebarDragEnter}
onDragOver={isMobile ? undefined : handleSidebarDragOver}
onDragLeave={isMobile ? undefined : handleSidebarDragLeave}
onDrop={isMobile ? undefined : handleSidebarDrop}
onDragEnter={dragDisabled ? undefined : handleSidebarDragEnter}
onDragOver={dragDisabled ? undefined : handleSidebarDragOver}
onDragLeave={dragDisabled ? undefined : handleSidebarDragLeave}
onDrop={dragDisabled ? undefined : handleSidebarDrop}
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "var(--font-system)", position: 'relative' }}
>
{!isMobile && sidebarDragOver && <PlacesDropOverlay {...S} />}
{!dragDisabled && sidebarDragOver && <PlacesDropOverlay {...S} />}
{/* Kopfbereich */}
<PlacesHeader {...S} />
@@ -7,7 +7,7 @@ export function PlacesList(S: SidebarState) {
const {
filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace,
categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId,
isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, tripId,
isMobile, isTouch, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, tripId,
} = S
// Plugin-contributed columns/actions for the places view, keyed by place id (#plugins).
const contribFor = usePluginViewContributions('places', tripId)
@@ -43,6 +43,7 @@ export function PlacesList(S: SidebarState) {
selectedDayId={selectedDayId}
canEditPlaces={canEditPlaces}
isMobile={isMobile}
isTouch={Boolean(isTouch)}
t={t}
onPlaceClick={onPlaceClick}
onContextMenu={openContextMenu}
@@ -15,6 +15,8 @@ interface MemoPlaceRowProps {
selectedDayId: number | null
canEditPlaces: boolean
isMobile: boolean
/** Primary pointer is coarse — HTML5 drag would swallow the scroll gesture (#1432). */
isTouch: boolean
t: (key: string, params?: Record<string, any>) => string
onPlaceClick: (id: number | null) => void
onContextMenu: (e: React.MouseEvent, place: Place) => void
@@ -26,19 +28,20 @@ interface MemoPlaceRowProps {
export const MemoPlaceRow = React.memo(function MemoPlaceRow({
place, category: cat, isSelected, isPlanned, inDay, isChecked,
selectMode, selectedDayId, canEditPlaces, isMobile, t,
selectMode, selectedDayId, canEditPlaces, isMobile, isTouch, t,
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
}: MemoPlaceRowProps) {
const hasGeometry = Boolean(place.route_geometry)
const dragDisabled = isMobile || isTouch
return (
<div
key={place.id}
ref={element => registerPlaceRow(place.id, element)}
aria-selected={isSelected}
data-place-id={place.id}
draggable={!selectMode && !isMobile}
draggable={!selectMode && !dragDisabled}
onDragStart={e => {
if (isMobile) { e.preventDefault(); return }
if (dragDisabled) { e.preventDefault(); return }
e.dataTransfer.setData('placeId', String(place.id))
e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(place.id) }
@@ -56,7 +59,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px',
cursor: selectMode || isMobile ? 'pointer' : 'grab',
cursor: selectMode || dragDisabled ? 'pointer' : 'grab',
background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s',
@@ -31,6 +31,8 @@ export interface PlacesSidebarProps {
onBulkChangeCategory?: (ids: number[], categoryId: number | null) => void
days: Day[]
isMobile: boolean
/** Primary pointer is coarse — HTML5 drag would swallow the scroll gesture (#1432). */
isTouch?: boolean
onCategoryFilterChange?: (categoryIds: Set<string>) => void
onPlacesFilterChange?: (filter: string) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void