Skip to content

feat: Host-injected dashboard nav items, filtered by user permission - #2701

Merged
camielvs merged 3 commits into
masterfrom
add-host-injected-nav-items
Sep 4, 2026
Merged

feat: Host-injected dashboard nav items, filtered by user permission#2701
camielvs merged 3 commits into
masterfrom
add-host-injected-nav-items

Conversation

@camielvs

@camielvs camielvs commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds a window.__TANGLE_EXTRA_NAV_ITEMS__ config contract so a deployment can add its own links to the dashboard sidebar without forking DashboardLayout. It sits alongside the existing injection points (__TANGLE_AI_MODELS__, __TANGLE_EXTRA_FLAGS__, __TANGLE_ANNOUNCEMENTS__).

Items render in the utility block directly under Settings. An item may declare requiresPermission; it is then shown only to users whose /api/users/me permissions include that string — reusing the same permissions array that already gates the Hugging Face auth button and run actions.

window.__TANGLE_EXTRA_NAV_ITEMS__ = [
  { label: "Admin", href: "/admin/", icon: "ShieldCheck", requiresPermission: "admin" },
];

Injected config is validated before use, following the aiModels.ts reader pattern: items without a usable label or href are dropped, an icon that isn't in the Lucide set is dropped (rather than crashing the sidebar), and hrefs are restricted to same-origin paths or http(s) URLs so a host assembling this config from a less-trusted source can't turn a nav item into a javascript: sink.

Two notes for reviewers:

  • The permission filter is presentation only. Whatever an item links to has to enforce its own access control — nothing here is a security boundary.
  • ExtraNavItems uses useQuery(userQueryOptions) rather than the useUserDetails() wrapper, because that wrapper is useSuspenseQuery and would suspend the whole dashboard shell on first paint. The item appears when /api/users/me resolves; the rest of the sidebar never waits on it.

Related Issue and Pull requests

Consumed by a matching Shopify-side overlay change that surfaces an admin dashboard link.

Type of Change

  • New feature

Checklist

  • I have tested this does not break current pipelines / runs functionality
  • I have tested the changes on staging

Screenshots (if applicable)

Verified locally with the config above injected and /api/users/me returning ["read", "write", "admin"] — "Admin" renders under Settings with the same styling as Docs/Settings, links to /admin/, and gets no target="_blank" since it is same-origin. With ["read", "write"] the item is absent.

Test Instructions

  1. pnpm start, then in the devtools console before load (or via an index.html script block) set window.__TANGLE_EXTRA_NAV_ITEMS__ as above.
  2. With a backend that grants your user admin, the item shows under Settings; without it, the item is hidden.
  3. pnpm vitest run src/config/extraNavItems.test.ts src/routes/Dashboard/ExtraNavItems.test.tsx

Additional Comments

pnpm validate and the full pnpm test suite (215 files, 2260 tests) pass locally.

Adds a `window.__TANGLE_EXTRA_NAV_ITEMS__` config contract, alongside the
existing `__TANGLE_AI_MODELS__` / `__TANGLE_EXTRA_FLAGS__` ones, so a
deployment can add its own links to the dashboard sidebar without forking
the layout. Items render under Settings; an item may declare
`requiresPermission`, in which case it is only shown to users whose
`/api/users/me` permissions include it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@camielvs
camielvs requested a review from a team as a code owner September 3, 2026 17:27
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

🎩 Preview

A preview build has been created at: add-host-injected-nav-items/25c38f5

@morgan-wowk morgan-wowk left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review

Nice, self-contained injection point that follows the established aiModels.ts reader pattern. Verified the parts that matter:

  • Fails closed on load. ExtraNavItems uses useQuery(userQueryOptions) (non-suspense, per your note), and while /api/users/me is pending or errored user is undefinedpermissions ?? [] → any requiresPermission item is hidden until permissions actually arrive. Good — the shell never waits and a permissioned item never flashes before its gate resolves.
  • The icon guard is actually safe here — I checked the failure mode you were guarding against. readIconName uses value in icons, and in walks the prototype chain, so "constructor"/"toString" would normally slip through and hand <Icon> a non-component (it does icons[icon] with no fallback → render crash). But lucide-react's icons is a null-prototype object ("constructor" in icons === false), so only real icon names pass. It holds — worth a one-line comment or Object.hasOwn(icons, value) to make the safety independent of lucide's internals, but not required.
  • Tests are thorough: no-injection, no-permission, permission filter both ways, invalid label/href dropped, javascript:/data:/relative rejected, unknown icon dropped-but-item-kept, and the component-level "appears once permissions arrive / no target for same-origin / hidden without permission". Placement confirmed directly under the Settings link.

