Skip to content

feat(fabric-admin): Fabric Admin section with API token generator#1533

Open
DavidCockerill wants to merge 4 commits into
stagefrom
feat/fabric-admin-section
Open

feat(fabric-admin): Fabric Admin section with API token generator#1533
DavidCockerill wants to merge 4 commits into
stagefrom
feat/fabric-admin-section

Conversation

@DavidCockerill

Copy link
Copy Markdown
Member

What

Adds a Fabric Admin section to Studio — an extensible home for internal admin tooling — and ships its first tool: a short-lived API Token generator.

  • New /fabric-admin area (src/features/fabricAdmin/): a layout shell (FabricAdminShell) with a sub-nav rail and an <Outlet/>, so future admin tools drop in as sibling routes.
  • API Token generator (apiToken/index.tsx): a Generate button that calls POST /Admin/ApiToken and shows the returned token (read-only field + copy) with its expiry and a short usage note. The token authenticates as the signed-in admin, with their permissions.
  • Access gating: the section and its navbar entry are shown only to fabric admins (useAdminMode()fabricRole of fabric_admin/super_user) and are hidden entirely in local Studio.
  • 401 handling relies on the sibling PR fix(auth): sign out and redirect on 401 from the CM API #1523 (sign out + redirect on a 401 from the CM API), so an expired admin session is handled gracefully.

Why

We're forcing internal fabric_admin accounts onto Google SSO (see the companion central-manager PR). SSO gives humans a browser session but no password for programmatic/API access. This page lets an admin mint a short-lived, SSO-derived token for CLI/API use without reintroducing a password. It also gives us a place to grow the rest of the admin surface.

Depends on / related

  • central-manager fabric-admin-auth-hardening — provides the POST /Admin/ApiToken endpoint this page calls (plus the SSO-session hardening). That endpoint must ship first, or Generate returns 404.
  • Studio fix(auth): sign out and redirect on 401 from the CM API #1523 — 401 auto-logout interceptor (the safety net for expired admin sessions).

Testing

  • vitest run — full suite green (241 files, 1722 passing).
  • tsc -b, oxlint, dprint check — all clean.
  • Route-tree wiring follows the getParentRoute/addChildren lockstep rule (CLAUDE.md).
  • Note: end-to-end happy-path (real Google SSO → Generate) needs this branch deployed to dev; local Studio can't complete the remote OAuth callback.

Notes

  • Drafted with assistance from Claude (Opus 4.8); reviewed by David before opening.

New top-level "Fabric Admin" area, gated to fabric admins / super users,
built as an extensible section (layout + sub-nav rail) so future admin
endpoints slot in as sub-pages. First page generates a short-lived API
token for programmatic access.

- Navbar: fabric-admin-gated "Fabric Admin" item (isAdminMode).
- routes: fabricAdminLayoutRoute (under dashboardLayout) + api-token index,
  wired into rootRouteTree keeping getParentRoute/addChildren in lockstep.
- FabricAdminShell: SubNavRail shell, render-gated via useCloudAuth/isAdminMode
  (dashboard guard still handles the unauthenticated redirect).
- ApiToken page: generate button -> POST /Admin/ApiToken, shows the token
  (read-only field + copy) with expiry and a "copy it now" note.
- ApiTokenResult type in api.patch.d.ts; endpoint isn't in generated types
  so the mutation casts the URL + response (like getCurrentUser).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a new Fabric Admin section, allowing administrators to generate short-lived API tokens for programmatic access. It includes the addition of a 'Fabric Admin' link in the navigation bar, a shell layout with sub-navigation, and the API token generation interface and mutation. Feedback on the changes suggests removing the local 'onError' handler from the token generation mutation to prevent duplicate error notifications, and correcting the 'variant="submit"' prop on the generate button to 'type="button"'.

Comment on lines +16 to +21
const onGenerate = () => {
mutate(undefined, {
onSuccess: (result) => setToken(result),
onError: (error) => toast.error('Failed to generate API token', { description: error.message }),
});
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Avoid adding local 'onError' handlers to individual mutations if a global error handler is already configured to handle and display error messages, to prevent duplicate error notifications (double-toasting).

	const onGenerate = () => {
		mutate(undefined, {
			onSuccess: (result) => setToken(result),
		});
	};
References
  1. Do not add local onError handlers to individual mutations if a global error handler (such as a global MutationCache.onError in React Query) is already configured to handle and display error messages, to prevent duplicate error notifications (double-toasting).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied in b0c9a6d — confirmed the global MutationCache.onError in src/react-query/queryClient.ts already toasts mutation failures (and other components like ResendInviteButton document relying on it), so the local handler was indeed a double-toast. Removed.

— Claude Opus 4.8 (on behalf of @DavidCockerill)

<code className="rounded bg-muted px-1 py-0.5 text-xs">Authorization: Bearer &lt;token&gt;</code>
</p>

<Button className="mt-4" variant="submit" onClick={onGenerate} disabled={isPending}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The 'variant' prop on the 'Button' component is typically used for visual styles (e.g., 'default', 'outline', 'ghost'). Using variant='submit' is likely a typo or an invalid variant. Since this button is not inside a form and uses an explicit 'onClick' handler, using type='button' is the standard approach.

Suggested change
<Button className="mt-4" variant="submit" onClick={onGenerate} disabled={isPending}>
<Button className="mt-4" type="button" onClick={onGenerate} disabled={isPending}>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Declining this one: submit is a defined variant in this repo — see src/components/ui/buttonVariants.tsx:31. It's the primary-CTA style used across the app, and it's why tsc accepts the prop (the variants are typed via cva, so an invalid variant would fail the build). The generic shadcn variant list doesn't apply here.

— Claude Opus 4.8 (on behalf of @DavidCockerill)

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 51% 5444 / 10673
🔵 Statements 51.56% 5828 / 11302
🔵 Functions 43.23% 1335 / 3088
🔵 Branches 44.42% 3669 / 8259
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/components/Navbar.tsx 30% 23.07% 15.78% 32.43% 32-42, 98-121, 164-303
src/features/admin/routes.ts 71.42% 100% 50% 71.42% 14-15
src/features/admin/apiToken/mutations/useGenerateApiToken.ts 66.66% 100% 50% 66.66% 17
src/features/admin/components/AdminShell.tsx 14.28% 0% 0% 20% 17-32
src/hooks/useAuth.ts 43.47% 30% 46.15% 47.36% 17-26, 58-64
src/router/rootRouteTree.ts 100% 50% 100% 100%
Generated in workflow #1561 for commit 7446b78 by the Vitest Coverage Report Action

@DavidCockerill
DavidCockerill marked this pull request as ready for review July 17, 2026 18:09
@DavidCockerill
DavidCockerill requested a review from a team as a code owner July 17, 2026 18:09
Review feedback (Gemini): the global MutationCache.onError in
react-query/queryClient already toasts mutation failures, so the local
onError produced a duplicate toast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I see there are comments about super user; I don't know if a super user is really a valid concern, if you manage to login as the super user, I assume you don't need a token. So I think this looks good.

const { isLoading, user } = useCloudAuth();

if (isLoading) { return null; }
if (!isAdminMode(user)) { return <Navigate to="/" replace />; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we align this guard with the token endpoint's session contract? isAdminMode includes super_user, and CM intentionally permits password login for that role, but POST /Admin/ApiToken requires the OAuth-only session.ssoAuthAt marker. A password-authenticated super user therefore sees this page and can only receive a 403. Please either restrict the token page to the intended SSO-only role, expose the session auth method and offer an SSO re-auth path/message, or adjust the endpoint if these sessions are intended to mint tokens.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied in 1b21e2a, taking your first option (and matching your review note that a super user doesn't need a token): a new isFabricAdmin helper gates the shell and the navbar item, so only fabric_admin accounts — the ones whose sessions can actually satisfy the endpoint's ssoAuthAt contract — see the section. A password-authenticated super user no longer gets shown a page that can only 403. If the section later grows tools that don't need SSO, we can widen per-page.

— Claude Opus 4.8 (on behalf of @DavidCockerill)

Comment thread src/components/Navbar.tsx Outdated
const { mutate: signOut } = useLogoutMutation();
const navigate = useNavigate();
const { user } = useOverallAuth();
const isAdmin = useAdminMode();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Navbar already has user from useOverallAuth() on the preceding line, while useAdminMode() calls useCloudAuth() -> useOverallAuth() again. That adds another state/effect/authStore listener for every Navbar (raising the Navbar tree's existing three subscriptions to four, including for non-admin/local users), and the extra listener runs on every auth update. Could we import the pure isAdminMode helper and derive this from the existing user instead, with a cloud/local type guard if needed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied in 1b21e2a — Navbar now derives admin visibility from its existing useOverallAuth() subscription via the pure helper (no fourth authStore listener). isFabricAdmin accepts the User | LocalUser union with an internal 'fabricRole' in user narrow, so no type-guard boilerplate at the call site. (useAdminMode() had been introduced here to dodge exactly that union mismatch — your version is better.)

— Claude Opus 4.8 (on behalf of @DavidCockerill)

…ription

Review feedback (Kris): isAdminMode includes super_user, but the token
endpoint requires an SSO session only fabric_admin accounts have — a
password-authenticated super user would see the page and only ever get
403. New isFabricAdmin helper (accepts the cloud/local user union)
gates the shell and navbar item. Navbar now derives it from its
existing useOverallAuth() subscription instead of useAdminMode(),
which added a second authStore listener per Navbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor

I can't verify this locally since fabric admins must use Google auth, even locally... pushing to dev, though I don't think I have a user there either... how did you verify locally, David?

@dawsontoth dawsontoth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's just call it Admin, drop Fabric from the name. We're already on Fabric, that's understood. And there's no Admin for anyone except us, so it's safe ambiguation of the term.

Review feedback (Dawson): we're already on Fabric and there's no admin
area for anyone but us, so "Admin" is unambiguous. Renames the route
(/fabric-admin -> /admin), nav label, section heading, feature dir
(features/fabricAdmin -> features/admin), and FabricAdminShell ->
AdminShell. The Harper role identifier (fabric_admin / fabricRole /
isFabricAdmin) is unchanged — that's the actual role name, not the
section label — and it now aligns with CM's existing /Admin namespace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DavidCockerill

Copy link
Copy Markdown
Member Author

Done in 7446b78 — renamed to Admin throughout: route /fabric-admin/admin, the nav label and section heading, the feature dir (features/fabricAdminfeatures/admin), and FabricAdminShellAdminShell. Left the Harper role identifier alone (fabric_admin / fabricRole / isFabricAdmin) since that's the actual role name, not the section label — and as a bonus "Admin" now lines up with CM's existing /Admin namespace. Full suite green (1722), tsc/lint/format clean.

On verifying locally — you're right that you can't, and neither could I do a true end-to-end run: real Google SSO won't complete against remote CM from localhost (the OAuth callback lands on the dev domain). What I checked locally was appearance only — I seeded a fake admin user and stubbed the generate-token mutation for a pure-visual pass, then reverted it. The happy path (real SSO → mint) was never exercised locally.

The actual unblock for "fabric admins must use Google even locally" is the CM side: central-manager#459 adds FABRIC_ADMIN_SSO_ENFORCED, which is off in local/dev, so a fabric admin can password-login there and click through the whole Admin tab (including minting a token) with no Google. The catch: #459 isn't deployed to dev yet. So the real verification path is — once #459 lands on dev, sign in on dev studio with a dev fabric_admin account + password and exercise it there. If you don't have a dev fabric-admin user, David can grant one via PATCH /Admin/User (or alter_user) on dev. Happy to walk through it once #459 is up.

— Claude Opus 4.8 (on behalf of @DavidCockerill)

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks like there is an edge case to cover, but looks good.


Proposed inline comments (anchors failed):

  • src/features/fabricAdmin/apiToken/mutations/useGenerateApiToken.ts:17: Could we keep this bearer token out of the global mutation cache once the page stops using it? Each mutate stores the successful ApiTokenResult; generating again or unmounting only makes the old entry inactive, and logout currently clears QueryCache but not MutationCache, so the credential remains readable through queryClient.getMutationCache() until GC. Setting gcTime: 0 here would discard each token as soon as its observer is removed. If we want the token to live only in ApiTokenIndex state even while mounted, we can also call the mutation's reset() after copying the success result into state.
  • src/hooks/useAuth.ts:54: Could we pin the role boundary introduced in the follow-up commit? A small table test for isFabricAdmin (fabric_admin true; super_user, least_privileged, a local user, and null false) would prevent the exact regression this re-review fixed. The current mutation test mocks only the transport and cannot catch the route being exposed to password-authenticated super_user users again.

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Another suggestion

}

export function useGenerateApiTokenMutation() {
return useMutation<ApiTokenResult, Error, void>({ mutationFn: generateApiToken });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we avoid returning the bearer credential through React Query mutation state? useMutation stores successful data in the global MutationCache; with the browser default it remains there for five minutes after this route unmounts, and logoutOnSuccess() clears only the QueryCache. That means a generated operationToken survives navigation/logout in memory (and is directly shown in the mutation inspector in development builds). Please keep the secret out of shared mutation state, or reset/remove this mutation with immediate GC after moving the value into local component state, and add a test asserting the cache no longer contains operationToken after success/unmount/logout.

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.

3 participants