From 5f52c28d279be2a74c1eecd32677cbf1e72be1d5 Mon Sep 17 00:00:00 2001 From: Samuel Olabode Date: Fri, 21 Aug 2026 13:32:01 -0600 Subject: [PATCH] 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 f3a3fdd..b194816 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 ee46cc0..c19560f 100644 --- a/backend/services/storageService.js +++ b/backend/services/storageService.js @@ -955,6 +955,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 883b313..0f67ae6 100644 --- a/backend/tests/storageService.test.js +++ b/backend/tests/storageService.test.js @@ -1332,6 +1332,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 4e700c9..910e013 100644 --- a/docs/guides/auth_storage_guide/TODO.md +++ b/docs/guides/auth_storage_guide/TODO.md @@ -129,6 +129,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) @@ -155,6 +156,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`) — the picker 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 7b07da6..77b10a3 100644 --- a/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md +++ b/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md @@ -359,6 +359,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 b8e9a1a..48dc310 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() @@ -425,6 +439,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 7a05ebf..0e0731b 100644 --- a/frontend/src/__tests__/apiClient.test.js +++ b/frontend/src/__tests__/apiClient.test.js @@ -241,4 +241,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 ba8756e..cedf472 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)} />