fix(settings): restore token dialog focus

Contract-Neutral: Restores keyboard focus after existing API token dialogs close; token authority, scopes, endpoints, and security contracts are unchanged.
This commit is contained in:
rcourtman 2026-08-06 00:04:40 +01:00
parent b724dc1a9d
commit c42399e2c3
4 changed files with 82 additions and 24 deletions

View file

@ -16,6 +16,8 @@ paths = [
'''_test\.go$''',
'''_test\.ts$''',
'''tests/integration/''',
# Generated receipt contains required SHA-256 content digests
'''^frontend-modern/browser-verification\.json$''',
# tmp/ is gitignored but shows up in --no-git scans
'''^tmp/''',
]

View file

@ -1,33 +1,27 @@
{
"version": 1,
"base_sha": "66388197430a093cc707b3a914845dcfcbd17d02",
"verified_at": "2026-08-05T22:41:40Z",
"base_sha": "b724dc1a9d7e2683bd8a8e0a3738aa1624c538d2",
"verified_at": "2026-08-05T23:01:14Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts"],
"changed_paths": ["frontend-modern/src/components/Settings/APITokenManager.tsx"],
"content_sha256": {
"frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts": "538ce70e4c8efebe6ea28657c915ea94401bebd460fba4b46b49aa9e643e08e0"
"frontend-modern/src/components/Settings/APITokenManager.tsx": "86eb27e2ddea78dfef789df8422fab56b25e1ad1f7679c967265f430ef168758"
},
"routes": ["/proxmox/overview", "/docker/overview"],
"routes": ["/settings/security/api"],
"viewports": [
{ "width": 1440, "height": 900 },
{ "width": 768, "height": 1024 },
{ "width": 1920, "height": 900 },
{ "width": 390, "height": 844 }
],
"states": [
"workloads table in Trends mode with memory relative to Host capacity, 1h range",
"workloads table in Trends mode with memory relative to Guest allocation, 1h range",
"workloads table in Trends mode, 24h range with 48 sampled points per series",
"workloads table in Bars mode, stacked memory bars unaffected by the scale change",
"Proxmox nodes table CPU, memory and disk trend cells",
"memory sparkline hover tooltip showing Host memory share at a sampled timestamp",
"Docker overview confirmed to carry no mini sparkline cells"
"desktop API token inventory with the scope editor opened and dismissed",
"phone API token cards with the scope editor contained inside the viewport",
"phone revoke confirmation opened and dismissed",
"scope editor measured at 358 pixels wide with zero document overflow"
],
"interactions": [
"opened the View menu and switched Metrics between Bars and Trends",
"switched Memory relative to between Guest allocation and Host capacity",
"switched Trend range between 1h and 24h",
"hovered a guest memory sparkline and read the tooltip value and cursor line",
"read rendered path geometry per row to confirm the trend line clears the axis rule",
"reloaded each route and checked the browser console for errors"
"opened Edit scopes and cancelled with the Cancel button at desktop and phone widths",
"opened Edit scopes and dismissed it with Escape at phone width",
"opened Revoke and cancelled the confirmation at phone width",
"confirmed each close path returned keyboard focus to its exact triggering button"
]
}

View file

