mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-10 17:46:15 +00:00
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:
parent
b724dc1a9d
commit
c42399e2c3
4 changed files with 82 additions and 24 deletions
|
|
@ -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/''',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue