diff --git a/backend/README.md b/backend/README.md index a944854..a93d340 100644 --- a/backend/README.md +++ b/backend/README.md @@ -224,6 +224,7 @@ See also [`docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation. | POST | `/api/scan` | run a live Puppeteer + axe-core scan | | GET | `/api/scan-results?url=` | re-run a scan for a URL (used by deep links) | | GET | `/api/scans` | list saved scans from the user's store | +| DELETE | `/api/scans` | delete the saved reports in body `{ ids }` | | GET | `/api/scans/:id` | load one saved report from the user's store | | DELETE | `/api/scans/:id` | delete one saved report from the user's store | | GET | `/api/problems/:id` | look up a single problem (legacy mock lookup) | diff --git a/backend/controllers/scanController.js b/backend/controllers/scanController.js index 34cd434..10a0af5 100644 --- a/backend/controllers/scanController.js +++ b/backend/controllers/scanController.js @@ -31,6 +31,7 @@ class ScanController { this.getSavedScan = this.getSavedScan.bind(this); this.getSavedScans = this.getSavedScans.bind(this); this.deleteSavedScan = this.deleteSavedScan.bind(this); + this.deleteSavedScans = this.deleteSavedScans.bind(this); this.getProblem = this.getProblem.bind(this); } @@ -174,6 +175,69 @@ class ScanController { } } + /** + * DELETE /api/scans — remove the saved reports named in `{ ids }`. + * Only those ids are deleted, so scans saved from another device after the + * client loaded its list are kept. Keeps the account manifest and repository. + */ + async deleteSavedScans(req, res) { + if ( + typeof req.isAuthenticated !== 'function' || + !req.isAuthenticated() || + !req.user?.storage + ) { + return res.status(401).json({ error: 'Not authenticated' }); + } + if (!this.authService || !this.storageService) { + return res.status(503).json({ error: 'Storage is not configured' }); + } + + try { + const clients = await this.authService.clientsFor(req.user, { + storageRef: req.user.storage, + }); + const result = await this.storageService.deleteScans( + req.user, + req.body?.ids, + clients, + ); + + if (!req.user.account) { + req.user.account = { + settings: { autoDelete90d: true }, + scanCount: 0, + }; + } + req.user.account.scanCount = result.scanCount; + await this.authService.persistUser(req); + + return res.json({ + deletedCount: result.deletedCount, + scanCount: result.scanCount, + scans: result.scans, + }); + } catch (err) { + if (err.code === 'SCAN_IDS_REQUIRED') { + return res.status(400).json({ error: err.message }); + } + if (err.code === 'PROVIDER_NOT_AVAILABLE' || err.status === 501) { + return res.status(501).json({ + error: err.message, + code: err.code, + }); + } + if ( + err.code === 'STORAGE_ACCESS_DENIED' || + err.code === 'STORAGE_IDENTITY_MISMATCH' || + err.status === 403 + ) { + return res.status(403).json({ error: err.message }); + } + console.error(err); + return res.status(500).json({ error: 'Internal server error' }); + } + } + /** * DELETE /api/scans/:id — remove one saved report from attached storage. */ diff --git a/backend/routes/scan.js b/backend/routes/scan.js index dd15992..23318b5 100644 --- a/backend/routes/scan.js +++ b/backend/routes/scan.js @@ -3,6 +3,7 @@ * - POST /api/scan * - GET /api/scan-results * - GET /api/scans + * - DELETE /api/scans (body: { ids }) * - GET /api/scans/:id * - DELETE /api/scans/:id * @@ -21,6 +22,8 @@ function makeScanRouter(controller) { router.post('/scan', controller.postScan); router.get('/scan-results', controller.getScanResults); router.get('/scans', controller.getSavedScans); + // Bulk delete must be registered before /scans/:id. + router.delete('/scans', controller.deleteSavedScans); router.get('/scans/:id', controller.getSavedScan); router.delete('/scans/:id', controller.deleteSavedScan); return router; diff --git a/backend/services/storageService.js b/backend/services/storageService.js index 6249f94..19221f8 100644 --- a/backend/services/storageService.js +++ b/backend/services/storageService.js @@ -1121,6 +1121,145 @@ class StorageService { }; } + /** + * Delete the given saved scans in one commit and refresh index/manifest caches. + * Only the listed ids are touched — scans saved elsewhere since the caller + * loaded its list survive. Ids with no file are treated as already deleted. + * Leaves vizably.json identity and the repository itself intact. + * @param {object} account session user (with storage binding) + * @param {string[]} scanIds + * @param {StorageClients} clients + * @returns {Promise<{ deletedCount: number, scanCount: number, scans: object[] }>} + */ + async deleteScans(account, scanIds, clients) { + if (account?.storage?.provider === 'google') { + const err = new Error(GOOGLE_NOT_AVAILABLE); + err.status = 501; + err.code = 'PROVIDER_NOT_AVAILABLE'; + throw err; + } + if (!clients.githubClient) { + throw new Error('GitHub client is required to delete saved scans'); + } + if ( + !Array.isArray(scanIds) || + scanIds.length === 0 || + !scanIds.every((id) => typeof id === 'string' && id) + ) { + const err = new Error('Scan ids are required'); + err.status = 400; + err.code = 'SCAN_IDS_REQUIRED'; + throw err; + } + + const maxAttempts = 3; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await this._deleteScansOnce(account, new Set(scanIds), clients); + } catch (err) { + const canRetry = this._isRefConflict(err) && attempt < maxAttempts - 1; + if (!canRetry) { + throw err; + } + } + } + + throw new Error('GitHub write failed after retries'); + } + + /** + * @param {object} account + * @param {Set} ids + * @param {StorageClients} clients + * @private + */ + async _deleteScansOnce(account, ids, clients) { + const storageRef = account.storageRef ?? account.storage; + const { owner, repo } = this._parseGitHubRef(storageRef); + const octokit = clients.githubClient; + const branch = await this._resolveGitHubBranch( + octokit, + owner, + repo, + storageRef.branch, + ); + + const scanEntries = await this._listGitHubDirectory( + octokit, + owner, + repo, + SCANS_DIR, + branch, + ); + const idOf = (name) => name.slice(0, name.indexOf('_')); + const scanFiles = scanEntries.filter( + (entry) => + entry.type === 'file' && + entry.name.endsWith('.json') && + entry.name !== 'index.json' && + ids.has(idOf(entry.name)), + ); + + const { index } = await this._reconcileGitHubIndex(octokit, owner, repo, branch); + index.scans = index.scans.filter((entry) => !ids.has(entry.id)); + + // Nothing left to remove (already deleted elsewhere) — report current state. + if (scanFiles.length === 0) { + return { deletedCount: 0, scanCount: index.scans.length, scans: index.scans }; + } + + const manifestFile = await this._readAccountManifest(octokit, owner, repo, branch); + if (!manifestFile) { + throw new Error('Account manifest not found'); + } + + const { manifest } = this._normalizeManifestBrand( + this._parseJson(manifestFile.content, 'manifest'), + ); + const updatedManifest = this._updateManifestSummary( + manifest, + index, + index.scans[0]?.scannedAt, + ); + if (index.scans.length === 0) { + updatedManifest.summary.lastScanAt = null; + } + updatedManifest.account.updatedAt = new Date().toISOString(); + + const indexFile = await this._readGitHubFile(octokit, owner, repo, INDEX_PATH, branch); + + await this._writeGitHubFiles( + octokit, + owner, + repo, + branch, + [ + ...scanFiles.map((entry) => ({ + path: `${SCANS_DIR}/${entry.name}`, + delete: true, + sha: entry.sha, + })), + { + path: INDEX_PATH, + content: JSON.stringify(index, null, 2) + '\n', + sha: indexFile?.sha, + }, + { + path: MANIFEST_PATH, + content: JSON.stringify(updatedManifest, null, 2) + '\n', + ...(manifestFile.path === MANIFEST_PATH ? { sha: manifestFile.sha } : {}), + }, + ], + `Delete accessibility scans (${scanFiles.length})`, + ); + + return { + deletedCount: scanFiles.length, + scanCount: index.scans.length, + scans: index.scans, + }; + } + /** * One save attempt: reconcile from scan-file truth, append prepared scan, write. * @param {object} account diff --git a/backend/tests/scan.test.js b/backend/tests/scan.test.js index bd31db3..71013c5 100644 --- a/backend/tests/scan.test.js +++ b/backend/tests/scan.test.js @@ -508,3 +508,81 @@ test('deleteSavedScan returns 404 for SCAN_NOT_FOUND', async () => { ); assert.equal(out.statusCode, 404); }); + +test('deleteSavedScans deletes the given ids and updates session scanCount', async () => { + const ScanController = require('../controllers/scanController'); + let persisted = false; + const ctrl = new ScanController({ + mockScanResults, + scanRunner: mockScanRunner, + authService: { + clientsFor: async () => ({ githubClient: {} }), + persistUser: async () => { + persisted = true; + }, + }, + storageService: { + deleteScans: async (_user, ids) => { + assert.deepEqual(ids, ['a', 'b']); + return { deletedCount: 2, scanCount: 0, scans: [] }; + }, + }, + }); + + const req = { + isAuthenticated: () => true, + user: { + storage: { id: 'R_kg', full_name: 'sam/repo' }, + account: { scanCount: 2 }, + }, + body: { ids: ['a', 'b'] }, + }; + const out = mockRes(); + await ctrl.deleteSavedScans(req, out.res); + + assert.equal(out.statusCode, 200); + assert.deepEqual(out.body, { deletedCount: 2, scanCount: 0, scans: [] }); + assert.equal(req.user.account.scanCount, 0); + assert.equal(persisted, true); +}); + +test('deleteSavedScans requires auth and attached storage', async () => { + const ScanController = require('../controllers/scanController'); + const ctrl = new ScanController({ + mockScanResults, + scanRunner: mockScanRunner, + authService: {}, + storageService: {}, + }); + const out = mockRes(); + await ctrl.deleteSavedScans( + { + isAuthenticated: () => false, + user: null, + }, + out.res, + ); + assert.equal(out.statusCode, 401); +}); + +test('deleteSavedScans rejects a request without ids', async () => { + const ScanController = require('../controllers/scanController'); + const ctrl = new ScanController({ + mockScanResults, + scanRunner: mockScanRunner, + authService: { clientsFor: async () => ({ githubClient: {} }) }, + storageService: { + deleteScans: async () => { + const err = new Error('Scan ids are required'); + err.code = 'SCAN_IDS_REQUIRED'; + throw err; + }, + }, + }); + const out = mockRes(); + await ctrl.deleteSavedScans( + { isAuthenticated: () => true, user: { storage: { id: 'R_kg' } }, body: {} }, + out.res, + ); + assert.equal(out.statusCode, 400); +}); diff --git a/backend/tests/storageService.test.js b/backend/tests/storageService.test.js index 63ceb9c..7c0dc6b 100644 --- a/backend/tests/storageService.test.js +++ b/backend/tests/storageService.test.js @@ -1360,6 +1360,184 @@ test('deleteScanById removes one scan file and leaves the others', async () => { assert.equal(updatedManifest.summary.scanCount, 1); }); +test('deleteScans removes only the given ids and keeps scans saved elsewhere', async () => { + const storageService = new StorageService(); + const account = { + storage: { ...STORAGE_REF, provider: 'github', branch: 'main' }, + }; + const client = createMockGitHubClient({ + files: { + 'vizably.json': { + content: JSON.stringify( + manifest({ summary: { scanCount: 2, lastScanAt: '2026-07-11T12:00:00Z' } }), + ), + sha: 'sha-manifest', + }, + 'scans/index.json': { + content: JSON.stringify({ + schemaVersion: 1, + scans: [ + { + id: 'a', + url: 'https://a.example', + host: 'a.example', + scannedAt: '2026-07-11T12:00:00Z', + file: 'scans/a_a.example.json', + }, + { + id: 'b', + url: 'https://b.example', + host: 'b.example', + scannedAt: '2026-07-10T12:00:00Z', + file: 'scans/b_b.example.json', + }, + ], + }), + sha: 'sha-index', + }, + 'scans/a_a.example.json': { + content: JSON.stringify({ + id: 'a', + url: 'https://a.example', + scannedAt: '2026-07-11T12:00:00Z', + result: { problems: {} }, + }), + sha: 'sha-a', + }, + 'scans/b_b.example.json': { + content: JSON.stringify({ + id: 'b', + url: 'https://b.example', + scannedAt: '2026-07-10T12:00:00Z', + result: { problems: {} }, + }), + sha: 'sha-b', + }, + // Saved from another device after the dashboard loaded — not in the ids. + 'scans/c_c.example.json': { + content: JSON.stringify({ + id: 'c', + url: 'https://c.example', + scannedAt: '2026-07-12T12:00:00Z', + result: { problems: {} }, + }), + sha: 'sha-c', + }, + 'README.md': { content: '# keep\n', sha: 'sha-readme' }, + }, + }); + + const result = await storageService.deleteScans(account, ['a', 'b'], { + githubClient: client, + }); + + assert.equal(result.deletedCount, 2); + assert.equal(result.scanCount, 1); + assert.deepEqual(result.scans.map((s) => s.id), ['c']); + assert.equal(client.files['scans/a_a.example.json'], undefined); + assert.equal(client.files['scans/b_b.example.json'], undefined); + assert.ok(client.files['scans/c_c.example.json']); + assert.equal(client.files['README.md'].content, '# keep\n'); + + const index = JSON.parse(client.files['scans/index.json'].content); + assert.deepEqual(index.scans.map((s) => s.id), ['c']); + + const updatedManifest = JSON.parse(client.files['vizably.json'].content); + assert.equal(updatedManifest.summary.scanCount, 1); + assert.ok(updatedManifest.account.id); +}); + +test('deleteScans clears lastScanAt when nothing is left', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient({ + files: { + 'vizably.json': { + content: JSON.stringify( + manifest({ summary: { scanCount: 1, lastScanAt: '2026-07-11T12:00:00Z' } }), + ), + sha: 'sha-manifest', + }, + 'scans/index.json': { + content: JSON.stringify({ schemaVersion: 1, scans: [] }), + sha: 'sha-index', + }, + 'scans/a_a.example.json': { + content: JSON.stringify({ + id: 'a', + url: 'https://a.example', + scannedAt: '2026-07-11T12:00:00Z', + result: { problems: {} }, + }), + sha: 'sha-a', + }, + }, + }); + + const result = await storageService.deleteScans( + { storage: { ...STORAGE_REF, provider: 'github', branch: 'main' } }, + ['a'], + { githubClient: client }, + ); + + assert.equal(result.scanCount, 0); + const updatedManifest = JSON.parse(client.files['vizably.json'].content); + assert.equal(updatedManifest.summary.scanCount, 0); + assert.equal(updatedManifest.summary.lastScanAt, null); +}); + +test('deleteScans treats ids that are already gone as deleted', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient({ + files: { + 'vizably.json': { + content: JSON.stringify(manifest({ summary: { scanCount: 0, lastScanAt: null } })), + sha: 'sha-manifest', + }, + 'scans/index.json': { + content: JSON.stringify({ schemaVersion: 1, scans: [] }), + sha: 'sha-index', + }, + }, + }); + + const result = await storageService.deleteScans( + { storage: { ...STORAGE_REF, provider: 'github', branch: 'main' } }, + ['gone'], + { githubClient: client }, + ); + + assert.equal(result.deletedCount, 0); + assert.equal(result.scanCount, 0); +}); + +test('deleteScans stubs google until Phase 3', async () => { + const storageService = new StorageService(); + await assert.rejects( + () => + storageService.deleteScans( + { storage: { provider: 'google', id: 'folder' } }, + ['a'], + {}, + ), + (err) => err.code === 'PROVIDER_NOT_AVAILABLE' && err.status === 501, + ); +}); + +test('deleteScans requires a non-empty list of ids', async () => { + const storageService = new StorageService(); + for (const ids of [undefined, [], [''], [1]]) { + await assert.rejects( + () => + storageService.deleteScans( + { storage: { ...STORAGE_REF, provider: 'github' } }, + ids, + { githubClient: {} }, + ), + (err) => err.code === 'SCAN_IDS_REQUIRED' && err.status === 400, + ); + } +}); + test('deleteScanById returns not found for unknown id', async () => { const storageService = new StorageService(); const client = createMockGitHubClient({ diff --git a/docs/guides/auth_storage_guide/TODO.md b/docs/guides/auth_storage_guide/TODO.md index de70944..63dac7b 100644 --- a/docs/guides/auth_storage_guide/TODO.md +++ b/docs/guides/auth_storage_guide/TODO.md @@ -130,6 +130,7 @@ Provider-neutral routes; Google OAuth endpoints stubbed until Phase 3. - [x] In `postScan`: if authenticated and `req.user.storage`, build clients and `saveScanResults(...)` — a storage failure logs a warning, **never** fails the scan - [x] `DELETE /api/scans/:id` — `deleteSavedScan` → `storageService.deleteScanById` +- [x] `DELETE /api/scans` `{ ids }` — `deleteSavedScans` → `storageService.deleteScans` (#119) (see [`scanDeletion.md`](./scanDeletion.md)) ### Phase 1 follow-ups (post-merge) @@ -156,6 +157,7 @@ Provider-neutral API shape; **GitHub picker wired**, Google deferred to Phase 3. - [x] `setupStorage(provider, storageRef, action)` → `POST /api/auth/storage` - [x] Keep `runScan`, `getScanResults`, `getProblem` - [x] `deleteScan(id)` → `DELETE /api/scans/:id` +- [x] `deleteScans(ids)` → `DELETE /api/scans` ### ConnectView (`frontend/src/views/ConnectView.jsx`) diff --git a/docs/guides/auth_storage_guide/accountStorageContract.md b/docs/guides/auth_storage_guide/accountStorageContract.md index 21d5493..cf00d94 100644 --- a/docs/guides/auth_storage_guide/accountStorageContract.md +++ b/docs/guides/auth_storage_guide/accountStorageContract.md @@ -195,6 +195,12 @@ summary) must be atomic or partial-write tolerant: `DELETE /api/scans/:id` removes one immutable file `scans/_.json` and refreshes rebuildable caches (`scans/index.json` + manifest `summary`). + +`DELETE /api/scans` with body `{ "ids": [...] }` removes exactly those scan +files in one commit and refreshes the same caches. It never deletes "everything +in `scans/`" — a scan another device saved after the client loaded its list is +not in `ids`, so it survives. Ids with no file are treated as already deleted. + Other scan files and account identity stay. GitHub history may still contain the deleted blob unless history is rewritten — disclose that in the UI if you talk about permanence. See [`scanDeletion.md`](./scanDeletion.md). diff --git a/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md b/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md index 6431aa4..a8b06c4 100644 --- a/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md +++ b/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md @@ -363,6 +363,7 @@ Session cookies, not Bearer tokens. All calls use `credentials: 'include'`; no - `listScans()` → `GET /api/scans` - `getSavedScan(id)` → `GET /api/scans/:id` - `deleteScan(id)` → `DELETE /api/scans/:id` +- `deleteScans(ids)` → `DELETE /api/scans` (body `{ ids }`) - `runScan`, `getScanResults`, `getProblem` unchanged. For Google, selection is done with the **Google Picker** client library; the diff --git a/docs/guides/auth_storage_guide/scanDeletion.md b/docs/guides/auth_storage_guide/scanDeletion.md index df7a4ca..f411274 100644 --- a/docs/guides/auth_storage_guide/scanDeletion.md +++ b/docs/guides/auth_storage_guide/scanDeletion.md @@ -30,7 +30,8 @@ Vizably has **no scan database**. Saved reports live in storage the user owns **Not in scope for #112** - Whole-account wipe / optional repo delete (that’s [#82](https://github.com/codrlabs/vizably/issues/82) / account deletion). -- Bulk “delete all” on Account settings (still a later phase). +- Bulk selection and deletion — that’s [#119](https://github.com/codrlabs/vizably/issues/119) + (`DELETE /api/scans` with `{ ids }`). - Rewriting Git history — after delete, GitHub history may still contain the blob; disclose that lightly in UI copy if you mention permanence. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c8ccb7e..455f064 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -310,11 +310,17 @@ function AppRoutes() { /** Remove one saved scan from attached storage and refresh the dashboard list. */ const deleteSaved = async (s) => { if (!s?.id) return - const result = await apiClient.deleteScan(s.id) - setUser((prev) => mergeAccountUpdate(prev, { - scanCount: result.scanCount, - scans: result.scans, - })) + try { + const result = await apiClient.deleteScan(s.id) + setUser((prev) => mergeAccountUpdate(prev, { + scanCount: result.scanCount, + scans: result.scans, + })) + } catch (err) { + // Already gone (deleted on another device) — same outcome; resync the list. + if (err.status !== 404) throw err + await refreshUser().catch(() => {}) + } if (location.search.includes(`scanId=${encodeURIComponent(s.id)}`)) { setScan(null) setProblem(null) @@ -322,6 +328,25 @@ function AppRoutes() { } } + /** + * Remove the selected saved scans in one storage write. Only the ids the user + * saw are sent, so scans saved on another device since are never swept up. + */ + const deleteManySaved = async (ids) => { + if (!ids?.length) return + try { + const result = await apiClient.deleteScans(ids) + setUser((prev) => mergeAccountUpdate(prev, { + scanCount: result.scanCount, + scans: result.scans, + })) + } catch (err) { + // Resync so the list (and any retry) reflects what is actually left. + await refreshUser().catch(() => {}) + throw err + } + } + const auth = (p) => { if (p === 'google') return apiClient.githubLogin() @@ -445,7 +470,7 @@ function AppRoutes() { { expect(result.scanCount).toBe(1) expect(result.scans).toEqual([{ id: 'scan-2' }]) }) + + it('deletes the given saved scans by id', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ deletedCount: 2, scanCount: 0, scans: [] }), + }) + + const client = new ApiClient({ fetchImpl }) + const result = await client.deleteScans(['a', 'b']) + + expect(fetchImpl).toHaveBeenCalledWith( + '/api/scans', + expect.objectContaining({ method: 'DELETE', body: JSON.stringify({ ids: ['a', 'b'] }) }), + ) + expect(result).toEqual({ deletedCount: 2, scanCount: 0, scans: [] }) + }) }) diff --git a/frontend/src/__tests__/dashboardView.test.jsx b/frontend/src/__tests__/dashboardView.test.jsx index 31a8e0c..d7e4f6c 100644 --- a/frontend/src/__tests__/dashboardView.test.jsx +++ b/frontend/src/__tests__/dashboardView.test.jsx @@ -78,7 +78,89 @@ describe('DashboardView', () => { expect(onOpen).toHaveBeenCalledWith(SAVED[1]) }) - it('asks for confirm before delete and does not open the scan', async () => { + it('does not render checkboxes or select-all when onDeleteMany is absent', () => { + render( + , + ) + expect(screen.queryByLabelText(/select all scans/i)).not.toBeInTheDocument() + expect(screen.queryByLabelText(/select scan example.com/i)).not.toBeInTheDocument() + }) + + it('selects scans with checkboxes and bulk-deletes the selection', async () => { + const onOpen = vi.fn() + const onDeleteMany = vi.fn().mockResolvedValue(undefined) + render( + , + ) + + fireEvent.click(screen.getByLabelText(/select scan example.com/i)) + expect(onOpen).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: /delete 1 selected/i })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /delete 1 selected/i })) + expect(screen.getByText(/delete 1 selected scan\?/i)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /yes, delete/i })) + await waitFor(() => expect(onDeleteMany).toHaveBeenCalledWith(['s1'])) + }) + + it('select all checks every row and bulk-deletes everything', async () => { + const onDeleteMany = vi.fn().mockResolvedValue(undefined) + render( + , + ) + + fireEvent.click(screen.getByLabelText(/^select all scans$/i)) + expect(screen.getByLabelText(/select scan example.com/i)).toBeChecked() + expect(screen.getByLabelText(/select scan other.org/i)).toBeChecked() + + fireEvent.click(screen.getByRole('button', { name: /delete 2 selected/i })) + fireEvent.click(screen.getByRole('button', { name: /yes, delete/i })) + await waitFor(() => expect(onDeleteMany).toHaveBeenCalledWith(['s1', 's2'])) + }) + + it('shows bulk-delete errors without clearing the selection', async () => { + const onDeleteMany = vi.fn().mockRejectedValue(new Error('GitHub refused the delete')) + render( + , + ) + + fireEvent.click(screen.getByLabelText(/select scan example.com/i)) + fireEvent.click(screen.getByRole('button', { name: /delete 1 selected/i })) + fireEvent.click(screen.getByRole('button', { name: /yes, delete/i })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/refused the delete/i) + expect(screen.getByLabelText(/select scan example.com/i)).toBeChecked() + }) + + it('asks for confirm before a per-row delete and does not open the scan', async () => { const onOpen = vi.fn() const onDelete = vi.fn().mockResolvedValue(undefined) render( @@ -86,6 +168,7 @@ describe('DashboardView', () => { onNav={vi.fn()} onOpen={onOpen} onDelete={onDelete} + onDeleteMany={vi.fn()} saved={SAVED} provider="github" user={{ email: 'sam@example.com' }} @@ -93,6 +176,8 @@ describe('DashboardView', () => { />, ) + // Per-row delete sits next to the checkbox. + expect(screen.getByLabelText(/select scan example.com/i)).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /delete scan example.com/i })) expect(onOpen).not.toHaveBeenCalled() expect(screen.getByText(/delete this scan/i)).toBeInTheDocument() @@ -120,4 +205,24 @@ describe('DashboardView', () => { expect(onDelete).not.toHaveBeenCalled() expect(screen.queryByText(/delete this scan/i)).not.toBeInTheDocument() }) + + it('never counts or sends a selected id that is no longer on screen', async () => { + const onDeleteMany = vi.fn().mockResolvedValue(undefined) + const props = { + onNav: vi.fn(), + onOpen: vi.fn(), + onDeleteMany, + provider: 'github', + user: { email: 'sam@example.com' }, + } + const { rerender } = render() + + fireEvent.click(screen.getByLabelText(/^select all scans$/i)) + // s2 disappears (e.g. deleted on another device and the list resynced). + rerender() + + fireEvent.click(screen.getByRole('button', { name: /delete 1 selected/i })) + fireEvent.click(screen.getByRole('button', { name: /yes, delete/i })) + await waitFor(() => expect(onDeleteMany).toHaveBeenCalledWith(['s1'])) + }) }) diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js index ab6efc4..7ab46ec 100644 --- a/frontend/src/lib/apiClient.js +++ b/frontend/src/lib/apiClient.js @@ -221,6 +221,20 @@ export class ApiClient { }) } + /** + * Delete the given saved scans in one storage write (keeps the account store). + * Pass the ids the user actually saw so scans saved elsewhere since are kept. + * @param {string[]} ids + * @returns {Promise<{ deletedCount: number, scanCount: number, scans: object[] }>} + */ + deleteScans(ids) { + return this._request('/api/scans', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }) + } + /** * Look up a single problem by id. * @param {string} id diff --git a/frontend/src/views/AccountView.jsx b/frontend/src/views/AccountView.jsx index 7ec93a6..4ac8006 100644 --- a/frontend/src/views/AccountView.jsx +++ b/frontend/src/views/AccountView.jsx @@ -83,11 +83,8 @@ export default function AccountView({ onSignOut, user, shellUser, provider }) {
- - - + +
setAutoDelete((v) => !v)} /> diff --git a/frontend/src/views/DashboardView.jsx b/frontend/src/views/DashboardView.jsx index 74dd673..2263b4e 100644 --- a/frontend/src/views/DashboardView.jsx +++ b/frontend/src/views/DashboardView.jsx @@ -4,18 +4,37 @@ import { Ico } from '../lib/icons' import { PROVIDERS } from '../data/placeholders' /** Dashboard — signed-in saved scans from the loaded account index. */ -export default function DashboardView({ onNav, onOpen, onDelete, saved, provider, user, storage }) { +export default function DashboardView({ onNav, onOpen, onDelete, onDeleteMany, saved, provider, user, storage }) { const pv = PROVIDERS[provider] || PROVIDERS.github const storageLabel = storage?.full_name || pv.dest + const [selected, setSelected] = useState(() => new Set()) + const [bulkConfirm, setBulkConfirm] = useState(false) + const [bulkBusy, setBulkBusy] = useState(false) + const [bulkError, setBulkError] = useState(null) const [confirmId, setConfirmId] = useState(null) const [deletingId, setDeletingId] = useState(null) const [deleteError, setDeleteError] = useState(null) + // Only ids still on screen count — a stale id is never confirmed or sent. + const selectedIds = saved.filter((s) => selected.has(s.id)).map((s) => s.id) const band = (v) => v >= 90 ? { c: 'var(--green-600)', g: 'Good' } : v >= 70 ? { c: 'var(--sev-moderate)', g: 'Fair' } : v >= 50 ? { c: 'var(--sev-serious)', g: 'Poor' } : { c: 'var(--sev-critical)', g: 'Critical' } + const toggleSelected = (id) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const toggleSelectAll = () => { + setSelected(selectedIds.length === saved.length ? new Set() : new Set(saved.map((s) => s.id))) + } + const handleDelete = async (s) => { if (!onDelete || !s?.id) return setDeleteError(null) @@ -30,9 +49,25 @@ export default function DashboardView({ onNav, onOpen, onDelete, saved, provider } } + const handleBulkDelete = async () => { + if (!onDeleteMany || selectedIds.length === 0) return + setBulkError(null) + setBulkBusy(true) + try { + await onDeleteMany(selectedIds) + setSelected(new Set()) + setBulkConfirm(false) + } catch (err) { + setBulkError(err?.message || 'Failed to delete the selected scans') + } finally { + setBulkBusy(false) + } + } + const Row = ({ s, last }) => { const [hover, setHover] = useState(false) const b = band(s.score) + const checked = selected.has(s.id) const confirming = confirmId === s.id const busy = deletingId === s.id @@ -61,6 +96,17 @@ export default function DashboardView({ onNav, onOpen, onDelete, saved, provider transition: 'background var(--duration-fast) var(--ease-standard)', }} > + {onDeleteMany && ( + e.stopPropagation()} + onChange={() => toggleSelected(s.id)} + style={{ flexShrink: 0, width: 16, height: 16, cursor: 'pointer' }} + /> + )} { e.stopPropagation() setDeleteError(null) @@ -227,16 +273,65 @@ export default function DashboardView({ onNav, onOpen, onDelete, saved, provider
- {deleteError && ( + {onDeleteMany && ( +
+ + {selectedIds.length > 0 && !bulkConfirm && ( + + )} +
+ )} + + {bulkConfirm && (
- {deleteError} +

+ Delete {selectedIds.length} selected scan{selectedIds.length === 1 ? '' : 's'}? +

+

+ They will be removed from your storage. GitHub history may still retain them. +

+ {bulkError && ( +
+ {bulkError} +
+ )} +
+ + +
)} @@ -254,6 +349,19 @@ export default function DashboardView({ onNav, onOpen, onDelete, saved, provider ))}
+ {deleteError && ( +
+ {deleteError} +
+ )} + {saved.map((s, i) => )}