feat: Host-injected dashboard nav items, filtered by user permission - #2701
Conversation
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>
🎩 PreviewA preview build has been created at: |
morgan-wowk
left a comment
There was a problem hiding this comment.
🤖 Automated review
Nice, self-contained injection point that follows the established aiModels.ts reader pattern. Verified the parts that matter:
- Fails closed on load.
ExtraNavItemsusesuseQuery(userQueryOptions)(non-suspense, per your note), and while/api/users/meis pending or erroreduserisundefined→permissions ?? []→ anyrequiresPermissionitem 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.
readIconNameusesvalue in icons, andinwalks the prototype chain, so"constructor"/"toString"would normally slip through and hand<Icon>a non-component (it doesicons[icon]with no fallback → render crash). But lucide-react'siconsis a null-prototype object ("constructor" in icons === false), so only real icon names pass. It holds — worth a one-line comment orObject.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, butkey={`${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>
|
Thanks — all three addressed in e285a6e..HEAD:
|
morgan-wowk
left a comment
There was a problem hiding this comment.
🤖 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 resolvesnew URL(href, window.location.origin)and keeps the result only whenurl.origin === window.location.origin, returning the normalizedpathname + search + hash(elsenull, and atry/catchcloses the malformed-URL path). Confirmed the payloads now fail closed://evil.com→null,/\evil.com→null(browser backslash→slash normalization), while/admin/and query/hash are preserved and/a/../bnormalizes to/b. New tests cover both the off-origin rejection and path/query/hash preservation. - Icon guard (NIT) — fixed.
Object.hasOwn(icons, value)instead ofvalue 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.
Description
Adds a
window.__TANGLE_EXTRA_NAV_ITEMS__config contract so a deployment can add its own links to the dashboard sidebar without forkingDashboardLayout. 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/mepermissions include that string — reusing the samepermissionsarray that already gates the Hugging Face auth button and run actions.Injected config is validated before use, following the
aiModels.tsreader pattern: items without a usable label or href are dropped, aniconthat isn't in the Lucide set is dropped (rather than crashing the sidebar), and hrefs are restricted to same-origin paths orhttp(s)URLs so a host assembling this config from a less-trusted source can't turn a nav item into ajavascript:sink.Two notes for reviewers:
ExtraNavItemsusesuseQuery(userQueryOptions)rather than theuseUserDetails()wrapper, because that wrapper isuseSuspenseQueryand would suspend the whole dashboard shell on first paint. The item appears when/api/users/meresolves; 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
Checklist
Screenshots (if applicable)
Verified locally with the config above injected and
/api/users/mereturning["read", "write", "admin"]— "Admin" renders under Settings with the same styling as Docs/Settings, links to/admin/, and gets notarget="_blank"since it is same-origin. With["read", "write"]the item is absent.Test Instructions
pnpm start, then in the devtools console before load (or via anindex.htmlscript block) setwindow.__TANGLE_EXTRA_NAV_ITEMS__as above.admin, the item shows under Settings; without it, the item is hidden.pnpm vitest run src/config/extraNavItems.test.ts src/routes/Dashboard/ExtraNavItems.test.tsxAdditional Comments
pnpm validateand the fullpnpm testsuite (215 files, 2260 tests) pass locally.