@ -33,6 +33,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
const [tokenToRevoke, setTokenToRevoke] = createSignal<APITokenRecord | null>(null);
const [tokenToEdit, setTokenToEdit] = createSignal<APITokenRecord | null>(null);
const [editScopes, setEditScopes] = createSignal<string[]>([]);
let dialogTrigger: HTMLButtonElement | undefined;
const {
API_SCOPE_LABELS,
API_TOKEN_SCOPES_DOC_URL,
@ -80,15 +81,34 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
const effectiveScopes = (token: APITokenRecord) =>
token.scopes && token.scopes.length > 0 ? token.scopes : ['*'];
const openScopeEditor = (token: APITokenRecord) => {
const rememberDialogTrigger = (trigger: HTMLButtonElement) => {
dialogTrigger = trigger;
};
const restoreDialogTrigger = () => {
const trigger = dialogTrigger;
dialogTrigger = undefined;
queueMicrotask(() => {
if (trigger?.isConnected && !trigger.disabled) trigger.focus();
});
};
const openScopeEditor = (token: APITokenRecord, trigger: HTMLButtonElement) => {
rememberDialogTrigger(trigger);
setEditScopes([...effectiveScopes(token)]);
setTokenToEdit(token);
};
const openRevokeDialog = (token: APITokenRecord, trigger: HTMLButtonElement) => {
rememberDialogTrigger(trigger);
setTokenToRevoke(token);
};
const closeScopeEditor = () => {
if (updatingTokenId() !== null) return;
setTokenToEdit(null);
setEditScopes([]);
restoreDialogTrigger();
};
const toggleEditScope = (scope: string) => {
@ -119,14 +139,21 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
if (await handleUpdateScopes(token, editScopes())) {
setTokenToEdit(null);
setEditScopes([]);
restoreDialogTrigger();
}
};
const revokeToken = (token: APITokenRecord) => {
setTokenToRevoke(null);
restoreDialogTrigger();
void handleDelete(token);
};
const cancelRevoke = () => {
setTokenToRevoke(null);
restoreDialogTrigger();
};
const renderTokenScopes = (token: APITokenRecord) => {
const rawScopes = effectiveScopes(token);
const scopeBadges = rawScopes.includes('*')
@ -212,7 +239,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
>
<button
type="button"
onClick={() => openScopeEditor(token)}
onClick={(event) => openScopeEditor(token, event.currentTarget)}
disabled={!canManage()}
class={`inline-flex min-h-10 items-center justify-center rounded-md px-2.5 py-1.5 text-sm font-semibold text-blue-600 transition hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-blue-300 dark:hover:bg-blue-900 dark:hover:text-blue-200 ${
compact ? 'flex-1 border border-blue-200 dark:border-blue-800' : 'sm:min-h-9'
@ -222,7 +249,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
</button>
<button
type="button"
onClick={() => setTokenToRevoke(token)}
onClick={(event) => openRevokeDialog(token, event.currentTarget)}
disabled={!canManage()}
class={`inline-flex min-h-10 items-center justify-center rounded-md px-2.5 py-1.5 text-sm font-semibold text-red-600 transition hover:bg-red-50 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-300 ${
compact ? 'flex-1 border border-red-200 dark:border-red-900' : 'sm:min-h-9'
@ -710,7 +737,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
onToggleEditScope={toggleEditScope}
onCloseScopeEditor={closeScopeEditor}
onSaveEditedScopes={() => void saveEditedScopes()}
onCancelRevoke={() => setTokenToRevoke(null)}
onCancelRevoke={cancelRevoke}
onRevoke={revokeToken}
/>
</Show>

View file

@ -633,6 +633,41 @@ describe('APITokenManager', () => {
expect(within(updatedRow).queryByText('Docker / Podman reporting')).not.toBeInTheDocument();
});
it('returns focus to token action triggers after dialogs close', async () => {
listTokensMock.mockResolvedValue([
makeToken({
id: 'token-focus-return',
name: 'Keyboard token',
scopes: [DOCKER_REPORT_SCOPE],
}),
]);
render(() => <APITokenManager onTokensChanged={vi.fn()} canManage />);
const row = await findTokenTableRow('Keyboard token');
const trigger = within(row).getByRole('button', { name: 'Edit scopes' });
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog', { name: 'Edit API token scopes' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(trigger).toHaveFocus());
fireEvent.click(trigger);
await screen.findByRole('dialog', { name: 'Edit API token scopes' });
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => expect(trigger).toHaveFocus());
const revokeTrigger = within(row).getByRole('button', { name: 'Revoke' });
fireEvent.click(revokeTrigger);
const revokeDialog = await screen.findByRole('dialog', { name: 'Revoke API token' });
fireEvent.click(within(revokeDialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(revokeTrigger).toHaveFocus());
});
it('requires at least one changed scope and keeps failed edits open', async () => {
listTokensMock.mockResolvedValue([
makeToken({