SHOULD-FIX (non-blocking)

readHref's same-origin branch admits cross-origin URLs. Anything starting with / is returned as-is, so a protocol-relative //evil.com — and a backslash variant /\evil.com, which browsers normalize to //evil.com — passes as a "same-origin path" and navigates off-origin:

readHref("//evil.com")  => "//evil.com"     // <a href> resolves to https://evil.com
readHref("/\\evil.com") => "/\\evil.com"    // browsers treat \ as / in the authority

This isn't a privilege escalation — the http(s) branch already accepts arbitrary https:// links by design, so a config author can't reach anywhere new. The concrete consequence is misclassification: these resolve cross-origin but are treated as internal, so they render same-tab with no external/target=_blank/rel="noopener" handling, and the "same-origin paths only" guarantee the doc-comment and the dedicated href test assert is weaker than it reads. Since this validator is explicitly framed as the thing standing between a less-trusted config source and the DOM, I'd close the gap:

if (href.startsWith("/")) {
  const url = new URL(href, window.location.origin);
  return url.origin === window.location.origin ? url.pathname + url.search + url.hash : null;
}

(mirrors the new URL(...)-origin approach in #694's safeUrl), and add a "//evil.com" case to the existing rejection test — it's the one same-origin-looking-but-cross-origin input the suite doesn't cover.

NIT

  • key={item.href} collides if two items share an href (different labels, same target). Unlikely, but key={`${item.href}:${item.label}`} or the index is safer.

Marking Comment on the one same-origin classification gap; everything else looks good and the fail-closed behavior is right.

`readHref` returned anything starting with `/` verbatim, so `//evil.com` and
its backslash variant `/\evil.com` passed as "same-origin paths" and rendered
same-tab with no external-link handling. Resolve the path branch against the
current origin and keep only what still belongs to it.

Also makes the icon guard use `Object.hasOwn` so it does not depend on
lucide's `icons` having a null prototype, and disambiguates the nav item React
key for two items sharing an href.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@camielvs

camielvs commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — all three addressed in e285a6e..HEAD:

  • Off-origin hrefs (should-fix): good catch. A leading slash is no longer taken as proof that a link is internal — each path is now resolved against the current origin and dropped if it lands somewhere else, so //evil.com and the backslash variant are rejected instead of rendering as internal links. Added both as test cases, plus one confirming normal paths keep their query and hash.
  • Icon guard: switched to Object.hasOwn, so it no longer relies on a detail of how lucide happens to build its icon set.
  • Nit: nav item keys now include the label, so two items pointing at the same place can coexist.

pnpm validate and the full suite pass.

@camielvs
camielvs requested a review from morgan-wowk September 4, 2026 22:03

@morgan-wowk morgan-wowk left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review — re-review after update

All three items from the prior pass are resolved, and the delta is confined to exactly them:

  • Same-origin href gap (SHOULD-FIX) — fixed. The /-leading branch now resolves new URL(href, window.location.origin) and keeps the result only when url.origin === window.location.origin, returning the normalized pathname + search + hash (else null, and a try/catch closes the malformed-URL path). Confirmed the payloads now fail closed: //evil.comnull, /\evil.comnull (browser backslash→slash normalization), while /admin/ and query/hash are preserved and /a/../b normalizes to /b. New tests cover both the off-origin rejection and path/query/hash preservation.
  • Icon guard (NIT) — fixed. Object.hasOwn(icons, value) instead of value in icons, so the safety no longer rides on lucide shipping a null-prototype object.
  • Key collision (NIT) — fixed. key={`${item.href}:${item.label}`}.

Nothing else in the PR's five files changed. LGTM — approving.

@camielvs
camielvs merged commit c75eec1 into master Sep 4, 2026
15 of 16 checks passed
@camielvs
camielvs deleted the add-host-injected-nav-items branch September 4, 2026 22:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants