Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions frontend/src/__tests__/a11y.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,27 @@ describe('accessibility smoke', () => {
const deleteBtn = container.querySelector('.btn-destructive') as HTMLButtonElement | null;
expect(deleteBtn?.getAttribute('aria-label')).toBe('Delete Prod (111222333)');
});

it('row actions are icon buttons with a per-account name and a tooltip', async () => {
const container = document.createElement('div');
container.id = 'aws-accounts-list';
document.body.appendChild(container);

(api.listAccounts as jest.Mock).mockResolvedValue([
{ id: 'a1', name: 'Prod', provider: 'aws', external_id: '111222333', enabled: true },
]);

await loadAccountsForProvider('aws');

const buttons = Array.from(container.querySelectorAll<HTMLButtonElement>('td.account-actions .row-actions button'));
expect(buttons.map(b => [b.getAttribute('aria-label'), b.title, b.querySelector('svg.icon')?.getAttribute('class')])).toEqual([
['Edit Prod (111222333)', 'Edit account', 'icon icon-edit'],
['Test credentials for Prod (111222333)', 'Test credentials', 'icon icon-zap'],
['Service overrides for Prod (111222333)', 'Service overrides', 'icon icon-sliders'],
['Delete Prod (111222333)', 'Delete account', 'icon icon-trash-2'],
]);
for (const b of buttons) expect(b.classList.contains('icon-btn')).toBe(true);
});
});

describe('RI Exchange settings deep-link', () => {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/__tests__/icons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const ALL: IconName[] = [
'alert-triangle', 'check', 'check-circle', 'chevron-down', 'chevron-right',
'chevron-up', 'chevrons-up-down', 'copy', 'download', 'edit', 'filter', 'info',
'pause', 'play', 'rotate-cw', 'settings', 'slash', 'trash-2', 'x', 'x-circle',
// Admin account / registration row actions.
'sliders', 'zap',
];

function parse(markup: string): SVGSVGElement {
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/__tests__/registrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,37 @@ describe('loadRegistrations default filter', () => {
expect(api.listRegistrations).toHaveBeenCalledWith(undefined);
});
});

describe('registration row actions', () => {
beforeEach(() => {
document.body.replaceChildren();
jest.clearAllMocks();
});

test('Approve stays a text button; Reject and Delete are named icon buttons in one row', async () => {
const container = document.createElement('div');
container.id = 'registrations-list';
document.body.appendChild(container);
(api.listRegistrations as jest.Mock).mockResolvedValue([{
id: 'r1', provider: 'aws', account_name: 'Checkout', external_id: '650412398877',
contact_email: 'c@example.com', status: 'pending', created_at: '2026-10-28T12:00:00Z',
}]);

await loadRegistrations();

const actions = container.querySelector('tbody tr td:last-child .row-actions')!;
const buttons = Array.from(actions.querySelectorAll('button'));
expect(buttons.map(b => b.getAttribute('aria-label'))).toEqual([
'Approve registration Checkout',
'Reject registration Checkout',
'Delete registration Checkout',
]);
expect(buttons[0]!.textContent).toBe('Approve');
for (const [btn, icon, title] of [[buttons[1]!, 'x-circle', 'Reject registration'], [buttons[2]!, 'trash-2', 'Delete registration']] as const) {
expect(btn.classList.contains('icon-btn')).toBe(true);
expect(btn.querySelector(`svg.icon-${icon}`)).not.toBeNull();
expect(btn.title).toBe(title);
}
expect(buttons[2]!.classList.contains('btn-danger')).toBe(true);
});
});
14 changes: 14 additions & 0 deletions frontend/src/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ const ICONS = {
['circle', { cx: '12', cy: '12', r: '3' }],
['path', { d: 'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z' }],
],
// Per-account service overrides (Admin > Accounts row action).
sliders: [
['line', { x1: '4', y1: '21', x2: '4', y2: '14' }],
['line', { x1: '4', y1: '10', x2: '4', y2: '3' }],
['line', { x1: '12', y1: '21', x2: '12', y2: '12' }],
['line', { x1: '12', y1: '8', x2: '12', y2: '3' }],
['line', { x1: '20', y1: '21', x2: '20', y2: '16' }],
['line', { x1: '20', y1: '12', x2: '20', y2: '3' }],
['line', { x1: '1', y1: '14', x2: '7', y2: '14' }],
['line', { x1: '9', y1: '8', x2: '15', y2: '8' }],
['line', { x1: '17', y1: '16', x2: '23', y2: '16' }],
],
slash: [
['circle', { cx: '12', cy: '12', r: '10' }],
['line', { x1: '4.93', y1: '4.93', x2: '19.07', y2: '19.07' }],
Expand All @@ -87,6 +99,8 @@ const ICONS = {
['line', { x1: '15', y1: '9', x2: '9', y2: '15' }],
['line', { x1: '9', y1: '9', x2: '15', y2: '15' }],
],
// Test credentials (Admin > Accounts row action).
zap: [['polygon', { points: '13 2 3 14 12 14 11 22 21 10 12 10 13 2' }]],
} as const satisfies Record<string, readonly Shape[]>;

export type IconName = keyof typeof ICONS;
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/lib/row-icon-button.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Compact icon-only row action for the Admin account and registration
* tables, matching the Users / Groups / API keys rows (.icon-btn).
*
* The accessible name comes from aria-label and the tooltip from title.
* The short verb is also kept as visually hidden text so code and tests
* that find a row action by its text ("Delete", "Overrides") keep working.
*/
import { createIcon, type IconName } from '../icons';

export interface RowIconButtonOptions {
icon: IconName;
/** Short verb kept as visually hidden text, e.g. "Delete". */
text: string;
/** Tooltip, e.g. "Delete account". */
title: string;
/** Accessible name naming the row, e.g. "Delete Prod (1234)". */
ariaLabel: string;
/** Extra classes, e.g. the danger variant. */
className?: string;
}

export function rowIconButton(opts: RowIconButtonOptions): HTMLButtonElement {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `btn btn-small icon-btn${opts.className ? ` ${opts.className}` : ''}`;
btn.title = opts.title;
btn.setAttribute('aria-label', opts.ariaLabel);
btn.appendChild(createIcon(opts.icon));
const text = document.createElement('span');
text.className = 'sr-only';
text.textContent = opts.text;
btn.appendChild(text);
return btn;
}
33 changes: 22 additions & 11 deletions frontend/src/modules/registrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { badgeClass, statusLabel, statusTone } from '../lib/status-badge';
import { showToast } from '../toast';
import { confirmDialog } from '../confirmDialog';
import { friendlyError } from '../api/errors';
import { rowIconButton } from '../lib/row-icon-button';

type AccountProvider = 'aws' | 'azure' | 'gcp';

Expand Down Expand Up @@ -138,39 +139,49 @@ function renderRegistrationsTable(container: HTMLElement, regs: AccountRegistrat
row.appendChild(statusTd);

const actionsTd = document.createElement('td');
// One non-wrapping row: Approve stays a small text button (the main
// call to action), Reject and Delete are compact icon buttons.
const actions = document.createElement('div');
actions.className = 'row-actions';
const regLabel = reg.account_name || reg.external_id;
if (reg.status === 'pending') {
const approveBtn = document.createElement('button');
approveBtn.type = 'button';
approveBtn.className = 'btn btn-small btn-primary';
approveBtn.textContent = 'Approve';
approveBtn.setAttribute('aria-label', `Approve registration ${regLabel}`);
approveBtn.addEventListener('click', () => handleApprove(reg));
actionsTd.appendChild(approveBtn);
actions.appendChild(approveBtn);

const rejectBtn = document.createElement('button');
rejectBtn.className = 'btn btn-small btn-danger ml-xs';
rejectBtn.textContent = 'Reject';
const rejectBtn = rowIconButton({
icon: 'x-circle', text: 'Reject', title: 'Reject registration',
ariaLabel: `Reject registration ${regLabel}`, className: 'btn-warning',
});
rejectBtn.addEventListener('click', () => void handleReject(reg));
actionsTd.appendChild(rejectBtn);
actions.appendChild(rejectBtn);
} else if (reg.status === 'approved') {
// After deleteAccount resets the linked registration back to pending, and
// the 000021 migration healed historical drift, an approved row always has
// a live cloud_account_id. Render accordingly.
const span = document.createElement('span');
span.className = 'text-muted';
span.textContent = 'Account created';
actionsTd.appendChild(span);
actions.appendChild(span);
} else if (reg.rejection_reason) {
const span = document.createElement('span');
span.className = 'text-muted';
span.textContent = 'Rejected';
span.title = reg.rejection_reason;
actionsTd.appendChild(span);
actions.appendChild(span);
}
// Delete button (available for any status)
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-small ml-xs';
deleteBtn.textContent = 'Delete';
const deleteBtn = rowIconButton({
icon: 'trash-2', text: 'Delete', title: 'Delete registration',
ariaLabel: `Delete registration ${regLabel}`, className: 'btn-danger',
});
deleteBtn.addEventListener('click', () => void handleDelete(reg));
actionsTd.appendChild(deleteBtn);
actions.appendChild(deleteBtn);
actionsTd.appendChild(actions);

row.appendChild(actionsTd);
tbody.appendChild(row);
Expand Down
93 changes: 48 additions & 45 deletions frontend/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { deleteAccount } from './settings-account-delete';
import { reflectDirtyState } from './settings-subnav';
import { showToast } from './toast';
import { createIcon } from './icons';
import { rowIconButton } from './lib/row-icon-button';
import { isValidCombination, getValidPaymentOptions, getValidTermOptions, getPaymentLabel, getCommitmentConfig, normalizePaymentValue } from './commitmentOptions';
import { openModal, closeModal } from './modal';
import { loadRecommendations } from './recommendations';
Expand Down Expand Up @@ -632,47 +633,39 @@ function renderAccountsList(

const actionsTd = document.createElement('td');
actionsTd.className = 'account-actions';
// Compact icon buttons in one non-wrapping row, like the Users rows.
const actions = document.createElement('div');
actions.className = 'row-actions';

const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn btn-small';
editBtn.textContent = 'Edit';
editBtn.setAttribute('aria-label', `Edit ${accountLabel}`);
const editBtn = rowIconButton({
icon: 'edit', text: 'Edit', title: 'Edit account', ariaLabel: `Edit ${accountLabel}`,
});
editBtn.addEventListener('click', () => openAccountModal(provider, account));
actionsTd.appendChild(editBtn);
actions.appendChild(editBtn);

const testBtn = document.createElement('button');
testBtn.type = 'button';
testBtn.className = 'btn btn-small';
testBtn.textContent = 'Test';
testBtn.setAttribute('aria-label', `Test credentials for ${accountLabel}`);
const testBtn = rowIconButton({
icon: 'zap', text: 'Test', title: 'Test credentials', ariaLabel: `Test credentials for ${accountLabel}`,
});
testBtn.addEventListener('click', () => void testAccount(account.id, accountLabel, testBtn));
actionsTd.appendChild(testBtn);
actions.appendChild(testBtn);

const overridesBtn = document.createElement('button');
overridesBtn.type = 'button';
overridesBtn.className = 'btn btn-small';
overridesBtn.textContent = 'Overrides';
overridesBtn.setAttribute('aria-label', `Service overrides for ${accountLabel}`);
const overridesBtn = rowIconButton({
icon: 'sliders', text: 'Overrides', title: 'Service overrides', ariaLabel: `Service overrides for ${accountLabel}`,
});
overridesBtn.addEventListener('click', () => openAccountOverridesModal(account));
actionsTd.appendChild(overridesBtn);

// Destructive Delete lives in its own subgroup to isolate it from the
// routine actions. A small spacer + btn-destructive class (from P2)
// signal "be careful".
const spacer = document.createElement('span');
spacer.className = 'account-actions-spacer';
spacer.setAttribute('aria-hidden', 'true');
actionsTd.appendChild(spacer);

const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'btn btn-small btn-destructive';
deleteBtn.textContent = 'Delete';
deleteBtn.setAttribute('aria-label', `Delete ${accountLabel}`);
actions.appendChild(overridesBtn);

// Destructive Delete: the subtle danger icon variant, set slightly apart
// from the routine actions. .btn-destructive stays as the hook that
// identifies the row's delete action.
const deleteBtn = rowIconButton({
icon: 'trash-2', text: 'Delete', title: 'Delete account', ariaLabel: `Delete ${accountLabel}`,
className: 'btn-destructive btn-danger account-delete-btn',
});
deleteBtn.addEventListener('click', () => void deleteAccount(account, provider, loadAccountsForProvider));
actionsTd.appendChild(deleteBtn);
actions.appendChild(deleteBtn);

actionsTd.appendChild(actions);
tr.appendChild(actionsTd);
tbody.appendChild(tr);
});
Expand Down Expand Up @@ -1409,13 +1402,29 @@ export async function loadOverridesPanel(accountId: string, panel: HTMLElement,
* clicked — still gets feedback.
*/
async function testAccount(accountId: string, accountLabel: string, btn: HTMLButtonElement): Promise<void> {
const original = btn.textContent;
// The row action is an icon button: progress and result go to its hidden
// text, its tooltip and a data-test-result tint; the toasts carry the
// message itself.
const labelEl = btn.querySelector<HTMLElement>('.sr-only') ?? btn;
const original = labelEl.textContent;
const originalTitle = btn.title;
const show = (text: string, result?: 'ok' | 'failed'): void => {
labelEl.textContent = text;
btn.title = text;
if (result) btn.dataset['testResult'] = result;
};
const restore = (): void => {
labelEl.textContent = original;
btn.title = originalTitle;
delete btn.dataset['testResult'];
btn.disabled = false;
};
btn.disabled = true;
btn.textContent = 'Testing...';
show('Testing...');
showToast({ message: `Testing credentials for ${accountLabel}…`, kind: 'info', timeout: 3_000 });
try {
const result = await api.testAccountCredentials(accountId);
btn.textContent = result.ok ? 'OK' : 'Failed';
show(result.ok ? 'OK' : 'Failed', result.ok ? 'ok' : 'failed');
if (result.ok) {
const detail = result.message ? `: ${result.message}` : '';
showToast({ message: `Credentials OK for ${accountLabel}${detail}`, kind: 'success', timeout: 5_000 });
Expand All @@ -1426,18 +1435,12 @@ async function testAccount(accountId: string, accountLabel: string, btn: HTMLBut
timeout: null,
});
}
setTimeout(() => {
btn.textContent = original;
btn.disabled = false;
}, 3000);
setTimeout(restore, 3000);
} catch (err: unknown) {
btn.textContent = 'Error';
show('Error', 'failed');
const msg = err instanceof Error ? err.message : String(err);
showToast({ message: `Failed to test credentials for ${accountLabel}: ${msg}`, kind: 'error', timeout: null });
setTimeout(() => {
btn.textContent = original;
btn.disabled = false;
}, 3000);
setTimeout(restore, 3000);
}
}

Expand Down
Loading
Loading