Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e88f3d4
feat(admin): mint API tokens for a staff owner with a role and lifeti…
DavidCockerill Oct 21, 2026
2b60d51
feat(admin): show the server's role refusal under the token role field
DavidCockerill Oct 21, 2026
9345213
fix(admin): settle the owner search before showing matches, and page …
DavidCockerill Oct 21, 2026
3b3d047
fix(admin): drop stale role refusals, refresh the list on any mint ou…
DavidCockerill Oct 21, 2026
d31a7fe
fix(admin): hint at unread pages beside matches, word a lost revoke h…
DavidCockerill Oct 21, 2026
d51d147
fix(admin): cancel a closed picker's search, and say when an unseen t…
DavidCockerill Oct 21, 2026
84a9507
fix(admin): show the API Tokens page only once central manager sends …
DavidCockerill Oct 21, 2026
39f44c7
fix(admin): keep the role-name fallback away from the API Tokens page
DavidCockerill Oct 21, 2026
fcfea6c
fix(admin): hint at more staff when the owner search is cut at its limit
DavidCockerill Oct 21, 2026
ba938ee
fix(admin): hold a second mint until the token on screen is dismissed
DavidCockerill Oct 21, 2026
8bbc0ad
fix(admin): say an unseen token is already gone when its clean-up rev…
DavidCockerill Oct 21, 2026
aab467a
fix(admin): search staff by the starts_with comparator, not a trailin…
DavidCockerill Oct 22, 2026
c7c93a5
fix(admin): keep Revoke on screen by leading the token row with it, a…
DavidCockerill Oct 22, 2026
ff4e95e
feat(admin): tidy the token table into six columns with relative time…
DavidCockerill Oct 22, 2026
4bb3fdc
fix(admin): keep the Revoke label and let the token table narrow with…
DavidCockerill Oct 22, 2026
85579bf
feat(admin): lay the token table out like the console, with a row men…
DavidCockerill Oct 22, 2026
7055b20
fix(admin): fold the role under the token on phones and let user cell…
DavidCockerill Oct 22, 2026
fc63fd4
fix(admin): pad the token table's cells, and name a removed owner as …
DavidCockerill Oct 22, 2026
bb63d30
fix(admin): lock the mint against a racing second submit, and never c…
DavidCockerill Oct 22, 2026
a064a1e
fix(admin): keep cached tokens through a failed refresh, and filter b…
DavidCockerill Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ The `/apis` route is a **custom, in-house API explorer** ([`src/features/instanc
- **Operations Studio has to know about are not sent blind.** A `restart` is tracked with `markRestarting` exactly like the Restart button's (released at once on a 4xx; a 5xx can be a gateway losing the answer to a restart that ran), so the page's reads are held (an instance) or their connectivity errors muted (a cluster, which restarts one member) until `waitUntilAnsweringAfterRestart` sees the target again. `logout` is refused: it would end the session the rest of Studio is still using. Any other operation with such a side effect needs the same treatment rather than a raw send.
- **Nothing leaves the page.** Failures render inline and never pass through `console.error` or the global React Query handlers (RUM reports the former, and bodies carry passwords and secrets). The editor buffer is not persisted, and the console remounts per entity so a confirmation written for one instance can never be sent to another. After a possible write, entity queries are marked stale with `refetchType: 'none'`, never refetched into whatever the operation just did. The download is abandoned once progress passes 5 MB (XHR reports progress about every 50 ms, so this bounds, not caps, what arrives), and display truncates like the API explorer.

## Admin API tokens (the Admin "API Tokens" page)

[`src/features/admin/apiToken/`](src/features/admin/apiToken/) mints, lists and revokes staff API tokens against central manager's `Admin/ApiToken` (HarperFast/central-manager#973).

- **The secret leaves the shared caches as soon as the form has it.** The query cache only ever holds `GET /Admin/ApiToken/` rows, which carry no secret. The mint's result does pass through the MutationCache, which is readable through `queryClient.getMutationCache()` and the devtools, so the form copies it into page state and `reset()`s in the same success callback, and the mutation runs with `gcTime: 0` so the discarded result is collected once its observer unmounts (`useGenerateApiToken.test.ts`). Sign-out clears the MutationCache as well (`clearAuthStateLocally`).
- **The role caps are a mirror, not the rule.** [`staffTokenRoles.ts`](src/features/admin/apiToken/staffTokenRoles.ts) repeats central manager's `STAFF_API_TOKEN_LIFETIMES` so the form can bound the lifetime field and pick a default; the server refuses anything longer. Change both together (`staffTokenRoles.test.ts` pins the numbers).
- **The role picker cannot see the owner's role.** Nothing central manager exposes says which staff role another account holds (a `fabricRole` on `Admin/User` is a central manager follow-up), so the form offers every staff role and leaves "no wider than the owner's" to the server's 403. That refusal names the owner's role, so `roleRefusalMessage` puts it under the role field; the mint mutation opts out of the global toast and the form toasts every other failure itself.
- **Visibility is a permission, not a role name.** The rail shows the page to `apiToken:self` (every staff role holds it, so an owner who cannot mint still sees and revokes their own tokens); the mint form is gated on `apiToken:create`. The role-name fallback for an API without `staffPermissions` never reaches the page, since such a central manager predates the token API it drives. `super_user` stays carved out: it may password-login, and a token is a staff credential it cannot mint.

## Cloud authentication presentation

All cloud authentication routes use `AuthLayout`; keep form submission, validation, OAuth targets, and error handling in the existing route components/hooks. `AuthInput` keeps `FormControl` directly around the input so label/error associations and React Hook Form refs reach the DOM. React 19 passes `ref` through props; browser tests verify first-invalid-field focus. Password selectors must use an exact label match because the reveal button also has “password” in its accessible name.
Expand Down
37 changes: 28 additions & 9 deletions src/features/admin/__tests__/adminShellVisibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ const user = (fabricRole: User['fabricRole'], staffPermissions?: string[]) =>
const labels = (u: User | null) => visibleAdminItems(u).map((item) => item.label);

describe('admin section visibility', () => {
it('shows every page to a holder of all four page permissions', () => {
const admin = user('fabric_admin', ['systemStatus:write', 'region:read', 'host:read', 'apiToken:create']);
expect(labels(admin)).toEqual(['Notifications', 'Regions', 'Hosts', 'API Token']);
it('shows every page to a holder of every page permission', () => {
const admin = user('fabric_admin', [
'systemStatus:write',
'region:read',
'host:read',
'cluster:read',
'apiToken:create',
'apiToken:self',
]);
expect(labels(admin)).toEqual(['Notifications', 'Regions', 'Hosts', 'Monitoring', 'API Tokens']);
});

it('shows only the pages a narrower role holds', () => {
Expand All @@ -24,6 +31,14 @@ describe('admin section visibility', () => {
expect(labels(user('fabric_readonly', ['region:read', 'host:read']))).toEqual(['Regions', 'Hosts']);
});

it('shows the API Tokens page to staff who can only manage their own tokens', () => {
expect(labels(user('fabric_readonly', ['region:read', 'apiToken:self']))).toEqual(['Regions', 'API Tokens']);
});

it('hides the API Tokens page from an admin whose API sends only apiToken:create', () => {
expect(labels(user('fabric_admin', ['apiToken:create']))).toEqual([]);
});

// Reading the policy is cluster:read, which every staff role holds; changing it is gated inside the page.
it('shows Monitoring to every role that can read clusters', () => {
expect(labels(user('fabric_readonly', ['cluster:read', 'region:read']))).toEqual(['Regions', 'Monitoring']);
Expand All @@ -35,16 +50,20 @@ describe('admin section visibility', () => {
expect(canSeeAdminSection(null)).toBe(false);
});

// super_user may password-login; without a Google SSO session the token mint
// 403s, so the API Token page stays hidden even though it holds the permission.
it('never shows the API Token page to super_user', () => {
const withField = user('super_user', ['systemStatus:write', 'region:read', 'host:read', 'apiToken:create']);
it('never shows the API Tokens page to super_user', () => {
const withField = user('super_user', [
'systemStatus:write',
'region:read',
'host:read',
'apiToken:create',
'apiToken:self',
]);
expect(labels(withField)).toEqual(['Notifications', 'Regions', 'Hosts']);
// Legacy API without staffPermissions: same carve-out via the fallback.
expect(labels(user('super_user'))).toEqual(['Notifications', 'Regions', 'Hosts', 'Monitoring']);
});

it('keeps the whole section for a legacy-API fabric_admin', () => {
expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'Hosts', 'Monitoring', 'API Token']);
it('keeps the older pages, not API Tokens, for a legacy-API fabric_admin', () => {
expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'Hosts', 'Monitoring']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
/** @vitest-environment jsdom */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { AxiosError, AxiosHeaders } from 'axios';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';

const { mutateAsync, reset, errorHandler, revokeApiToken, toast } = vi.hoisted(() => ({
mutateAsync: vi.fn(),
reset: vi.fn(),
errorHandler: vi.fn(),
revokeApiToken: vi.fn(),
toast: { warning: vi.fn(), error: vi.fn() },
}));
vi.mock('@/features/admin/apiToken/mutations/useGenerateApiToken', () => ({
useGenerateApiTokenMutation: () => ({ mutateAsync, reset, isPending: false }),
}));
vi.mock('@/features/admin/apiToken/mutations/useRevokeApiToken', () => ({ revokeApiToken }));
vi.mock(
'@/features/admin/apiToken/queries/getStaffApiTokens',
() => ({ staffApiTokensQueryKey: ['fabric-admin', 'apiTokens'] }),
);
vi.mock('@/react-query/queryClient', async (importOriginal) => ({
...(await importOriginal<typeof import('@/react-query/queryClient')>()),
errorHandler,
}));

vi.mock('sonner', () => ({ toast }));

import { MintStaffApiTokenForm, roleRefusalMessage } from './MintStaffApiTokenForm';

const self = { id: 'usr-me', email: 'me@harperdb.io', firstname: 'Me', lastname: 'Myself' };
const minted = {
operationToken: 'fat_sat-1_secret',
id: 'sat-1',
name: 'nightly report',
role: 'fabric_readonly',
ownerUserId: 'usr-me',
createdByUserId: 'usr-me',
createdAt: '2026-10-21T00:00:00.000Z',
expiresAt: '2026-11-04T00:00:00.000Z',
lastUsedAt: null,
revokedAt: null,
status: 'active',
} as const;
const refusal = "A fabric_admin token would exceed the owner's fabric_support role";

function axiosFailure(status: number, body: unknown, method = 'post'): AxiosError {
const config = { headers: new AxiosHeaders(), method };
return new AxiosError('Request failed', String(status), config, undefined, {
status,
statusText: '',
headers: {},
config,
data: body,
});
}

function pendingMint() {
let resolve!: (value: unknown) => void;
let reject!: (reason: unknown) => void;
mutateAsync.mockImplementationOnce(
() =>
new Promise((res, rej) => {
resolve = res;
reject = rej;
}),
);
return {
succeed: () => act(async () => resolve(minted)),
fail: (error: unknown) => act(async () => reject(error)),
};
}

function renderForm(holdingSecret = false) {
const onMinted = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const { unmount } = render(
<QueryClientProvider client={client}>
<MintStaffApiTokenForm self={self} onMinted={onMinted} holdingSecret={holdingSecret} />
</QueryClientProvider>,
);
return { onMinted, unmount };
}

async function fillAndSubmit(name = 'nightly report') {
fireEvent.change(screen.getByLabelText('Name'), { target: { value: name } });
await act(async () => {
fireEvent.submit(screen.getByRole('button', { name: 'Mint token' }).closest('form')!);
});
}

beforeAll(() => {
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.scrollIntoView ??= () => undefined;
});

beforeEach(() => {
mutateAsync.mockReset();
reset.mockReset();
errorHandler.mockReset();
revokeApiToken.mockReset();
toast.warning.mockReset();
toast.error.mockReset();
});

afterEach(cleanup);

describe('MintStaffApiTokenForm', () => {
it('mints for the signed-in owner at read-only for 14 days by default', async () => {
const mint = pendingMint();
renderForm();

await fillAndSubmit();
await mint.succeed();

expect(mutateAsync.mock.calls[0][0]).toEqual({
ownerUserId: 'usr-me',
role: 'fabric_readonly',
name: 'nightly report',
expiresInHours: 336,
});
});

// The order matters: the page must hold the secret before the mutation forgets it.
it('hands the minted token to the page, then clears the mutation and the form', async () => {
const mint = pendingMint();
const { onMinted } = renderForm();

await fillAndSubmit();
await mint.succeed();

expect(onMinted).toHaveBeenCalledWith(minted);
expect(reset).toHaveBeenCalledTimes(1);
expect(onMinted.mock.invocationCallOrder[0]).toBeLessThan(reset.mock.invocationCallOrder[0]);
expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('');
});

it('shows a role wider than the owner holds under the role field, and toasts anything else', async () => {
const first = pendingMint();
renderForm();

await fillAndSubmit();
await first.fail(axiosFailure(403, { code: 'Forbidden', title: refusal }));
expect(screen.getByText(refusal)).toBeDefined();
expect(errorHandler).not.toHaveBeenCalled();

const second = pendingMint();
const limit = axiosFailure(409, { title: 'Limit of 3 live API tokens per owner reached' });
await fillAndSubmit();
await second.fail(limit);
expect(errorHandler).toHaveBeenCalledWith(limit);
});

it('clears the refusal once the owner changes', async () => {
const mint = pendingMint();
renderForm();
await fillAndSubmit();
await mint.fail(axiosFailure(403, { title: refusal }));
expect(screen.getByText(refusal)).toBeDefined();

fireEvent.click(screen.getByRole('button', { name: 'Change owner' }));

expect(screen.queryByText(refusal)).toBeNull();
});

it('revokes a token minted after the page was left', async () => {
const mint = pendingMint();
const { onMinted, unmount } = renderForm();
await fillAndSubmit();

unmount();
revokeApiToken.mockResolvedValue({ id: 'sat-1', revoked: true });
await mint.succeed();

expect(onMinted).not.toHaveBeenCalled();
expect(revokeApiToken).toHaveBeenCalledWith('sat-1');
expect(toast.warning).toHaveBeenCalled();
expect(reset).toHaveBeenCalledTimes(1);
});

// A revoke whose answer was lost may have landed; the shared handler words that, a flat
// "could not be revoked" would not.
it('reports a failed unseen-token revoke through the shared handler', async () => {
const mint = pendingMint();
const { unmount } = renderForm();
await fillAndSubmit();
unmount();
const lost = axiosFailure(502, 'Bad Gateway', 'delete');
revokeApiToken.mockRejectedValueOnce(lost);

await mint.succeed();

expect(errorHandler).toHaveBeenCalledWith(lost);
expect(toast.error).not.toHaveBeenCalled();
});

it('says an unseen token is still live when its revoke is refused', async () => {
const mint = pendingMint();
const { unmount } = renderForm();
await fillAndSubmit();
unmount();
revokeApiToken.mockRejectedValueOnce(axiosFailure(403, { title: 'Forbidden' }));
vi.spyOn(console, 'error').mockImplementation(() => undefined);

await mint.succeed();

expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('still live'));
expect(errorHandler).not.toHaveBeenCalled();
});

// Central manager answers 404 for another owner's token when the caller lacks apiToken:create,
// so a 404 here is an unconfirmed clean-up, never proof the token is gone.
it('reports an unconfirmed clean-up, naming the token and owner, when the revoke answers 404', async () => {
const mint = pendingMint();
const { unmount } = renderForm();
await fillAndSubmit();
unmount();
revokeApiToken.mockRejectedValueOnce(axiosFailure(404, { title: 'API token not found' }));
vi.spyOn(console, 'error').mockImplementation(() => undefined);

await mint.succeed();

expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('could not be confirmed revoked'));
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('sat-1 for usr-me'));
expect(toast.warning).not.toHaveBeenCalled();
});

// Disabling the button through isPending does not serialize a submit already in the handler.
it('sends one mint for two submits that race the first response', async () => {
const mint = pendingMint();
const { onMinted } = renderForm();
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'nightly report' } });
const form = screen.getByRole('button', { name: 'Mint token' }).closest('form')!;

await act(async () => {
fireEvent.submit(form);
fireEvent.submit(form);
});
await mint.succeed();

expect(mutateAsync).toHaveBeenCalledTimes(1);
expect(onMinted).toHaveBeenCalledTimes(1);
});

it('toasts a mint that failed after the page was left', async () => {
const mint = pendingMint();
const { unmount } = renderForm();
await fillAndSubmit();
unmount();
const lost = new Error('timeout');

await mint.fail(lost);

expect(errorHandler).toHaveBeenCalledWith(lost);
});

it('does not demand an owner the moment the picker is cleared', async () => {
renderForm();

fireEvent.click(screen.getByRole('button', { name: 'Change owner' }));

expect(screen.queryByText('Pick an owner.')).toBeNull();
});

it('does not mint while the page still shows a secret', async () => {
renderForm(true);

expect((screen.getByRole('button', { name: 'Mint token' }) as HTMLButtonElement).disabled).toBe(true);
await fillAndSubmit();

expect(mutateAsync).not.toHaveBeenCalled();
expect(screen.getByText(/before minting another/)).toBeDefined();
});

it('refuses an empty name before asking the server', async () => {
renderForm();

await fillAndSubmit(' ');

expect(mutateAsync).not.toHaveBeenCalled();
expect(screen.getByText('Enter a name.')).toBeDefined();
});
});

describe('roleRefusalMessage', () => {
it('returns the server message only for a role wider than the owner holds', () => {
const message = "A fabric_support token would exceed the owner's fabric_readonly role";
expect(roleRefusalMessage(axiosFailure(403, { code: 'Forbidden', title: message }))).toBe(message);
expect(roleRefusalMessage(axiosFailure(403, message))).toBe(message);
expect(roleRefusalMessage(axiosFailure(403, { title: 'API tokens can only be created from a Google SSO session' })))
.toBeNull();
expect(roleRefusalMessage(axiosFailure(409, { title: 'Limit of 3 live API tokens per owner reached' }))).toBeNull();
expect(roleRefusalMessage(new Error("would exceed the owner's role"))).toBeNull();
});
});
Loading
Loading