From 31f22ae77ffe98a6b7ee23f196b560303693d9b1 Mon Sep 17 00:00:00 2001 From: Samuel Olabode Date: Fri, 21 Aug 2026 13:32:01 -0600 Subject: [PATCH 1/4] feat(scan): add bulk delete functionality for saved scans Implement a new API endpoint `DELETE /api/scans` to remove all saved scan reports while retaining the account storage. Update the ScanController and storageService to handle the deletion logic, including necessary authentication checks and error handling. Enhance the frontend with a confirmation dialog for users to delete all scans, ensuring a smooth user experience. Update documentation to reflect the new functionality. --- backend/README.md | 1 + backend/controllers/scanController.js | 56 ++++++++ backend/routes/scan.js | 3 + backend/services/storageService.js | 126 ++++++++++++++++++ backend/tests/scan.test.js | 56 ++++++++ backend/tests/storageService.test.js | 115 ++++++++++++++++ docs/guides/auth_storage_guide/TODO.md | 2 + .../accountStorageContract.md | 5 + .../githubGoogleAuthStorageImplementation.md | 1 + .../guides/auth_storage_guide/scanDeletion.md | 3 +- frontend/src/App.jsx | 15 +++ frontend/src/__tests__/accountView.test.jsx | 79 ++++++++++- frontend/src/__tests__/apiClient.test.js | 17 +++ frontend/src/lib/apiClient.js | 10 ++ frontend/src/views/AccountView.jsx | 105 ++++++++++++++- 15 files changed, 587 insertions(+), 7 deletions(-) diff --git a/backend/README.md b/backend/README.md index a944854..24cae1f 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 every saved report (keep account store) | | 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..8528273 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.deleteAllSavedScans = this.deleteAllSavedScans.bind(this); this.getProblem = this.getProblem.bind(this); } @@ -174,6 +175,61 @@ class ScanController { } } + /** + * DELETE /api/scans — remove every saved report from attached storage. + * Keeps the account manifest and repository; only clears scan files + caches. + */ + async deleteAllSavedScans(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.deleteAllScans(req.user, 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 === '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..c0dc26f 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 * - 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.deleteAllSavedScans); 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..0ff3d85 100644 --- a/backend/services/storageService.js +++ b/backend/services/storageService.js @@ -1121,6 +1121,132 @@ class StorageService { }; } + /** + * Delete every immutable saved scan and reset index/manifest caches. + * Leaves vizably.json identity and the repository itself intact. + * @param {object} account session user (with storage binding) + * @param {StorageClients} clients + * @returns {Promise<{ deletedCount: number, scanCount: number, scans: object[] }>} + */ + async deleteAllScans(account, 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'); + } + + const maxAttempts = 3; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await this._deleteAllScansOnce(account, 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 {StorageClients} clients + * @private + */ + async _deleteAllScansOnce(account, 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 scanFiles = scanEntries.filter( + (entry) => + entry.type === 'file' && + entry.name.endsWith('.json') && + entry.name !== 'index.json', + ); + + 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 emptyIndex = { schemaVersion: 1, scans: [] }; + const updatedManifest = this._updateManifestSummary(manifest, emptyIndex, null); + updatedManifest.summary.lastScanAt = null; + updatedManifest.account.updatedAt = new Date().toISOString(); + + const indexFile = await this._readGitHubFile(octokit, owner, repo, INDEX_PATH, branch); + + /** @type {Array<{ path: string, delete?: boolean, sha?: string, content?: string }>} */ + const files = scanFiles.map((entry) => ({ + path: `${SCANS_DIR}/${entry.name}`, + delete: true, + sha: entry.sha, + })); + + files.push({ + path: INDEX_PATH, + content: JSON.stringify(emptyIndex, null, 2) + '\n', + sha: indexFile?.sha, + }); + files.push({ + path: MANIFEST_PATH, + content: JSON.stringify(updatedManifest, null, 2) + '\n', + ...(manifestFile.path === MANIFEST_PATH ? { sha: manifestFile.sha } : {}), + }); + + // Idempotent when already empty — still refresh caches if they disagree. + if (scanFiles.length === 0 && indexFile) { + const currentIndex = this._parseJson(indexFile.content, 'index'); + if ( + Array.isArray(currentIndex?.scans) && + currentIndex.scans.length === 0 && + (manifest.summary?.scanCount ?? 0) === 0 + ) { + return { deletedCount: 0, scanCount: 0, scans: [] }; + } + } + + await this._writeGitHubFiles( + octokit, + owner, + repo, + branch, + files, + scanFiles.length === 0 + ? 'Reset accessibility scan caches' + : `Delete all accessibility scans (${scanFiles.length})`, + ); + + return { + deletedCount: scanFiles.length, + scanCount: 0, + 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..aa2eb0a 100644 --- a/backend/tests/scan.test.js +++ b/backend/tests/scan.test.js @@ -508,3 +508,59 @@ test('deleteSavedScan returns 404 for SCAN_NOT_FOUND', async () => { ); assert.equal(out.statusCode, 404); }); + +test('deleteAllSavedScans clears every scan 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: { + deleteAllScans: async () => ({ + deletedCount: 2, + scanCount: 0, + scans: [], + }), + }, + }); + + const req = { + isAuthenticated: () => true, + user: { + storage: { id: 'R_kg', full_name: 'sam/repo' }, + account: { scanCount: 2 }, + }, + }; + const out = mockRes(); + await ctrl.deleteAllSavedScans(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('deleteAllSavedScans 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.deleteAllSavedScans( + { + isAuthenticated: () => false, + user: null, + }, + out.res, + ); + assert.equal(out.statusCode, 401); +}); diff --git a/backend/tests/storageService.test.js b/backend/tests/storageService.test.js index 63ceb9c..be7d328 100644 --- a/backend/tests/storageService.test.js +++ b/backend/tests/storageService.test.js @@ -1360,6 +1360,121 @@ test('deleteScanById removes one scan file and leaves the others', async () => { assert.equal(updatedManifest.summary.scanCount, 1); }); +test('deleteAllScans removes every scan file and resets caches', 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', + }, + 'README.md': { content: '# keep\n', sha: 'sha-readme' }, + }, + }); + + const result = await storageService.deleteAllScans(account, { + githubClient: client, + }); + + assert.equal(result.deletedCount, 2); + assert.equal(result.scanCount, 0); + assert.deepEqual(result.scans, []); + 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/index.json']); + assert.ok(client.files['vizably.json']); + assert.equal(client.files['README.md'].content, '# keep\n'); + + const index = JSON.parse(client.files['scans/index.json'].content); + assert.deepEqual(index.scans, []); + + const updatedManifest = JSON.parse(client.files['vizably.json'].content); + assert.equal(updatedManifest.summary.scanCount, 0); + assert.equal(updatedManifest.summary.lastScanAt, null); + assert.ok(updatedManifest.account.id); +}); + +test('deleteAllScans is idempotent when already empty', 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.deleteAllScans( + { storage: { ...STORAGE_REF, provider: 'github', branch: 'main' } }, + { githubClient: client }, + ); + + assert.equal(result.deletedCount, 0); + assert.equal(result.scanCount, 0); +}); + +test('deleteAllScans stubs google until Phase 3', async () => { + const storageService = new StorageService(); + await assert.rejects( + () => + storageService.deleteAllScans( + { storage: { provider: 'google', id: 'folder' } }, + {}, + ), + (err) => err.code === 'PROVIDER_NOT_AVAILABLE' && err.status === 501, + ); +}); + 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..2c2d003 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` — `deleteAllSavedScans` → `storageService.deleteAllScans` (#110) (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] `deleteAllScans()` → `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..f356271 100644 --- a/docs/guides/auth_storage_guide/accountStorageContract.md +++ b/docs/guides/auth_storage_guide/accountStorageContract.md @@ -194,6 +194,11 @@ summary) must be atomic or partial-write tolerant: ### Deleting a scan `DELETE /api/scans/:id` removes one immutable file `scans/_.json` +and refreshes `scans/index.json` + `vizably.json` summary caches. + +`DELETE /api/scans` removes **all** immutable scan files under `scans/` (except +`index.json`), then writes an empty index and `summary.scanCount: 0`. The +account manifest identity and repository remain. and refreshes rebuildable caches (`scans/index.json` + manifest `summary`). 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 diff --git a/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md b/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md index 6431aa4..12337b6 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` +- `deleteAllScans()` → `DELETE /api/scans` - `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..9a236dc 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 “delete all” on Account settings — that’s [#110](https://github.com/codrlabs/vizably/issues/110) + (`DELETE /api/scans`). - 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..deef963 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -322,6 +322,20 @@ function AppRoutes() { } } + /** Remove every saved scan from attached storage; keep the account connected. */ + const deleteAllSaved = async () => { + const result = await apiClient.deleteAllScans() + setUser((prev) => mergeAccountUpdate(prev, { + scanCount: result.scanCount, + scans: result.scans, + })) + if (location.search.includes('scanId=')) { + setScan(null) + setProblem(null) + } + return result + } + const auth = (p) => { if (p === 'google') return apiClient.githubLogin() @@ -460,6 +474,7 @@ function AppRoutes() { { fireEvent.click(screen.getByRole('button', { name: /yes, sign out/i })) expect(onSignOut).toHaveBeenCalledTimes(2) }) + + it('asks for confirm before delete-all and calls onDeleteAllScans', async () => { + const onDeleteAllScans = vi.fn().mockResolvedValue({ + deletedCount: 3, + scanCount: 0, + scans: [], + }) + + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /^delete all$/i })) + expect(screen.getByText(/delete all 3 saved scans/i)).toBeInTheDocument() + expect(onDeleteAllScans).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: /yes, delete all/i })) + await waitFor(() => expect(onDeleteAllScans).toHaveBeenCalledTimes(1)) + + rerender( + , + ) + + expect(screen.getByRole('button', { name: /^cleared$/i })).toBeDisabled() + }) + + it('keeps scans when confirm is cancelled', () => { + const onDeleteAllScans = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /^delete all$/i })) + fireEvent.click(screen.getByRole('button', { name: /keep scans/i })) + expect(screen.getByRole('button', { name: /^delete all$/i })).toBeInTheDocument() + expect(onDeleteAllScans).not.toHaveBeenCalled() + }) + + it('shows delete-all errors without clearing', async () => { + const onDeleteAllScans = vi.fn().mockRejectedValue(new Error('GitHub refused the wipe')) + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /^delete all$/i })) + fireEvent.click(screen.getByRole('button', { name: /yes, delete all/i })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/refused the wipe/i) + expect(screen.getByText(/saved scans · 3/i)).toBeInTheDocument() + }) }) diff --git a/frontend/src/__tests__/apiClient.test.js b/frontend/src/__tests__/apiClient.test.js index 1fd991c..eaa44a0 100644 --- a/frontend/src/__tests__/apiClient.test.js +++ b/frontend/src/__tests__/apiClient.test.js @@ -268,4 +268,21 @@ describe('ApiClient', () => { expect(result.scanCount).toBe(1) expect(result.scans).toEqual([{ id: 'scan-2' }]) }) + + it('deletes all saved scans', 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.deleteAllScans() + + expect(fetchImpl).toHaveBeenCalledWith( + '/api/scans', + expect.objectContaining({ method: 'DELETE' }), + ) + expect(result).toEqual({ deletedCount: 2, scanCount: 0, scans: [] }) + }) }) diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js index ab6efc4..08d116d 100644 --- a/frontend/src/lib/apiClient.js +++ b/frontend/src/lib/apiClient.js @@ -221,6 +221,16 @@ export class ApiClient { }) } + /** + * Delete every saved scan from attached storage (keeps the account store). + * @returns {Promise<{ deletedCount: number, scanCount: number, scans: object[] }>} + */ + deleteAllScans() { + return this._request('/api/scans', { + method: 'DELETE', + }) + } + /** * 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..c814f8d 100644 --- a/frontend/src/views/AccountView.jsx +++ b/frontend/src/views/AccountView.jsx @@ -2,15 +2,34 @@ import { useState } from 'react' import { Button, Card, Input } from '../design-system' import { Ico, GoogleMark } from '../lib/icons' import { PROVIDERS } from '../data/placeholders' +import { apiClient } from '../lib/apiClient' /** * Account settings — profile + data/storage controls + delete account. * Deliberately framed around using LESS storage, not more. + * + * @param {object} props + * @param {() => void | Promise} props.onSignOut + * @param {(result: { scanCount: number, scans: object[] }) => void | Promise} [props.onDeleteAllScans] + * @param {object} props.user + * @param {object} [props.shellUser] + * @param {'github' | 'google'} props.provider + * @param {import('../lib/apiClient').ApiClient} [props.client] */ -export default function AccountView({ onSignOut, user, shellUser, provider }) { +export default function AccountView({ + onSignOut, + onDeleteAllScans, + user, + shellUser, + provider, + client = apiClient, +}) { const pv = PROVIDERS[provider] || PROVIDERS.github const [autoDelete, setAutoDelete] = useState(user?.account?.settings?.autoDelete90d ?? true) const [confirmDelete, setConfirmDelete] = useState(false) + /** @type {'idle' | 'confirm' | 'busy'} */ + const [deleteAllStep, setDeleteAllStep] = useState('idle') + const [deleteAllError, setDeleteAllError] = useState(null) const savedCount = user?.account?.scanCount ?? user?.account?.scans?.length ?? 0 const storageLabel = user?.storage?.full_name || pv.dest @@ -18,6 +37,7 @@ export default function AccountView({ onSignOut, user, shellUser, provider }) { name: user?.displayName || user?.username || 'User', email: user?.email || '', } + const deletingAll = deleteAllStep === 'busy' const Section = ({ title, desc, children }) => (
@@ -48,6 +68,21 @@ export default function AccountView({ onSignOut, user, shellUser, provider }) { ) + const handleDeleteAll = async () => { + setDeleteAllError(null) + setDeleteAllStep('busy') + try { + const result = onDeleteAllScans + ? await onDeleteAllScans() + : await client.deleteAllScans() + setDeleteAllStep('idle') + return result + } catch (err) { + setDeleteAllError(err?.message || 'Failed to delete saved scans') + setDeleteAllStep('confirm') + } + } + return (
@@ -83,11 +118,71 @@ export default function AccountView({ onSignOut, user, shellUser, provider }) {
- - + + {savedCount === 0 ? ( + + ) : deleteAllStep === 'idle' ? ( + + ) : null} + + {deleteAllStep !== 'idle' && savedCount > 0 && ( +
+

+ Delete all {savedCount} saved scan{savedCount === 1 ? '' : 's'} from {storageLabel}? +

+

+ Removes every report under scans/. Your Vizably account and repository stay. This can’t be undone. +

+ {deleteAllError && ( +
+ {deleteAllError} +
+ )} +
+ + +
+
+ )} +
setAutoDelete((v) => !v)} /> From 8baf4a5422bd769d8a5321c165136aacdb0ad991 Mon Sep 17 00:00:00 2001 From: Samuel Olabode Date: Fri, 18 Sep 2026 17:47:42 -0600 Subject: [PATCH 2/4] feat(dashboard): implement bulk delete functionality for saved scans with confirmation and error handling --- frontend/src/App.jsx | 21 ++-- frontend/src/__tests__/accountView.test.jsx | 79 +------------ frontend/src/__tests__/dashboardView.test.jsx | 86 ++++++++++++++ frontend/src/views/AccountView.jsx | 102 +---------------- frontend/src/views/DashboardView.jsx | 108 +++++++++++++++++- 5 files changed, 208 insertions(+), 188 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index deef963..17e3021 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -322,18 +322,21 @@ function AppRoutes() { } } - /** Remove every saved scan from attached storage; keep the account connected. */ - const deleteAllSaved = async () => { - const result = await apiClient.deleteAllScans() + /** Remove several saved scans; uses the bulk-delete-all endpoint when every scan is selected. */ + const deleteManySaved = async (ids) => { + if (!ids?.length) return + let result + if (savedScans.length > 0 && ids.length === savedScans.length) { + result = await apiClient.deleteAllScans() + } else { + for (const id of ids) { + result = await apiClient.deleteScan(id) + } + } setUser((prev) => mergeAccountUpdate(prev, { scanCount: result.scanCount, scans: result.scans, })) - if (location.search.includes('scanId=')) { - setScan(null) - setProblem(null) - } - return result } const auth = (p) => { @@ -460,6 +463,7 @@ function AppRoutes() { onNav={nav} onOpen={openSaved} onDelete={deleteSaved} + onDeleteMany={deleteManySaved} saved={savedScans} provider={provider} user={shellUser} @@ -474,7 +478,6 @@ function AppRoutes() { { fireEvent.click(screen.getByRole('button', { name: /yes, sign out/i })) expect(onSignOut).toHaveBeenCalledTimes(2) }) - - it('asks for confirm before delete-all and calls onDeleteAllScans', async () => { - const onDeleteAllScans = vi.fn().mockResolvedValue({ - deletedCount: 3, - scanCount: 0, - scans: [], - }) - - const { rerender } = render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /^delete all$/i })) - expect(screen.getByText(/delete all 3 saved scans/i)).toBeInTheDocument() - expect(onDeleteAllScans).not.toHaveBeenCalled() - - fireEvent.click(screen.getByRole('button', { name: /yes, delete all/i })) - await waitFor(() => expect(onDeleteAllScans).toHaveBeenCalledTimes(1)) - - rerender( - , - ) - - expect(screen.getByRole('button', { name: /^cleared$/i })).toBeDisabled() - }) - - it('keeps scans when confirm is cancelled', () => { - const onDeleteAllScans = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /^delete all$/i })) - fireEvent.click(screen.getByRole('button', { name: /keep scans/i })) - expect(screen.getByRole('button', { name: /^delete all$/i })).toBeInTheDocument() - expect(onDeleteAllScans).not.toHaveBeenCalled() - }) - - it('shows delete-all errors without clearing', async () => { - const onDeleteAllScans = vi.fn().mockRejectedValue(new Error('GitHub refused the wipe')) - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /^delete all$/i })) - fireEvent.click(screen.getByRole('button', { name: /yes, delete all/i })) - - expect(await screen.findByRole('alert')).toHaveTextContent(/refused the wipe/i) - expect(screen.getByText(/saved scans · 3/i)).toBeInTheDocument() - }) }) diff --git a/frontend/src/__tests__/dashboardView.test.jsx b/frontend/src/__tests__/dashboardView.test.jsx index 31a8e0c..cfccbb4 100644 --- a/frontend/src/__tests__/dashboardView.test.jsx +++ b/frontend/src/__tests__/dashboardView.test.jsx @@ -120,4 +120,90 @@ describe('DashboardView', () => { expect(onDelete).not.toHaveBeenCalled() expect(screen.queryByText(/delete this scan/i)).not.toBeInTheDocument() }) + + 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() + }) }) diff --git a/frontend/src/views/AccountView.jsx b/frontend/src/views/AccountView.jsx index c814f8d..4ac8006 100644 --- a/frontend/src/views/AccountView.jsx +++ b/frontend/src/views/AccountView.jsx @@ -2,34 +2,15 @@ import { useState } from 'react' import { Button, Card, Input } from '../design-system' import { Ico, GoogleMark } from '../lib/icons' import { PROVIDERS } from '../data/placeholders' -import { apiClient } from '../lib/apiClient' /** * Account settings — profile + data/storage controls + delete account. * Deliberately framed around using LESS storage, not more. - * - * @param {object} props - * @param {() => void | Promise} props.onSignOut - * @param {(result: { scanCount: number, scans: object[] }) => void | Promise} [props.onDeleteAllScans] - * @param {object} props.user - * @param {object} [props.shellUser] - * @param {'github' | 'google'} props.provider - * @param {import('../lib/apiClient').ApiClient} [props.client] */ -export default function AccountView({ - onSignOut, - onDeleteAllScans, - user, - shellUser, - provider, - client = apiClient, -}) { +export default function AccountView({ onSignOut, user, shellUser, provider }) { const pv = PROVIDERS[provider] || PROVIDERS.github const [autoDelete, setAutoDelete] = useState(user?.account?.settings?.autoDelete90d ?? true) const [confirmDelete, setConfirmDelete] = useState(false) - /** @type {'idle' | 'confirm' | 'busy'} */ - const [deleteAllStep, setDeleteAllStep] = useState('idle') - const [deleteAllError, setDeleteAllError] = useState(null) const savedCount = user?.account?.scanCount ?? user?.account?.scans?.length ?? 0 const storageLabel = user?.storage?.full_name || pv.dest @@ -37,7 +18,6 @@ export default function AccountView({ name: user?.displayName || user?.username || 'User', email: user?.email || '', } - const deletingAll = deleteAllStep === 'busy' const Section = ({ title, desc, children }) => (
@@ -68,21 +48,6 @@ export default function AccountView({ ) - const handleDeleteAll = async () => { - setDeleteAllError(null) - setDeleteAllStep('busy') - try { - const result = onDeleteAllScans - ? await onDeleteAllScans() - : await client.deleteAllScans() - setDeleteAllStep('idle') - return result - } catch (err) { - setDeleteAllError(err?.message || 'Failed to delete saved scans') - setDeleteAllStep('confirm') - } - } - return (
@@ -118,70 +83,7 @@ export default function AccountView({
- - {savedCount === 0 ? ( - - ) : deleteAllStep === 'idle' ? ( - - ) : null} - - - {deleteAllStep !== 'idle' && savedCount > 0 && ( -
-

- Delete all {savedCount} saved scan{savedCount === 1 ? '' : 's'} from {storageLabel}? -

-

- Removes every report under scans/. Your Vizably account and repository stay. This can’t be undone. -

- {deleteAllError && ( -
- {deleteAllError} -
- )} -
- - -
-
- )} +
diff --git a/frontend/src/views/DashboardView.jsx b/frontend/src/views/DashboardView.jsx index 74dd673..17dae78 100644 --- a/frontend/src/views/DashboardView.jsx +++ b/frontend/src/views/DashboardView.jsx @@ -4,12 +4,16 @@ 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 [confirmId, setConfirmId] = useState(null) const [deletingId, setDeletingId] = useState(null) const [deleteError, setDeleteError] = useState(null) + const [selected, setSelected] = useState(() => new Set()) + const [bulkConfirm, setBulkConfirm] = useState(false) + const [bulkBusy, setBulkBusy] = useState(false) + const [bulkError, setBulkError] = useState(null) const band = (v) => v >= 90 ? { c: 'var(--green-600)', g: 'Good' } : v >= 70 ? { c: 'var(--sev-moderate)', g: 'Fair' } @@ -30,11 +34,40 @@ export default function DashboardView({ onNav, onOpen, onDelete, saved, provider } } + 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((prev) => (prev.size === saved.length ? new Set() : new Set(saved.map((s) => s.id)))) + } + + const handleBulkDelete = async () => { + if (!onDeleteMany || selected.size === 0) return + setBulkError(null) + setBulkBusy(true) + try { + await onDeleteMany(Array.from(selected)) + 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 confirming = confirmId === s.id const busy = deletingId === s.id + const checked = selected.has(s.id) return (
+ {onDeleteMany && !confirming && ( + e.stopPropagation()} + onChange={() => toggleSelected(s.id)} + style={{ flexShrink: 0, width: 16, height: 16, cursor: 'pointer' }} + /> + )} )} + {onDeleteMany && ( +
+ + {selected.size > 0 && !bulkConfirm && ( + + )} +
+ )} + + {bulkConfirm && ( +
+

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

+

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

+ {bulkError && ( +
+ {bulkError} +
+ )} +
+ + +
+
+ )} +
{[['Sites saved', saved.length, 'var(--text-strong)'], ['Avg. score', avg, band(avg).c]].map(([label, val, col]) => ( From c2f28fab0ca22b05d8b5b941934af9a93cdcea08 Mon Sep 17 00:00:00 2001 From: Samuel Olabode Date: Fri, 18 Sep 2026 17:54:49 -0600 Subject: [PATCH 3/4] refactor(dashboard): remove delete functionality from DashboardView and related tests --- frontend/src/App.jsx | 1 - frontend/src/__tests__/dashboardView.test.jsx | 47 ------ frontend/src/views/DashboardView.jsx | 140 ++++-------------- 3 files changed, 27 insertions(+), 161 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 17e3021..87f6b5e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -462,7 +462,6 @@ function AppRoutes() { { expect(onOpen).toHaveBeenCalledWith(SAVED[1]) }) - it('asks for confirm before delete and does not open the scan', async () => { - const onOpen = vi.fn() - const onDelete = vi.fn().mockResolvedValue(undefined) - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /delete scan example.com/i })) - expect(onOpen).not.toHaveBeenCalled() - expect(screen.getByText(/delete this scan/i)).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: /yes, delete/i })) - await waitFor(() => expect(onDelete).toHaveBeenCalledWith(SAVED[0])) - expect(onOpen).not.toHaveBeenCalled() - }) - - it('cancel leaves the list alone without calling onDelete', () => { - const onDelete = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /delete scan example.com/i })) - fireEvent.click(screen.getByRole('button', { name: /cancel/i })) - expect(onDelete).not.toHaveBeenCalled() - expect(screen.queryByText(/delete this scan/i)).not.toBeInTheDocument() - }) - it('does not render checkboxes or select-all when onDeleteMany is absent', () => { render( { { { new Set()) const [bulkConfirm, setBulkConfirm] = useState(false) const [bulkBusy, setBulkBusy] = useState(false) @@ -20,20 +17,6 @@ export default function DashboardView({ onNav, onOpen, onDelete, onDeleteMany, s : v >= 50 ? { c: 'var(--sev-serious)', g: 'Poor' } : { c: 'var(--sev-critical)', g: 'Critical' } - const handleDelete = async (s) => { - if (!onDelete || !s?.id) return - setDeleteError(null) - setDeletingId(s.id) - try { - await onDelete(s) - setConfirmId(null) - } catch (err) { - setDeleteError(err?.message || 'Failed to delete that scan') - } finally { - setDeletingId(null) - } - } - const toggleSelected = (id) => { setSelected((prev) => { const next = new Set(prev) @@ -65,17 +48,14 @@ export default function DashboardView({ onNav, onOpen, onDelete, onDeleteMany, s const Row = ({ s, last }) => { const [hover, setHover] = useState(false) const b = band(s.score) - const confirming = confirmId === s.id - const busy = deletingId === s.id const checked = selected.has(s.id) return (
{ if (!confirming && !busy) onOpen(s) }} + role="button" + tabIndex={0} + onClick={() => onOpen(s)} onKeyDown={(e) => { - if (confirming || busy) return if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onOpen(s) @@ -90,16 +70,16 @@ export default function DashboardView({ onNav, onOpen, onDelete, onDeleteMany, s padding: '16px 18px', background: hover ? 'var(--bg-subtle)' : 'var(--surface-card)', borderBottom: last ? 'none' : '1px solid var(--border-subtle)', - cursor: confirming || busy ? 'default' : 'pointer', + cursor: 'pointer', transition: 'background var(--duration-fast) var(--ease-standard)', }} > - {onDeleteMany && !confirming && ( + {onDeleteMany && ( e.stopPropagation()} onChange={() => toggleSelected(s.id)} style={{ flexShrink: 0, width: 16, height: 16, cursor: 'pointer' }} @@ -127,80 +107,27 @@ export default function DashboardView({ onNav, onOpen, onDelete, onDeleteMany, s }}> {Ico('Clock', 12)} Scanned {s.when}
- {confirming && ( -
e.stopPropagation()}> -

- Delete this scan? It will be removed from your storage. GitHub history may still retain it. -

-
- - -
-
- )}
- {!confirming && ( - <> - - - - {s.score} - - {b.g} - - {onDelete && ( - - )} - - - )} + + + + {s.score} + + {b.g} + +
) } @@ -271,19 +198,6 @@ export default function DashboardView({ onNav, onOpen, onDelete, onDeleteMany, s
- {deleteError && ( -
- {deleteError} -
- )} - {onDeleteMany && (
- - - - {s.score} - - {b.g} - - + {!confirming && ( + <> + + + + {s.score} + + {b.g} + + {onDelete && ( + + )} + + + )}
) } @@ -204,21 +279,21 @@ export default function DashboardView({ onNav, onOpen, onDeleteMany, saved, prov Select all - {selected.size > 0 && !bulkConfirm && ( + {selectedIds.length > 0 && !bulkConfirm && ( )}
@@ -235,7 +310,7 @@ export default function DashboardView({ onNav, onOpen, onDeleteMany, saved, prov }} >

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

They will be removed from your storage. GitHub history may still retain them. @@ -274,6 +349,19 @@ export default function DashboardView({ onNav, onOpen, onDeleteMany, saved, prov ))}

+ {deleteError && ( +
+ {deleteError} +
+ )} + {saved.map((s, i) => )}