diff --git a/frontend/package.json b/frontend/package.json index 922f4b51e..ffebd8e89 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -58,6 +58,7 @@ "jest": { "preset": "ts-jest", "testEnvironment": "jsdom", + "globalSetup": "/src/__tests__/globalSetup.ts", "setupFilesAfterEnv": [ "/src/__tests__/setup.ts" ], diff --git a/frontend/src/__tests__/api.test.ts b/frontend/src/__tests__/api.test.ts index ab0541056..af7174aca 100644 --- a/frontend/src/__tests__/api.test.ts +++ b/frontend/src/__tests__/api.test.ts @@ -378,25 +378,32 @@ describe('API Requests', () => { } }); - // Q3: defence-in-depth — even if Q1 somehow misses a nil-body case, - // the frontend tolerates empty / malformed 2xx bodies without - // crashing with SyntaxError. - test('tolerates empty 2xx body and resolves to null', async () => { + test('resolves a 204 No Content response to null', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 204, + json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')), + }); + await expect(apiRequest('/test')).resolves.toBeNull(); + }); + + test('resolves a content-length 0 response to null', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200, + headers: new Headers({ 'content-length': '0' }), json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')), }); await expect(apiRequest('/test')).resolves.toBeNull(); }); - test('tolerates malformed 2xx body and resolves to null', async () => { + test('rejects a malformed 2xx body instead of resolving null (#273)', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200, json: () => Promise.reject(new SyntaxError('Unexpected token in JSON at position 0')), }); - await expect(apiRequest('/test')).resolves.toBeNull(); + await expect(apiRequest('/test')).rejects.toThrow(SyntaxError); }); test('surfaces a timeout error when the backend hangs past timeoutMs (issue #20)', async () => { diff --git a/frontend/src/__tests__/app.test.ts b/frontend/src/__tests__/app.test.ts index 731f4eafd..b20799121 100644 --- a/frontend/src/__tests__/app.test.ts +++ b/frontend/src/__tests__/app.test.ts @@ -35,6 +35,7 @@ jest.mock('../dashboard', () => ({ jest.mock('../navigation', () => ({ switchTab: jest.fn(), + setActiveNav: jest.fn(), applyTabFromPath: jest.fn().mockReturnValue('dashboard'), initRouter: jest.fn(), switchSettingsSubTab: jest.fn(), @@ -113,6 +114,17 @@ describe('App Module', () => { expect(auth.updateUserUI).toHaveBeenCalled(); }); + test('highlights the nav from the URL before any async fetch resolves (#187)', async () => { + (api.isAuthenticated as jest.Mock).mockReturnValue(true); + (api.getCurrentUser as jest.Mock).mockReturnValue(new Promise(() => {})); + (navigation.applyTabFromPath as jest.Mock).mockReturnValue('opportunities'); + + void init(); + await Promise.resolve(); + + expect(navigation.setActiveNav).toHaveBeenCalledWith('opportunities'); + }); + test('seeds the URL from canonicalTabPath before routing', async () => { (api.isAuthenticated as jest.Mock).mockReturnValue(true); (api.getCurrentUser as jest.Mock).mockResolvedValue({ id: 'user-1', email: 'test@example.com' }); diff --git a/frontend/src/__tests__/auth.test.ts b/frontend/src/__tests__/auth.test.ts index cede48c71..0aa919c8d 100644 --- a/frontend/src/__tests__/auth.test.ts +++ b/frontend/src/__tests__/auth.test.ts @@ -446,6 +446,21 @@ describe('Auth Module', () => { expect((userInfo as HTMLElement).classList.contains('hidden')).toBe(true); }); + test('keeps the profile button hidden until a user is set, then shows it', () => { + const btn = document.getElementById('user-email-display') as HTMLElement; + (state.getCurrentUser as jest.Mock).mockReturnValue(null); + updateUserUI(); + expect(btn.hidden).toBe(true); + + (state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'u', email: 'a@example.com', groups: [] }); + updateUserUI(); + expect((document.getElementById('user-email-display') as HTMLElement).hidden).toBe(false); + + (state.getCurrentUser as jest.Mock).mockReturnValue(null); + updateUserUI(); + expect((document.getElementById('user-email-display') as HTMLElement).hidden).toBe(true); + }); + test('shows admin-only elements for admin users', () => { (state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'admin-1', diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 1a6e60e02..f72cd84a7 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -310,6 +310,17 @@ describe('Dashboard Module', () => { expect(summary?.innerHTML).toContain('Failed to load dashboard'); }); + test('names an empty summary response instead of throwing a TypeError (#280)', async () => { + (api.getDashboardSummary as jest.Mock).mockResolvedValue(null); + console.error = jest.fn(); + + await loadDashboard(); + + const text = document.getElementById('summary')?.textContent ?? ''; + expect(text).toContain('Dashboard summary response was empty'); + expect(text).not.toContain('Cannot read properties of null'); + }); + // Issue #344 T3: skeleton lifecycle — skeleton renders synchronously // at fetch start, then is replaced by the success render (clean // handoff) or torn down on error so it never sits next to a stale @@ -1640,6 +1651,20 @@ describe('Dashboard Module', () => { expect(totalLine).toMatch(/\$400/); }); + test('tooltip shows the committed value for a service with a commitment but no recommendation (#752)', () => { + buildDOM(); + renderSavingsByService( + [rec('ec2', 100, 1, 'no_upfront')], + { sqldb: { potential_savings: 0, current_savings: 250 } }, + ); + const chartCtor = Chart as unknown as jest.Mock; + const lastCall = chartCtor.mock.calls[chartCtor.mock.calls.length - 1]; + const label = (lastCall?.[1] as { + options: { plugins: { tooltip: { callbacks: { label: (ctx: { label: string; dataset: { label: string } }) => string } } } }; + }).options.plugins.tooltip.callbacks.label; + expect(label({ label: 'sqldb', dataset: { label: 'Current / Committed' } })).toMatch(/Current \/ Committed: \$250/); + }); + // Issue #908: merged chart draws a current-savings bottom layer per service. test('renders Current / Committed layer from byService.current_savings', () => { buildDOM(); diff --git a/frontend/src/__tests__/globalSetup.ts b/frontend/src/__tests__/globalSetup.ts new file mode 100644 index 000000000..0305c5e2d --- /dev/null +++ b/frontend/src/__tests__/globalSetup.ts @@ -0,0 +1,6 @@ +// Run every suite outside UTC so code that mixes local and UTC dates cannot +// pass on a UTC CI runner by coincidence. New York is UTC-4/-5, so its +// calendar day trails the UTC day for several hours each evening. +export default function globalSetup(): void { + process.env['TZ'] = 'America/New_York'; +} diff --git a/frontend/src/__tests__/groups.test.ts b/frontend/src/__tests__/groups.test.ts index 0ebe7f9b2..211de0534 100644 --- a/frontend/src/__tests__/groups.test.ts +++ b/frontend/src/__tests__/groups.test.ts @@ -1260,7 +1260,8 @@ describe('groups/groupModals — duplicate', () => { describe('11-M3 max_amount validation in collectPermissions', () => { // Access collectPermissions indirectly via saveGroup and observe API call. - it('rejects Infinity max_amount (does not forward it to API)', async () => { + it('accepts a valid positive max_amount', async () => { + document.body.innerHTML = ''; const modal = document.createElement('div'); modal.id = 'group-modal'; modal.innerHTML = ` @@ -1274,7 +1275,7 @@ describe('11-M3 max_amount validation in collectPermissions', () => { - + `; @@ -1285,14 +1286,11 @@ describe('11-M3 max_amount validation in collectPermissions', () => { const event = new Event('submit', { cancelable: true }); await groupModals.saveGroup(event); - expect(api.createGroup).toHaveBeenCalled(); const call = (api.createGroup as jest.Mock).mock.calls[0][0] as { permissions: Array<{ constraints?: { max_amount?: number } }> }; - // max_amount must not be Infinity (non-finite rejected). - const maxAmount = call.permissions[0]?.constraints?.max_amount; - expect(maxAmount).toBeUndefined(); + expect(call.permissions[0]?.constraints?.max_amount).toBe(5000); }); - it('accepts a valid positive max_amount', async () => { + it('rejects negative max_amount (does not forward it to API)', async () => { document.body.innerHTML = ''; const modal = document.createElement('div'); modal.id = 'group-modal'; @@ -1307,7 +1305,7 @@ describe('11-M3 max_amount validation in collectPermissions', () => { - + `; @@ -1318,15 +1316,15 @@ describe('11-M3 max_amount validation in collectPermissions', () => { const event = new Event('submit', { cancelable: true }); await groupModals.saveGroup(event); - const call = (api.createGroup as jest.Mock).mock.calls[0][0] as { permissions: Array<{ constraints?: { max_amount?: number } }> }; - expect(call.permissions[0]?.constraints?.max_amount).toBe(5000); + // Negative value must be refused, not silently dropped (#133). + expect(api.createGroup).not.toHaveBeenCalled(); + expect(document.querySelector('.toast-error')?.textContent).toContain( + 'Max Amount must be greater than 0; leave it blank for no limit (permission 0).' + ); + expect(document.querySelector('.toast-error')?.textContent).not.toContain('Repair'); }); - it('rejects negative max_amount (does not forward it to API)', async () => { - document.body.innerHTML = ''; - const modal = document.createElement('div'); - modal.id = 'group-modal'; - modal.innerHTML = ` + const maxAmountForm = (attrs: string): string => ` @@ -1334,22 +1332,42 @@ describe('11-M3 max_amount validation in collectPermissions', () => {
- - - - +
`; - document.body.appendChild(modal); + const MAX_AMOUNT_MESSAGE = 'Max Amount must be greater than 0; leave it blank for no limit (permission 0).'; + + it.each(['0', '-100'])('refuses max_amount %s instead of saving without the cap (#133)', async raw => { + document.body.innerHTML = maxAmountForm(`value="${raw}"`); + await groupModals.saveGroup(new Event('submit', { cancelable: true })); + expect(api.createGroup).not.toHaveBeenCalled(); + expect(document.querySelector('.toast-error')?.textContent).toContain(MAX_AMOUNT_MESSAGE); + expect(document.querySelector('.toast-error')?.textContent).not.toContain('Repair'); + }); - (api.createGroup as jest.Mock).mockResolvedValue({ id: 'new-id', name: 'Test', description: '', permissions: [] }); + // Browsers report unparsable text ("abc", "1e", "1e999") as value "" with + // validity.badInput set; without that check the cap would be silently dropped. + it('refuses unparsable number-input text (badInput) instead of saving without the cap (#133)', async () => { + document.body.innerHTML = maxAmountForm(''); + const input = document.querySelector('.perm-max-amount') as HTMLInputElement; + Object.defineProperty(input, 'validity', { value: { badInput: true } }); + expect(input.value).toBe(''); + await groupModals.saveGroup(new Event('submit', { cancelable: true })); + expect(api.createGroup).not.toHaveBeenCalled(); + expect(document.querySelector('.toast-error')?.textContent).toContain(MAX_AMOUNT_MESSAGE); + }); - const event = new Event('submit', { cancelable: true }); - await groupModals.saveGroup(event); + it('still saves a blank max_amount as no cap', async () => { + document.body.innerHTML = maxAmountForm(''); + (api.createGroup as jest.Mock).mockResolvedValue({ id: 'x', name: 'Test', description: '', permissions: [] }); + await groupModals.saveGroup(new Event('submit', { cancelable: true })); + expect(api.createGroup).toHaveBeenCalled(); + }); - const call = (api.createGroup as jest.Mock).mock.calls[0][0] as { permissions: Array<{ constraints?: { max_amount?: number } }> }; - // Negative value must be silently skipped. - expect(call.permissions[0]?.constraints?.max_amount).toBeUndefined(); + it('shows a stored max_amount of 0 in the edit form (#133)', () => { + document.body.innerHTML = '
'; + groupModals.addPermission({ action: 'execute', resource: 'purchases', constraints: { max_amount: 0 } }); + expect((document.querySelector('.perm-max-amount') as HTMLInputElement).value).toBe('0'); }); }); diff --git a/frontend/src/__tests__/html.test.ts b/frontend/src/__tests__/html.test.ts index 8be1d4622..463cc4889 100644 --- a/frontend/src/__tests__/html.test.ts +++ b/frontend/src/__tests__/html.test.ts @@ -783,5 +783,13 @@ describe('HTML Structure', () => { // Link target must be the AWS console role-creation wizard. expect(link?.getAttribute('href')).toBe('https://console.aws.amazon.com/iam/home#/roles$new'); }); + + test('profile entry point is a keyboard-focusable button (#715)', () => { + const el = document.getElementById('user-email-display'); + expect(el?.tagName.toLowerCase()).toBe('button'); + expect(el?.getAttribute('type')).toBe('button'); + // Inert until updateUserUI shows it with the signed-in email. + expect(el?.hasAttribute('hidden')).toBe(true); + }); }); }); diff --git a/frontend/src/__tests__/inventory.test.ts b/frontend/src/__tests__/inventory.test.ts index 010177181..d8feb5bba 100644 --- a/frontend/src/__tests__/inventory.test.ts +++ b/frontend/src/__tests__/inventory.test.ts @@ -383,6 +383,17 @@ describe('loadActiveCommitments — fetch + render flow', () => { expect(api.listActiveCommitments).toHaveBeenCalledTimes(2); }); + test('does not repeat the account id when the name already contains it (#179)', async () => { + (api.listActiveCommitments as jest.Mock).mockResolvedValue([ + makeCommitment({ account_name: 'CUDly host (acct-1234)', account_id: 'acct-1234' }), + ]); + + await loadActiveCommitments(); + + const accountCell = document.querySelector('#active-commitments-list tbody tr td:nth-child(2)'); + expect(accountCell?.textContent).toBe('CUDly host (acct-1234)'); + }); + test('falls back to monospaced account_id when account_name is missing', async () => { (api.listActiveCommitments as jest.Mock).mockResolvedValue([ makeCommitment({ account_name: undefined, account_id: 'acc-no-name' }), diff --git a/frontend/src/__tests__/mobile-nav.test.ts b/frontend/src/__tests__/mobile-nav.test.ts index 481894e78..efb795077 100644 --- a/frontend/src/__tests__/mobile-nav.test.ts +++ b/frontend/src/__tests__/mobile-nav.test.ts @@ -18,7 +18,7 @@ function buildDOM(): void {

CUDly

- + API Docs @@ -27,6 +27,7 @@ function buildDOM(): void {
@@ -401,6 +409,29 @@ function unrepresentablePermissionErrors(): string[] { return errors; } +// Max Amount is typed by the admin, unlike the stored-value problems above, so +// its message tells them how to fix the field instead of pointing at the API. +// 0 would read as "no cap" at enforcement, so it is refused rather than sent. +function maxAmountInputErrors(): string[] { + const errors: string[] = []; + document.querySelectorAll('#permissions-list .permission-item').forEach((item, index) => { + const input = item.querySelector('.perm-max-amount') as HTMLInputElement | null; + // A number input reports unparsable text ("abc", "1e") as value "", which + // would read as blank and save with no cap; badInput is the only signal. + if (input?.validity?.badInput) { + errors.push(`permission ${index}`); + return; + } + const raw = input?.value ?? ''; + if (raw.trim() === '') return; + const amount = Number(raw); + if (!Number.isFinite(amount) || amount <= 0) { + errors.push(`permission ${index}`); + } + }); + return errors; +} + /** * Render permissions list */ @@ -463,17 +494,7 @@ function collectPermissions(): Permission[] { if (providersList.length > 0) permission.constraints.providers = providersList; if (servicesList.length > 0) permission.constraints.services = servicesList; if (regionsList.length > 0) permission.constraints.regions = regionsList; - if (maxAmount) { - const parsed = parseFloat(maxAmount); - // Reject non-finite or negative values (feedback_nullable_not_zero). - // A malformed entry is silently skipped so the rest of the constraints - // still reach the API; the input's type="number" min="0" already - // prevents browser submission of non-numeric values, but the JS - // path must guard too. - if (Number.isFinite(parsed) && parsed >= 0) { - permission.constraints.max_amount = parsed; - } - } + if ((maxAmount ?? '').trim() !== '') permission.constraints.max_amount = Number(maxAmount); // Nothing survived: the permission carries no restriction, so send no // constraints object rather than an empty one. if (Object.keys(permission.constraints).length === 0) { diff --git a/frontend/src/index.html b/frontend/src/index.html index 5d0c7b317..da2843aa7 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -41,7 +41,7 @@

CUDly

- + API Docs diff --git a/frontend/src/inventory.ts b/frontend/src/inventory.ts index 81d80de63..43926d1c2 100644 --- a/frontend/src/inventory.ts +++ b/frontend/src/inventory.ts @@ -322,7 +322,9 @@ function mountInventoryAmortizeCheckbox(): void { function buildAccountCell(c: api.InventoryCommitment): HTMLTableCellElement { const td = document.createElement('td'); - if (c.account_name) { + if (c.account_name && c.account_name.includes(c.account_id)) { + td.textContent = c.account_name; + } else if (c.account_name) { td.appendChild(document.createTextNode(c.account_name + ' ')); const id = document.createElement('span'); id.className = 'monospace'; diff --git a/frontend/src/lib/purchase-compatibility.ts b/frontend/src/lib/purchase-compatibility.ts index f6ac892a0..f630ba1e2 100644 --- a/frontend/src/lib/purchase-compatibility.ts +++ b/frontend/src/lib/purchase-compatibility.ts @@ -28,6 +28,12 @@ export function isPaymentSupported( payment: Payment, ): boolean { if (provider === 'aws') { + // The backend accepts only these three tokens for AWS + // (config.ValidPaymentOptionsByProvider); 'monthly' and 'upfront' + // are Azure/GCP tokens and would be rejected with a 400. + if (payment !== 'all-upfront' && payment !== 'partial-upfront' && payment !== 'no-upfront') { + return false; + } // AWS RDS 3-year RIs don't support no-upfront — only all-upfront // and partial-upfront are offered by the provider. Surfacing // no-upfront here would produce zero recommendations for the @@ -45,10 +51,11 @@ export function isPaymentSupported( return payment === 'all-upfront' || payment === 'upfront' || payment === 'monthly'; } - // GCP CUDs accept the same three options as Azure per - // providers/gcp/services/computeengine/client.go:554. + // GCP CUDs are billed monthly only: the backend accepts just 'monthly' and + // rewrites every other token to it (config.ValidPaymentOptionsByProvider, + // internal/config/validation.go), so offering an upfront option misleads. if (provider === 'gcp') { - return payment === 'all-upfront' || payment === 'upfront' || payment === 'monthly'; + return payment === 'monthly'; } return true; diff --git a/frontend/src/navigation.ts b/frontend/src/navigation.ts index abc0408dc..dc71108a7 100644 --- a/frontend/src/navigation.ts +++ b/frontend/src/navigation.ts @@ -134,6 +134,15 @@ function confirmLeaveSettings(): Promise { }); } +/** Mark the sidebar item for tabName active, without loading the tab. */ +export function setActiveNav(tabName: string): void { + document.querySelectorAll('.tab-btn').forEach(btn => { + const isActive = btn.dataset['tab'] === tabName; + btn.classList.toggle('active', isActive); + btn.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); +} + export function switchTab(tabName: string, opts: SwitchTabOptions = {}): void { if (!isKnownKey(TABS, tabName)) tabName = 'home'; @@ -151,11 +160,7 @@ export function switchTab(tabName: string, opts: SwitchTabOptions = {}): void { return; } - document.querySelectorAll('.tab-btn').forEach(btn => { - const isActive = btn.dataset['tab'] === tabName; - btn.classList.toggle('active', isActive); - btn.setAttribute('aria-selected', isActive ? 'true' : 'false'); - }); + setActiveNav(tabName); document.querySelectorAll('.tab-content').forEach(content => { content.classList.toggle('active', content.id === `${tabName}-tab`); diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index 7145826f5..2fec43667 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -755,11 +755,11 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string { ${savingsCell} ${escapeHtml(purchase.status)} - ${canRunPurchase ? `` : ''} - ${canPauseOrResumePurchase && isPending ? `` : ''} - ${canPauseOrResumePurchase && isPaused ? `` : ''} - ${canEditPlan ? `` : ''} - ${canDisablePlan ? `` : ''} + ${canRunPurchase ? `` : ''} + ${canPauseOrResumePurchase && isPending ? `` : ''} + ${canPauseOrResumePurchase && isPaused ? `` : ''} + ${canEditPlan ? `` : ''} + ${canDisablePlan ? `` : ''} `; @@ -1048,18 +1048,6 @@ function extractPlanInfo(plan: BackendPlan): { provider: string | null; service: return { provider: null, service: '—', term: null, coverage: null }; } -// Format a Date as YYYY-MM-DD in the user's *local* calendar — the format -// expected by .value and .min. Built from local -// year/month/day components so we don't shift by the user's UTC offset -// (e.g. a US user at 6pm local already crosses into UTC's next day, so -// toISOString() would return the wrong calendar day for a date-picker). -function toLocalDateInputValue(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${y}-${m}-${day}`; -} - // Returns true when the plan's next_execution_date is strictly before today. function isPlanOverdue(plan: BackendPlan): boolean { if (!plan.next_execution_date) return false; @@ -2268,7 +2256,7 @@ export async function openAddPurchasesModal(planId: string, planName: string, no const startDateInput = document.getElementById('add-purchases-start-date') as HTMLInputElement; const now = new Date(); const validNotice = Number.isInteger(notificationDays) && notificationDays >= 0 && notificationDays <= 30; - startDateInput.min = toLocalDateInputValue(now); + startDateInput.min = earliestStartDate(now); if (validNotice) { // The API parses date-only values at UTC midnight. The extra calendar // day avoids opening just before the notification/veto overlap ends. @@ -2335,6 +2323,14 @@ function closeAddPurchasesModal(): void { modal?.remove(); } +/** + * First accepted start date. The server parses start_date as midnight UTC and + * requires it after now, so tomorrow (UTC) is the earliest (handler_plans.go, #50). + */ +function earliestStartDate(now: Date): string { + return new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); +} + /** * Handle form submission for adding planned purchases */ @@ -2358,6 +2354,13 @@ async function handleAddPurchases(e: Event): Promise { } const count = rawCount; const startDate = (document.getElementById('add-purchases-start-date') as HTMLInputElement).value; + if (startDate < earliestStartDate(new Date())) { + if (errorDiv) { + errorDiv.textContent = 'Start date must be in the future (after today UTC)'; + errorDiv.classList.remove('hidden'); + } + return; + } await api.createPlannedPurchases(planId, count, startDate); diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 6f44b1be9..e7f1ce4ab 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -3578,13 +3578,20 @@ function normalizeBulkPayment(payment: string | undefined): BulkPurchasePayment // compatible iff EVERY rec in it has a supported (provider, service, // term, payment) combination. Used by the bulk-buy fan-out path to // flag buckets the user has built but won't be allowed to submit. +// A rec's own supported payment also qualifies: the single-bucket modal seeds +// from it (resolvePerRecPaymentSeed), so a GCP rec priced monthly must not be +// forced into fan-out by the all-upfront toolbar default. function isBucketPaymentCompatible( recs: readonly LocalRecommendation[], payment: BulkPurchasePayment, ): boolean { - return recs.every((r) => - isPaymentSupported(r.provider as CompatProvider, r.service, r.term as 1 | 3, payment), - ); + return recs.every((r) => { + const provider = r.provider as CompatProvider; + const term = r.term as 1 | 3; + const own = normalizeBulkPayment(r.payment); + return isPaymentSupported(provider, r.service, term, payment) + || (own !== null && isPaymentSupported(provider, r.service, term, own)); + }); } interface BulkPurchaseToolbarState { diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css index 9b4491af8..55708f7ff 100644 --- a/frontend/src/styles/layout.css +++ b/frontend/src/styles/layout.css @@ -227,6 +227,15 @@ header h1 { opacity: 1; } +#user-email-display { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: inherit; +} + #logout-btn { padding: var(--cudly-sp-2) var(--cudly-sp-3); border-radius: var(--cudly-r-sm); diff --git a/frontend/src/users/userActions.ts b/frontend/src/users/userActions.ts index 8827509d0..cc7d7e7ec 100644 --- a/frontend/src/users/userActions.ts +++ b/frontend/src/users/userActions.ts @@ -11,7 +11,6 @@ import { selectedUserIds, setAllUsers, setAvailableGroups, - clearSelectedUserIds, removeSelectedUserId } from './state'; import { showError, showSuccess, validateGroupCombination } from './utils'; @@ -168,18 +167,26 @@ export async function bulkDeleteUsers(): Promise { }); if (!ok) return; - try { - // Delete users in parallel - await Promise.all( - Array.from(selectedUserIds).map(userId => api.deleteUser(userId)) - ); + const ids = Array.from(selectedUserIds); + const labels = new Map(allUsers.map(u => [u.id, u.email])); + const results = await Promise.allSettled(ids.map(userId => api.deleteUser(userId))); - clearSelectedUserIds(); - await loadUsers(); + const failed: string[] = []; + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + removeSelectedUserId(ids[i]!); + } else { + console.error('Failed to delete user:', ids[i], result.reason); + failed.push(labels.get(ids[i]!) ?? ids[i]!); + } + }); + + await loadUsers(); + + if (failed.length > 0) { + showError(`Failed to delete ${failed.length} of ${ids.length} user(s): ${failed.join(', ')}`); + } else { showSuccess(`Successfully deleted ${count} user(s)`); - } catch (error) { - console.error('Failed to delete users:', error); - showError('Failed to delete some users'); } }