diff --git a/frontend/src/__tests__/a11y.test.ts b/frontend/src/__tests__/a11y.test.ts index 0cc472ad0..fd5435718 100644 --- a/frontend/src/__tests__/a11y.test.ts +++ b/frontend/src/__tests__/a11y.test.ts @@ -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('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', () => { diff --git a/frontend/src/__tests__/icons.test.ts b/frontend/src/__tests__/icons.test.ts index 14bbd1814..496ed50b4 100644 --- a/frontend/src/__tests__/icons.test.ts +++ b/frontend/src/__tests__/icons.test.ts @@ -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 { diff --git a/frontend/src/__tests__/registrations.test.ts b/frontend/src/__tests__/registrations.test.ts index 0da3db8ca..c6dac55b6 100644 --- a/frontend/src/__tests__/registrations.test.ts +++ b/frontend/src/__tests__/registrations.test.ts @@ -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); + }); +}); diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 8f6ef09d9..43a2b1be7 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -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' }], @@ -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; export type IconName = keyof typeof ICONS; diff --git a/frontend/src/lib/row-icon-button.ts b/frontend/src/lib/row-icon-button.ts new file mode 100644 index 000000000..87b028307 --- /dev/null +++ b/frontend/src/lib/row-icon-button.ts @@ -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; +} diff --git a/frontend/src/modules/registrations.ts b/frontend/src/modules/registrations.ts index 4a03f35f5..f18679195 100644 --- a/frontend/src/modules/registrations.ts +++ b/frontend/src/modules/registrations.ts @@ -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'; @@ -138,18 +139,26 @@ 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 @@ -157,20 +166,22 @@ function renderRegistrationsTable(container: HTMLElement, regs: AccountRegistrat 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); diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index fc7c5d7e5..eaa848f43 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -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'; @@ -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); }); @@ -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 { - 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('.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 }); @@ -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); } } diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index 5e677adc9..e7a5d6758 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -721,3 +721,57 @@ tr.override-disabled { .override-transfer-account { white-space: nowrap; } + +/* Admin > Accounts row actions: compact icon buttons in one row, as on the + * Users rows. The cell stays a table cell (components.css made it a flex + * box, which pulled its bottom border out of line with the row). */ +.accounts-table td.account-actions { + display: table-cell; + width: 1%; + white-space: nowrap; +} + +/* The sr-only verb inside each icon button is absolutely positioned; give + * it the button as containing block, or inside the sideways-scrolling + * table on phones it lands at the table's far edge and widens the page. */ +.row-actions button.icon-btn { + position: relative; +} + +/* Delete keeps .btn-destructive as its hook but uses the subtle danger look + * (tinted, red icon) instead of a solid red block next to neutral actions; + * it fills red only on hover. A little extra space sets it apart. */ +.row-actions button.icon-btn.btn-destructive { + background: var(--cudly-error-bg); + border-color: var(--cudly-error); + color: var(--cudly-error-fg); +} + +.row-actions button.icon-btn.btn-destructive:hover { + background: var(--cudly-error); + border-color: var(--cudly-error); + color: var(--cudly-primary-fg); +} + +.row-actions .account-delete-btn { + margin-left: var(--cudly-sp-2); +} + +/* Test credentials result, shown for a few seconds after the check. */ +.row-actions button[data-test-result="ok"] { + border-color: var(--cudly-success); + color: var(--cudly-success); +} + +.row-actions button[data-test-result="failed"] { + border-color: var(--cudly-error); + color: var(--cudly-error-fg); +} + +/* Registrations: Approve is the one text button in the row; match the icon + * buttons' height so the row lines up. */ +.row-actions .btn-small.btn-primary { + height: 1.75rem; + margin: 0; + padding: 0 var(--cudly-sp-3); +}