diff --git a/backend/routes/auth.js b/backend/routes/auth.js index b3dec1b..db08822 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -264,6 +264,87 @@ function makeAuthRouter({ authService, storageService }) { }); }); + router.post('/account/wipe', requireAuth, async (req, res) => { + try { + const storageRef = req.user?.storage; + if (!storageRef?.full_name && !storageRef?.id) { + return res.status(400).json({ + error: 'No storage is attached to this session. Connect a repository first.', + }); + } + + const provider = req.body?.provider || storageRef.provider || 'github'; + const clients = await authService.clientsFor(req.user, { storageRef }); + const result = await storageService.wipeAccountStore( + provider, + storageRef, + clients, + ); + + delete req.user.storage; + delete req.user.account; + await authService.persistUser(req); + + return res.json({ + success: true, + wiped: result.wiped, + pathsRemoved: result.pathsRemoved, + storageRef: result.storageRef, + }); + } catch (err) { + console.error(err); + const allowed = new Set([400, 401, 403, 404, 501, 502, 503]); + const status = allowed.has(err.status) ? err.status : 400; + return res.status(status).json({ + error: err.message || 'Failed to wipe account storage', + code: err.code || undefined, + }); + } + }); + + router.post('/account/delete-repository', requireAuth, async (req, res) => { + try { + if (req.body?.confirm !== true) { + return res.status(400).json({ + error: 'confirm: true is required to delete the repository', + }); + } + + // Only the session-attached store — never trust a client-supplied storageRef. + const storageRef = req.user?.storage; + if (!storageRef?.full_name && !storageRef?.id) { + return res.status(400).json({ + error: + 'No storage is attached to this session. Connect a repository first.', + }); + } + + const clients = await authService.clientsFor(req.user, { storageRef }); + const result = await storageService.deleteGitHubRepository( + storageRef, + clients, + ); + + delete req.user.storage; + delete req.user.account; + await authService.persistUser(req); + + return res.json({ + success: true, + deleted: result.deleted, + full_name: result.full_name, + }); + } catch (err) { + console.error(err); + const allowed = new Set([400, 401, 403, 404, 501, 502, 503]); + const status = allowed.has(err.status) ? err.status : 400; + return res.status(status).json({ + error: err.message || 'Failed to delete repository', + code: err.code || undefined, + }); + } + }); + return router; } diff --git a/backend/services/storageService.js b/backend/services/storageService.js index 55de330..0a29c32 100644 --- a/backend/services/storageService.js +++ b/backend/services/storageService.js @@ -436,6 +436,157 @@ class StorageService { return formatted; } + /** + * Remove Vizably account files from the connected store (manifest + scans/). + * Does not delete the GitHub repository itself. + * + * @param {'github' | 'google'} provider + * @param {object} storageRef + * @param {StorageClients} clients + * @returns {Promise<{ wiped: boolean, pathsRemoved: string[], storageRef: object }>} + */ + async wipeAccountStore(provider, storageRef, clients) { + if (provider === 'google') { + const err = new Error(GOOGLE_NOT_AVAILABLE); + err.status = 501; + err.code = 'PROVIDER_NOT_AVAILABLE'; + throw err; + } + if (provider !== 'github') { + const err = new Error(`Unsupported storage provider: ${provider}`); + err.status = 400; + throw err; + } + + const octokit = clients.githubClient ?? clients.githubUserClient; + if (!octokit) { + throw new Error('GitHub client is required to wipe account storage'); + } + + const { owner, repo } = this._parseGitHubRef(storageRef); + const branch = await this._resolveGitHubBranch( + octokit, + owner, + repo, + storageRef.branch, + ); + + /** @type {Array<{ path: string, delete: true, sha?: string }>} */ + const toDelete = []; + + for (const path of [MANIFEST_PATH, LEGACY_MANIFEST_PATH]) { + const file = await this._readGitHubFile(octokit, owner, repo, path, branch); + if (file) { + toDelete.push({ path, delete: true, sha: file.sha }); + } + } + + const scanEntries = await this._listGitHubDirectory( + octokit, + owner, + repo, + SCANS_DIR, + branch, + ); + for (const entry of scanEntries) { + if (entry.type !== 'file' || !entry.name) { + continue; + } + toDelete.push({ + path: `${SCANS_DIR}/${entry.name}`, + delete: true, + sha: entry.sha, + }); + } + + if (toDelete.length === 0) { + return { + wiped: true, + pathsRemoved: [], + storageRef: this._normalizeGitHubStorageRef(storageRef, branch), + }; + } + + await this._writeGitHubFiles( + octokit, + owner, + repo, + branch, + toDelete, + 'Remove Vizably account store', + ); + + return { + wiped: true, + pathsRemoved: toDelete.map((f) => f.path), + storageRef: this._normalizeGitHubStorageRef(storageRef, branch), + }; + } + + /** + * Delete the GitHub repository that holds the account store. + * Requires a user access token (Administration permission on the App). + * + * @param {object} storageRef + * @param {StorageClients} clients + * @returns {Promise<{ deleted: boolean, full_name: string }>} + */ + async deleteGitHubRepository(storageRef, clients) { + const octokit = clients.githubUserClient ?? clients.githubClient; + if (!octokit) { + throw new Error('GitHub user client is required to delete a repository'); + } + + const { owner, repo } = this._parseGitHubRef(storageRef); + const fullName = `${owner}/${repo}`; + + try { + await octokit.rest.repos.delete({ owner, repo }); + } catch (err) { + throw this._formatGitHubDeleteError(err, fullName); + } + + return { deleted: true, full_name: fullName }; + } + + /** + * @param {unknown} err + * @param {string} fullName + * @private + */ + _formatGitHubDeleteError(err, fullName) { + const status = err?.status; + const message = err?.response?.data?.message ?? err?.message ?? ''; + + if (status === 403 || status === 401) { + const formatted = new Error( + /not accessible by integration|Resource not accessible/i.test(message) + ? 'GitHub App cannot delete repositories. Add Repository permissions → Administration: Read and write, accept the permission upgrade on your installation, then sign out and sign in again.' + : message || + 'GitHub refused to delete this repository. Confirm Administration access, then try again.', + ); + formatted.status = 403; + formatted.code = 'REPO_DELETE_FORBIDDEN'; + return formatted; + } + + if (status === 404) { + const formatted = new Error( + `Repository "${fullName}" was not found (it may already be deleted).`, + ); + formatted.status = 404; + formatted.code = 'REPO_NOT_FOUND'; + return formatted; + } + + const formatted = new Error( + message || `Could not delete repository "${fullName}".`, + ); + formatted.status = status || 500; + formatted.code = 'REPO_DELETE_FAILED'; + return formatted; + } + /** * @param {'github' | 'google'} provider * @param {object} storageRef @@ -1339,21 +1490,28 @@ class StorageService { * @private */ async _writeGitHubFiles(octokit, owner, repo, branch, files, message) { - try { - return await this._writeGitHubFilesViaGit( - octokit, - owner, - repo, - branch, - files, - message, - ); - } catch (err) { - if (!this._shouldFallbackToContentsApi(err)) { - throw Object.assign(new Error(this._formatGitHubStorageError(err)), { - status: err?.status, - cause: err, - }); + // Delete-only batches (account wipe) skip the Git Database API: GitHub + // rejects empty trees (`{"tree":[]}` → 422) when the wipe removes every + // file, and sha:null deletes are unreliable. Contents deleteFile handles both. + const deleteOnly = + files.length > 0 && files.every((file) => Boolean(file.delete)); + if (!deleteOnly) { + try { + return await this._writeGitHubFilesViaGit( + octokit, + owner, + repo, + branch, + files, + message, + ); + } catch (err) { + if (!this._shouldFallbackToContentsApi(err)) { + throw Object.assign(new Error(this._formatGitHubStorageError(err)), { + status: err?.status, + cause: err, + }); + } } } @@ -1386,11 +1544,18 @@ class StorageService { if (err?.status === 409 && /empty/i.test(message)) { return true; } + // Empty-tree / invalid delete payloads are not branch conflicts. + if (err?.status === 422 && /invalid tree info/i.test(message)) { + return true; + } if (this._isRefConflict(err)) { return false; } + // GitHub often returns 404 (not 403) when the Git Database API cannot apply a + // tree change. Contents API deleteFile still works. return ( err?.status === 403 || + err?.status === 404 || /not accessible by integration/i.test(message) || /Resource not accessible/i.test(message) ); @@ -1413,29 +1578,101 @@ class StorageService { commit_sha: baseCommitSha, }); - const treeEntries = await Promise.all( - files.map(async (file) => { + const deletePaths = new Set( + files.filter((file) => file.delete).map((file) => file.path), + ); + const writeFiles = files.filter((file) => !file.delete); + + /** @type {Array<{ path: string, mode: string, type: string, sha: string }>} */ + let treeEntries; + + if (deletePaths.size > 0) { + // Prefer rebuilding the tree without deleted paths. createTree + sha:null + // frequently returns 404 against real GitHub even when the paths exist. + const { data: existing } = await octokit.rest.git.getTree({ + owner, + repo, + tree_sha: baseCommit.tree.sha, + recursive: 'true', + }); + if (existing.truncated) { + const err = new Error( + 'Git tree is too large for an atomic wipe; falling back to Contents API', + ); + err.status = 403; + throw err; + } + + treeEntries = (existing.tree || []) + .filter( + (entry) => + entry.type === 'blob' && + entry.path && + entry.sha && + !deletePaths.has(entry.path), + ) + .map((entry) => ({ + path: entry.path, + mode: entry.mode || '100644', + type: 'blob', + sha: entry.sha, + })); + + for (const file of writeFiles) { const { data: blob } = await octokit.rest.git.createBlob({ owner, repo, content: Buffer.from(file.content, 'utf8').toString('base64'), encoding: 'base64', }); - return { + treeEntries = treeEntries.filter((entry) => entry.path !== file.path); + treeEntries.push({ path: file.path, mode: '100644', type: 'blob', sha: blob.sha, - }; - }), - ); + }); + } - const { data: tree } = await octokit.rest.git.createTree({ - owner, - repo, - base_tree: baseCommit.tree.sha, - tree: treeEntries, - }); + // GitHub rejects createTree with an empty tree array. + if (treeEntries.length === 0) { + const err = new Error( + 'Invalid tree info — cannot create an empty Git tree; use Contents API', + ); + err.status = 422; + err.response = { data: { message: 'Invalid tree info' } }; + throw err; + } + } else { + treeEntries = await Promise.all( + writeFiles.map(async (file) => { + const { data: blob } = await octokit.rest.git.createBlob({ + owner, + repo, + content: Buffer.from(file.content, 'utf8').toString('base64'), + encoding: 'base64', + }); + return { + path: file.path, + mode: '100644', + type: 'blob', + sha: blob.sha, + }; + }), + ); + } + + const createTreeParams = + deletePaths.size > 0 + ? { owner, repo, tree: treeEntries } + : { + owner, + repo, + base_tree: baseCommit.tree.sha, + tree: treeEntries, + }; + + const { data: tree } = await octokit.rest.git.createTree(createTreeParams); const { data: commit } = await octokit.rest.git.createCommit({ owner, @@ -1486,6 +1723,22 @@ class StorageService { : `${message} (${i + 1}/${files.length})`; try { + if (file.delete) { + if (!sha) { + continue; + } + const { data } = await octokit.rest.repos.deleteFile({ + owner, + repo, + path: file.path, + message: fileMessage, + sha, + branch, + }); + lastCommit = data.commit; + continue; + } + const { data } = await octokit.rest.repos.createOrUpdateFileContents({ owner, repo, diff --git a/backend/tests/auth.test.js b/backend/tests/auth.test.js index 2677203..be0dcc7 100644 --- a/backend/tests/auth.test.js +++ b/backend/tests/auth.test.js @@ -512,3 +512,145 @@ test('POST /api/auth/storage load attaches account to the session user', async ( assert.equal(user.storage.full_name, 'sam/repo'); assert.equal(persisted, true); }); + +test('POST /api/auth/account/wipe requires authentication', async () => { + const app = createTestApp(); + const res = await request(app).post('/api/auth/account/wipe'); + assert.equal(res.status, 401); +}); + +test('POST /api/auth/account/wipe clears session storage after wipe', async () => { + const user = { + ...AUTHED_USER, + storage: { id: 'R_kg', full_name: 'sam/site-audits', provider: 'github' }, + account: { accountId: 'a1', scanCount: 2 }, + }; + let persisted = false; + const app = createAuthedApp({ + user, + authService: { + clientsFor: async () => ({ githubClient: {} }), + persistUser: async () => { + persisted = true; + }, + }, + storageService: { + wipeAccountStore: async () => ({ + wiped: true, + pathsRemoved: ['vizably.json', 'scans/index.json'], + storageRef: { id: 'R_kg', full_name: 'sam/site-audits', branch: 'main' }, + }), + }, + }); + const res = await request(app).post('/api/auth/account/wipe').send({}); + assert.equal(res.status, 200); + assert.equal(res.body.success, true); + assert.equal(res.body.wiped, true); + assert.equal(user.storage, undefined); + assert.equal(user.account, undefined); + assert.equal(persisted, true); +}); + +test('POST /api/auth/account/delete-repository requires confirm true', async () => { + const app = createAuthedApp({ + user: { + ...AUTHED_USER, + storage: { id: 'R_kg', full_name: 'sam/site-audits' }, + }, + authService: { + clientsFor: async () => ({ githubUserClient: {} }), + persistUser: async () => {}, + }, + storageService: { + deleteGitHubRepository: async () => { + throw new Error('should not be called'); + }, + }, + }); + const res = await request(app) + .post('/api/auth/account/delete-repository') + .send({ confirm: false }); + assert.equal(res.status, 400); + assert.match(res.body.error, /confirm/); +}); + +test('POST /api/auth/account/delete-repository deletes when confirmed', async () => { + const user = { + ...AUTHED_USER, + storage: { id: 'R_kg', full_name: 'sam/site-audits' }, + }; + const app = createAuthedApp({ + user, + authService: { + clientsFor: async () => ({ githubUserClient: {} }), + persistUser: async () => {}, + }, + storageService: { + deleteGitHubRepository: async (ref) => ({ + deleted: true, + full_name: ref.full_name, + }), + }, + }); + const res = await request(app) + .post('/api/auth/account/delete-repository') + .send({ confirm: true }); + assert.equal(res.status, 200); + assert.equal(res.body.deleted, true); + assert.equal(res.body.full_name, 'sam/site-audits'); + assert.equal(user.storage, undefined); +}); + +test('POST /api/auth/account/delete-repository ignores client storageRef and uses session', async () => { + const user = { + ...AUTHED_USER, + storage: { id: 'R_kg', full_name: 'sam/site-audits' }, + }; + /** @type {object | null} */ + let deletedRef = null; + const app = createAuthedApp({ + user, + authService: { + clientsFor: async () => ({ githubUserClient: {} }), + persistUser: async () => {}, + }, + storageService: { + deleteGitHubRepository: async (ref) => { + deletedRef = ref; + return { deleted: true, full_name: ref.full_name }; + }, + }, + }); + const res = await request(app) + .post('/api/auth/account/delete-repository') + .send({ + confirm: true, + storageRef: { id: 'R_evil', full_name: 'evil/other-repo' }, + }); + assert.equal(res.status, 200); + assert.equal(deletedRef?.full_name, 'sam/site-audits'); + assert.equal(deletedRef?.id, 'R_kg'); +}); + +test('POST /api/auth/account/delete-repository requires attached session storage', async () => { + const app = createAuthedApp({ + user: { ...AUTHED_USER }, + authService: { + clientsFor: async () => ({ githubUserClient: {} }), + persistUser: async () => {}, + }, + storageService: { + deleteGitHubRepository: async () => { + throw new Error('should not be called'); + }, + }, + }); + const res = await request(app) + .post('/api/auth/account/delete-repository') + .send({ + confirm: true, + storageRef: { id: 'R_kg', full_name: 'sam/site-audits' }, + }); + assert.equal(res.status, 400); + assert.match(res.body.error, /No storage is attached/i); +}); diff --git a/backend/tests/storageService.test.js b/backend/tests/storageService.test.js index 07abd03..fbbcde7 100644 --- a/backend/tests/storageService.test.js +++ b/backend/tests/storageService.test.js @@ -106,6 +106,27 @@ function createMockGitHubClient(initial = {}) { }; return { data: created }; }, + delete: async () => { + if (initial.deleteRepoError) { + throw initial.deleteRepoError; + } + state.repoDeleted = true; + return { status: 204 }; + }, + deleteFile: async ({ path, sha }) => { + if (!files[path]) { + const err = new Error('Not Found'); + err.status = 404; + throw err; + } + if (sha && files[path].sha !== sha) { + const err = new Error('Reference update failed'); + err.status = 422; + throw err; + } + delete files[path]; + return { data: { commit: { sha: `delete-${path}` } } }; + }, createOrUpdateFileContents: async ({ path, content, sha }) => { if (createOrUpdateFailures > 0) { createOrUpdateFailures -= 1; @@ -154,11 +175,12 @@ function createMockGitHubClient(initial = {}) { if (path === 'scans') { const scanFiles = Object.keys(files) - .filter((p) => p.startsWith('scans/') && !p.endsWith('index.json')) + .filter((p) => p.startsWith('scans/')) .map((p) => ({ name: p.replace('scans/', ''), type: 'file', path: p, + sha: files[p].sha, })); if (scanFiles.length === 0) { const err = new Error('Not Found'); @@ -203,9 +225,40 @@ function createMockGitHubClient(initial = {}) { blobs[sha] = Buffer.from(content, 'base64').toString('utf8'); return { data: { sha } }; }, - createTree: async ({ tree }) => { + getTree: async () => { + if (initial.getTreeError) { + throw initial.getTreeError; + } + return { + data: { + truncated: Boolean(initial.treeTruncated), + tree: Object.keys(files).map((path) => ({ + path, + mode: '100644', + type: 'blob', + sha: files[path].sha, + })), + }, + }; + }, + createTree: async ({ tree, base_tree: baseTree }) => { + if (typeof initial.createTree === 'function') { + return initial.createTree({ tree, base_tree: baseTree }); + } + if (initial.createTreeError) { + throw initial.createTreeError; + } treeCounter += 1; const sha = `tree-${treeCounter}`; + // Full-tree rebuilds (no base_tree) replace the working set. + if (!baseTree) { + const keep = new Set(tree.map((entry) => entry.path)); + for (const path of Object.keys(files)) { + if (!keep.has(path)) { + delete files[path]; + } + } + } pendingTrees[sha] = tree; return { data: { sha } }; }, @@ -240,7 +293,14 @@ function createMockGitHubClient(initial = {}) { if (pending) { for (const entry of pending.tree || []) { - if (entry.path && entry.sha && blobs[entry.sha]) { + if (!entry.path) { + continue; + } + if (entry.sha == null) { + delete files[entry.path]; + continue; + } + if (blobs[entry.sha]) { files[entry.path] = { content: blobs[entry.sha], sha: entry.sha, @@ -1113,3 +1173,158 @@ test('getScanById returns not found for unknown id', async () => { (err) => err.code === 'SCAN_NOT_FOUND' && err.status === 404, ); }); + +test('wipeAccountStore removes Vizably files and leaves unrelated root files', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient({ + files: { + 'vizably.json': { + content: JSON.stringify(manifest()), + sha: 'sha-manifest', + }, + 'README.md': { content: '# keep me\n', sha: 'sha-readme' }, + 'scans/index.json': { + content: JSON.stringify({ schemaVersion: 1, scans: [] }), + sha: 'sha-index', + }, + 'scans/abc_example.com.json': { + content: JSON.stringify({ + id: 'abc', + url: 'https://example.com', + result: { problems: {} }, + }), + sha: 'sha-scan', + }, + }, + }); + + const result = await storageService.wipeAccountStore('github', STORAGE_REF, { + githubClient: client, + }); + + assert.equal(result.wiped, true); + assert.ok(result.pathsRemoved.includes('vizably.json')); + assert.ok(result.pathsRemoved.includes('scans/index.json')); + assert.ok(result.pathsRemoved.includes('scans/abc_example.com.json')); + assert.equal(client.files['vizably.json'], undefined); + assert.equal(client.files['scans/index.json'], undefined); + assert.equal(client.files['scans/abc_example.com.json'], undefined); + assert.equal(client.files['README.md'].content, '# keep me\n'); +}); + +test('wipeAccountStore is idempotent when the store is already empty', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient({ + files: { + 'README.md': { content: '# only\n', sha: 'sha-readme' }, + }, + }); + + const result = await storageService.wipeAccountStore('github', STORAGE_REF, { + githubClient: client, + }); + + assert.equal(result.wiped, true); + assert.deepEqual(result.pathsRemoved, []); + assert.equal(client.files['README.md'].content, '# only\n'); +}); + +test('wipeAccountStore removes every Vizably file when the repo has nothing else', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient({ + files: { + 'vizably.json': { + content: JSON.stringify(manifest()), + sha: 'sha-manifest', + }, + 'scans/index.json': { + content: JSON.stringify({ schemaVersion: 1, scans: [] }), + sha: 'sha-index', + }, + }, + }); + + const result = await storageService.wipeAccountStore('github', STORAGE_REF, { + githubClient: client, + }); + + assert.equal(result.wiped, true); + assert.deepEqual( + [...result.pathsRemoved].sort(), + ['scans/index.json', 'vizably.json'], + ); + assert.equal(Object.keys(client.files).length, 0); +}); + +test('wipeAccountStore falls back to Contents API when Git tree delete returns 404', async () => { + const storageService = new StorageService(); + const notFound = new Error('Not Found'); + notFound.status = 404; + notFound.response = { + url: 'https://api.github.com/repos/acme/vizably-data/git/trees', + data: { message: 'Not Found' }, + }; + const client = createMockGitHubClient({ + files: { + 'vizably.json': { + content: JSON.stringify(manifest()), + sha: 'sha-manifest', + }, + 'scans/index.json': { + content: JSON.stringify({ schemaVersion: 1, scans: [] }), + sha: 'sha-index', + }, + }, + getTreeError: notFound, + }); + + const result = await storageService.wipeAccountStore('github', STORAGE_REF, { + githubClient: client, + }); + + assert.equal(result.wiped, true); + assert.ok(result.pathsRemoved.includes('vizably.json')); + assert.ok(result.pathsRemoved.includes('scans/index.json')); + assert.equal(client.files['vizably.json'], undefined); + assert.equal(client.files['scans/index.json'], undefined); +}); + +test('wipeAccountStore stubs google until Phase 3', async () => { + const storageService = new StorageService(); + await assert.rejects( + () => storageService.wipeAccountStore('google', STORAGE_REF, {}), + (err) => err.status === 501 && /Phase 3/.test(err.message), + ); +}); + +test('deleteGitHubRepository deletes via the user client', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient(); + const result = await storageService.deleteGitHubRepository(STORAGE_REF, { + githubUserClient: client, + }); + assert.equal(result.deleted, true); + assert.equal(result.full_name, STORAGE_REF.full_name); +}); + +test('deleteGitHubRepository maps Administration 403 to REPO_DELETE_FORBIDDEN', async () => { + const storageService = new StorageService(); + const forbidden = new Error('Resource not accessible by integration'); + forbidden.status = 403; + forbidden.response = { + data: { message: 'Resource not accessible by integration' }, + }; + const client = createMockGitHubClient({ deleteRepoError: forbidden }); + await assert.rejects( + () => + storageService.deleteGitHubRepository(STORAGE_REF, { + githubUserClient: client, + }), + (err) => { + assert.equal(err.code, 'REPO_DELETE_FORBIDDEN'); + assert.equal(err.status, 403); + assert.match(err.message, /Administration/i); + return true; + }, + ); +}); diff --git a/docs/guides/auth_storage_guide/TODO.md b/docs/guides/auth_storage_guide/TODO.md index 3e845c3..7f343ee 100644 --- a/docs/guides/auth_storage_guide/TODO.md +++ b/docs/guides/auth_storage_guide/TODO.md @@ -108,6 +108,8 @@ Provider-neutral routes; Google OAuth endpoints stubbed until Phase 3. - [x] `GET /user` — safe profile (+ `storage`), **no tokens** - [x] `GET /status` — `{ authenticated, user }` - [x] `POST /logout` — `req.logout()`, destroy session, clear cookie +- [x] `POST /account/wipe` — remove Vizably files from attached storage +- [x] `POST /account/delete-repository` — optional GitHub repo delete (session storage only; UI deletes repo before wipe when chosen) - [x] **No frontend import** (`PROVIDERS` not used here) - [x] `module.exports = makeAuthRouter` diff --git a/docs/guides/auth_storage_guide/accountDeletion.md b/docs/guides/auth_storage_guide/accountDeletion.md new file mode 100644 index 0000000..78a1aa8 --- /dev/null +++ b/docs/guides/auth_storage_guide/accountDeletion.md @@ -0,0 +1,266 @@ +# Account deletion — implementation guide (closes [#82](https://github.com/codrlabs/vizably/issues/82)) + +Step-by-step plan to make **Delete my account** permanently remove the Vizably +account store — not just sign the user out. + +Related sources of truth: + +- [`accountStorageContract.md`](./accountStorageContract.md) — on-disk layout +- [`githubGoogleAuthStorageImplementation.md`](./githubGoogleAuthStorageImplementation.md) — auth/storage flow +- Issue: [codrlabs/vizably#82](https://github.com/codrlabs/vizably/issues/82) + +--- + +## 0. What “delete account” means in Vizably + +Vizably has **no user database**. The account lives in storage the user owns +(GitHub repo or, later, Google Drive folder). Deleting an account therefore means: + +1. Remove Vizably’s data from that store (`vizably.json` + `scans/`). +2. Optionally delete the GitHub repository itself (user chooses). +3. End the session (sign out / clear cookie). + +The user’s GitHub/Google **identity** is never deleted — only Vizably’s use of +their storage and our session. + +--- + +## 1. Investigate the current delete flow (done baseline) + +Confirm these facts before coding (re-check if the tree has drifted): + +| Location | Today’s behaviour | +|----------|-------------------| +| `frontend/src/views/AccountView.jsx` | Danger zone → confirm → **`onSignOut` only**. Copy even says storage deletion “is not wired yet”. | +| `frontend/src/App.jsx` → `signOut` | `POST /api/auth/logout`, clear local user, navigate to landing. | +| `backend/routes/auth.js` → `POST /logout` | Passport logout + session clear. No storage writes. | +| `StorageService` | No wipe / delete-repo helpers yet. | +| `frontend/src/lib/apiClient.js` | No delete-account API method. | + +**Bug to fix:** Confirm button label is “Yes, sign out” and it only logs out. + +--- + +## 2. Agree the product sequence (matches #82) + +Implement this UX, in order: + +``` +User clicks “Delete my account” + │ + ▼ +Confirm: wipe Vizably data in the connected store + (vizably.json + scans/ — irreversible) + │ + ▼ +Backend wipes store contents (account store removed; repo may still exist) + │ + ▼ +Ask: “Also delete the GitHub repository ?” + │ + ┌────┴────┐ + Yes No + │ │ + ▼ ▼ +Delete Leave empty / non-Vizably repo +repo via on GitHub +GitHub API + │ │ + └────┬────┘ + ▼ +Sign out + clear session + ▼ +Landing + short success message +``` + +Notes: + +- **Step A (wipe files) is mandatory** for “account deleted.” +- **Step B (delete repo) is optional** and must be an explicit second confirmation. +- If the connected store was not a Vizably-created repo (user picked an existing + repo that also holds other files), prefer **wipe Vizably paths only**, never + `git rm` unrelated content. Prefer deleting known paths + (`vizably.json`, `equalview.json` legacy, `scans/**`) in one commit. +- Google Drive: stub with a clear “not available until Phase 3” until Drive + adapter exists (same pattern as other storage stubs). + +--- + +## 3. Backend — StorageService + +Add methods on `backend/services/storageService.js` (pure storage brain; no HTTP): + +### 3.1 `wipeAccountStore(provider, storageRef, clients)` + +1. Resolve owner/repo/branch from `storageRef` + clients (same helpers as load/save). +2. List files under the store root that belong to Vizably: + - `vizably.json` (and legacy `equalview.json` if present) + - everything under `scans/` +3. Delete them in **one atomic GitHub commit** (reuse the existing tree/commit/ref + write path used by `saveScanResults` / init — do not leave a half-wiped store). +4. Return a summary: `{ wiped: true, pathsRemoved: [...], storageRef }`. +5. Errors: map permission failures to actionable codes (e.g. `STORAGE_WRITE_FORBIDDEN`). + +### 3.2 `deleteGitHubRepository(storageRef, clients)` + +1. Require `githubUserClient` (user access token / App UAT) — installation token + alone may not delete the repo depending on permissions. +2. Call GitHub delete repo (`octokit.rest.repos.delete({ owner, repo })`). +3. Requires **Administration** on the App installation (same permission class as + create). Surface `REPO_DELETE_FORBIDDEN` on 403 with the same upgrade guidance + style as `REPO_CREATE_FORBIDDEN`. +4. Return `{ deleted: true, full_name }` or a structured error. + +### 3.3 Unit tests (`backend/tests/storageService.test.js`) + +- Wipe removes manifest + scan files; unrelated root files stay. +- Wipe on empty / already-wiped store is idempotent (or clear 404 handling). +- Delete repo success + 403 → `REPO_DELETE_FORBIDDEN`. +- Google provider returns Phase-3 stub error. + +Run impact analysis on any method you edit (`wipe` / delete helpers and callers) +per project GitNexus rules before changing symbols. + +--- + +## 4. Backend — Auth routes + +Add authenticated endpoints in `backend/routes/auth.js` (thin; call +`storageService` + `authService.clientsFor`): + +| Method | Path | Body | Behaviour | +|--------|------|------|-----------| +| `POST` | `/api/auth/account/wipe` | (optional provider) | Wipe Vizably files in the session user’s attached `storage`. | +| `POST` | `/api/auth/account/delete-repository` | `{ confirm: true }` | Delete the GitHub repo for **session-attached** `storage` only. Ignore any client `storageRef`. Require explicit `confirm`. | +| `POST` | `/api/auth/account/delete` | `{ deleteRepository: boolean }` | Optional orchestrated endpoint: if `deleteRepository` → delete repo first; else wipe; then logout. | + +Recommended for #82 UX (two questions, **collect both answers before mutating**): + +1. Confirm wipe intent (no API yet). +2. GitHub only: ask whether to also delete the repository. +3. If yes → `POST /api/auth/account/delete-repository` **first** (whole repo gone; + wipe is unnecessary). A failed delete (e.g. missing Administration) leaves + Vizably data intact. +4. If no → `POST /api/auth/account/wipe` only. +5. Always finish with existing `POST /api/auth/logout`. + +**Session rules:** + +- Require auth + attached `user.storage` (same as load/save paths). +- `delete-repository` must **not** accept a client-supplied `storageRef` — only + the session-attached store. +- After wipe/delete, clear `user.storage` / account payload from the session + before or as part of logout so a stale cookie cannot reload a deleted store. +- Never write OAuth tokens into the store (existing non-negotiable). + +**Route tests** (`backend/tests/auth.test.js`): + +- Unauthenticated → 401. +- Wipe returns success and does not leave session claiming a loadable store. +- Delete-repository without `confirm` → 400. +- Delete-repository uses session storage; body `storageRef` is ignored. +- Delete-repository without attached session storage → 400. +- Happy path wipe **or** delete + logout. + +--- + +## 5. Frontend — API client + +In `frontend/src/lib/apiClient.js` (only file that may `fetch`): + +- `wipeAccount()` → `POST /api/auth/account/wipe` +- `deleteAccountRepository()` → `POST /api/auth/account/delete-repository` +- or `deleteAccount({ deleteRepository })` → orchestrated endpoint + +Mirror error `code` / `message` to the UI. + +--- + +## 6. Frontend — AccountView UX + +Replace the logout-only danger zone in `frontend/src/views/AccountView.jsx`: + +1. **Step 1 — Wipe intent confirm** (no mutation yet) + - Copy: deletes Vizably data in `{storageLabel}` (`vizably.json` + scans). + - GitHub: “Continue” → Step 2. Google / non-GitHub: wipe API then sign out. + +2. **Step 2 — Repo delete prompt** (GitHub only, **before** any wipe) + - “Also delete the repository `{full_name}` on GitHub?” + - Yes → delete-repository API first (wipe unnecessary if repo is gone). + - No → wipe API only. + - Disclose Administration permission; on failure leave the store intact. + +3. **Step 3 — Sign out** + - Call `onAccountDeleted` / logout after a successful wipe **or** repo delete. + - Remove the “storage deletion is not wired yet” disclaimer. + +4. **Success** + - Landing (or sign-in) with a one-time banner: “Your Vizably account data was + removed” (+ “repository deleted” if applicable). Pass via navigate state or + query flag; do not persist it in storage. + +Wire `App.jsx` so AccountView receives the new handlers (or a single +`onDeleteAccount`) instead of reusing `onSignOut` for delete confirm. + +Update `frontend/src/__tests__/accountView.test.jsx`: + +- Confirm no longer calls logout-only path without wipe. +- Two-step flow: wipe then optional repo delete then sign-out. +- Error states for wipe / repo-delete failures. + +--- + +## 7. Permissions & copy (GitHub App) + +Deleting a repository needs **Administration: Read and write** on the Vizably +GitHub App (same family as in-app create). In the UI: + +- Mention that optional repo deletion uses Administration. +- On 403, reuse the upgrade / re-auth messaging pattern from create-forbidden. + +Wiping files only needs **Contents: write** on the installed repo. + +--- + +## 8. Docs to update when implementing + +Keep these in sync with the code (same rule as other auth/storage work): + +- This guide (mark steps done in the checklist below). +- [`githubGoogleAuthStorageImplementation.md`](./githubGoogleAuthStorageImplementation.md) — add wipe / delete endpoints to the API table. +- [`accountStorageContract.md`](./accountStorageContract.md) — short “Deleting a store” section (wipe paths; optional repo delete). +- [`TODO.md`](./TODO.md) — check off account-deletion items. +- Legal/privacy blurbs in `LegalView` if they still imply disconnect alone removes scans (align with real behaviour). + +--- + +## 9. Suggested commit sequence + +Keep PRs reviewable (~4–6 commits): + +1. `StorageService.wipeAccountStore` + unit tests +2. `StorageService.deleteGitHubRepository` + unit tests +3. Auth routes + auth tests +4. `apiClient` + AccountView two-step UX + frontend tests +5. Docs + legal/copy alignment + +Run `detect_changes` before committing (GitNexus project rule). Do not add +Cursor co-author trailers if the team asks to omit them. + +--- + +## 10. Acceptance checklist (closes #82) + +- [x] “Delete my account” no longer only signs the user out. +- [x] Vizably files (`vizably.json` / legacy manifest + `scans/`) are removed from + the connected store. +- [x] User is asked whether to delete the GitHub repository; Yes deletes it, No leaves it. +- [x] Session is cleared afterward; user cannot load the old account without + reconnecting / re-init. +- [x] Failures (permissions, network) show clear errors and do not silently + pretend deletion succeeded. +- [x] Backend + frontend tests cover wipe, optional repo delete, and logout. +- [x] Docs updated; Google remains explicitly stubbed until Phase 3. + +When the checklist is green and the PR is merged, close +[#82](https://github.com/codrlabs/vizably/issues/82). diff --git a/docs/guides/auth_storage_guide/accountStorageContract.md b/docs/guides/auth_storage_guide/accountStorageContract.md index 01132b0..251cc3c 100644 --- a/docs/guides/auth_storage_guide/accountStorageContract.md +++ b/docs/guides/auth_storage_guide/accountStorageContract.md @@ -205,6 +205,24 @@ summary) must be atomic or partial-write tolerant: --- +## Deleting a store + +Account deletion is **storage wipe**, not deleting the user’s GitHub/Google +identity: + +1. **Wipe (required)** — delete only Vizably paths in one commit: + `vizably.json`, legacy `equalview.json` if present, and every file under + `scans/`. Unrelated root files stay untouched. +2. **Delete repository (optional)** — after wipe, the user may choose to delete + the GitHub repository (`repos.delete`). Requires Administration permission. +3. **Session** — clear attached `storage` / `account` from the session and log + out. + +See [`accountDeletion.md`](accountDeletion.md) for the full product flow and +API surface. + +--- + ## Quick checklist for implementers - [ ] `vizably.json` written with random `account.id` + stable provider ids. diff --git a/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md b/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md index 5c05742..7edf039 100644 --- a/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md +++ b/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md @@ -253,6 +253,8 @@ at router level. **No frontend imports** (`PROVIDERS` lives in the frontend only | GET | `/api/auth/user` | Current user profile (+ `storage` if attached). No tokens. | | GET | `/api/auth/status` | `{ authenticated, user }` | | POST | `/api/auth/logout` | `req.logout()`, destroy session, clear cookie | +| POST | `/api/auth/account/wipe` | Remove Vizably files (`vizably.json` + `scans/`) from attached storage; clear session storage | +| POST | `/api/auth/account/delete-repository` | `{ confirm: true }` — delete the session-attached GitHub repo (Administration); ignore client `storageRef` | **Mounting** — exactly once, in `backend/routes/index.js`: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2874d22..2ef0bc1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -304,6 +304,22 @@ function AppRoutes() { navigate(PATHS.landing) } + const handleAccountDeleted = async ({ deletedRepository }) => { + try { + await apiClient.logout() + } catch { + // Clear local state even if the network call fails. + } + setUser(null) + navigate(PATHS.landing, { + replace: true, + state: { + accountDeleted: true, + deletedRepository: Boolean(deletedRepository), + }, + }) + } + const route = routeKeyFor(location.pathname) useEffect(() => { @@ -386,6 +402,7 @@ function AppRoutes() { { @@ -17,6 +17,7 @@ describe('AccountView', () => { render( { render( { expect(screen.getByRole('switch')).toBeChecked() }) - it('calls onSignOut from the header and from delete confirm', () => { + it('calls onSignOut from the header without wiping', () => { const onSignOut = vi.fn() render( { fireEvent.click(screen.getByRole('button', { name: /^sign out$/i })) expect(onSignOut).toHaveBeenCalledTimes(1) + }) + + it('asks about repo delete before any mutation, then deletes the repo first', async () => { + const onAccountDeleted = vi.fn() + const client = { + wipeAccount: vi.fn(), + deleteAccountRepository: vi.fn().mockResolvedValue({ + success: true, + deleted: true, + full_name: 'sam/vizably-scans', + }), + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /delete my account/i })) + fireEvent.click(screen.getByRole('button', { name: /^continue$/i })) + + expect(await screen.findByText(/also delete the github repository/i)).toBeInTheDocument() + expect(client.wipeAccount).not.toHaveBeenCalled() + expect(client.deleteAccountRepository).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: /yes, delete repository/i })) + + await waitFor(() => expect(client.deleteAccountRepository).toHaveBeenCalledTimes(1)) + await waitFor(() => + expect(onAccountDeleted).toHaveBeenCalledWith({ deletedRepository: true }), + ) + expect(client.wipeAccount).not.toHaveBeenCalled() + }) + + it('wipes only when the user keeps the repository', async () => { + const onAccountDeleted = vi.fn() + const client = { + wipeAccount: vi.fn().mockResolvedValue({ + success: true, + wiped: true, + pathsRemoved: [], + storageRef: { id: 'R_kg', full_name: 'sam/vizably-scans', branch: 'main' }, + }), + deleteAccountRepository: vi.fn(), + } + + render( + , + ) fireEvent.click(screen.getByRole('button', { name: /delete my account/i })) - fireEvent.click(screen.getByRole('button', { name: /yes, sign out/i })) - expect(onSignOut).toHaveBeenCalledTimes(2) + fireEvent.click(screen.getByRole('button', { name: /^continue$/i })) + expect(await screen.findByText(/also delete the github repository/i)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /no, wipe data only/i })) + + await waitFor(() => expect(client.wipeAccount).toHaveBeenCalledTimes(1)) + await waitFor(() => + expect(onAccountDeleted).toHaveBeenCalledWith({ deletedRepository: false }), + ) + expect(client.deleteAccountRepository).not.toHaveBeenCalled() + }) + + it('leaves the store intact when repo delete fails', async () => { + const onAccountDeleted = vi.fn() + const client = { + wipeAccount: vi.fn(), + deleteAccountRepository: vi.fn().mockRejectedValue( + new Error('GitHub App cannot delete repositories'), + ), + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /delete my account/i })) + fireEvent.click(screen.getByRole('button', { name: /^continue$/i })) + fireEvent.click(await screen.findByRole('button', { name: /yes, delete repository/i })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/cannot delete/i) + expect(client.wipeAccount).not.toHaveBeenCalled() + expect(onAccountDeleted).not.toHaveBeenCalled() + }) + + it('shows wipe errors without signing out', async () => { + const onAccountDeleted = vi.fn() + const client = { + wipeAccount: vi.fn().mockRejectedValue(new Error('GitHub refused the wipe')), + deleteAccountRepository: vi.fn(), + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /delete my account/i })) + fireEvent.click(screen.getByRole('button', { name: /^continue$/i })) + fireEvent.click(await screen.findByRole('button', { name: /no, wipe data only/i })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/refused the wipe/i) + expect(onAccountDeleted).not.toHaveBeenCalled() }) }) diff --git a/frontend/src/__tests__/landingView.test.jsx b/frontend/src/__tests__/landingView.test.jsx index 95b181c..13b7687 100644 --- a/frontend/src/__tests__/landingView.test.jsx +++ b/frontend/src/__tests__/landingView.test.jsx @@ -1,9 +1,14 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' import LandingView from '../views/LandingView' function renderLanding(onScan = vi.fn()) { - render() + render( + + + , + ) return onScan } diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js index fff968e..502d60f 100644 --- a/frontend/src/lib/apiClient.js +++ b/frontend/src/lib/apiClient.js @@ -95,6 +95,36 @@ export class ApiClient { return this._request('/api/auth/logout', { method: 'POST' }) } + /** + * Wipe Vizably files (manifest + scans/) from the attached store. + * @returns {Promise<{ + * success: boolean, + * wiped: boolean, + * pathsRemoved: string[], + * storageRef: object, + * }>} + */ + wipeAccount() { + return this._request('/api/auth/account/wipe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + } + + /** + * Delete the GitHub repository that holds the account store. + * Uses the session-attached storage only (no client-supplied storageRef). + * @returns {Promise<{ success: boolean, deleted: boolean, full_name: string }>} + */ + deleteAccountRepository() { + return this._request('/api/auth/account/delete-repository', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ confirm: true }), + }) + } + /** * @param {'github' | 'google'} provider * @returns {Promise<{ provider: string, storages: object[] }>} diff --git a/frontend/src/views/AccountView.jsx b/frontend/src/views/AccountView.jsx index 7ec93a6..1272f82 100644 --- a/frontend/src/views/AccountView.jsx +++ b/frontend/src/views/AccountView.jsx @@ -2,15 +2,37 @@ 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. + * + * Delete flow collects both confirms before mutating. If the user wants the + * GitHub repo gone, delete it first (wipe is then unnecessary). If they keep + * the repo, wipe Vizably files only. A failed repo delete leaves the store intact. + * + * @param {object} props + * @param {() => void | Promise} props.onSignOut + * @param {(result: { deletedRepository: boolean }) => void | Promise} props.onAccountDeleted + * @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, + onAccountDeleted, + 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-wipe' | 'ask-repo' | 'busy'} */ + const [deleteStep, setDeleteStep] = useState('idle') + const [deleteError, setDeleteError] = useState(null) const savedCount = user?.account?.scanCount ?? user?.account?.scans?.length ?? 0 const storageLabel = user?.storage?.full_name || pv.dest @@ -18,6 +40,8 @@ export default function AccountView({ onSignOut, user, shellUser, provider }) { name: user?.displayName || user?.username || 'User', email: user?.email || '', } + const isGitHub = provider === 'github' + const deleting = deleteStep === 'busy' const Section = ({ title, desc, children }) => (
@@ -48,6 +72,50 @@ export default function AccountView({ onSignOut, user, shellUser, provider }) { ) + const finishDeletion = async (deletedRepository) => { + await onAccountDeleted({ deletedRepository }) + } + + /** First confirm — for GitHub, ask about the repo before any mutation. */ + const handleConfirmWipeIntent = async () => { + setDeleteError(null) + if (isGitHub) { + setDeleteStep('ask-repo') + return + } + setDeleteStep('busy') + try { + await client.wipeAccount() + await finishDeletion(false) + } catch (err) { + setDeleteError(err.message || 'Failed to delete Vizably data from storage') + setDeleteStep('confirm-wipe') + } + } + + const handleRepoChoice = async (shouldDeleteRepo) => { + setDeleteError(null) + setDeleteStep('busy') + try { + if (shouldDeleteRepo) { + // Delete the repo first so a 403 leaves Vizably data untouched. + await client.deleteAccountRepository() + await finishDeletion(true) + return + } + await client.wipeAccount() + await finishDeletion(false) + } catch (err) { + setDeleteError( + err.message || + (shouldDeleteRepo + ? 'Failed to delete the GitHub repository' + : 'Failed to delete Vizably data from storage'), + ) + setDeleteStep('ask-repo') + } + } + return (
@@ -58,7 +126,6 @@ export default function AccountView({ onSignOut, user, shellUser, provider }) {
- {/* Profile */}
@@ -79,7 +146,6 @@ export default function AccountView({ onSignOut, user, shellUser, provider }) {
- {/* Data & storage */}
@@ -100,28 +166,68 @@ export default function AccountView({ onSignOut, user, shellUser, provider }) {
- {/* Danger zone */}

Delete account

- Disconnect Vizably and delete the scans it saved in {pv.storeShort}. Your {pv.name} account itself stays untouched. This can’t be undone. + Remove Vizably’s data from {pv.storeShort} ({storageLabel}), or delete the whole GitHub repository. Your {pv.name} login itself stays untouched. This can’t be undone.

- {!confirmDelete ? ( - - ) : ( + + {deleteError && ( +
+ {deleteError} +
+ )} + + {deleteStep === 'idle' && ( + + )} + + {deleteStep === 'confirm-wipe' && (

- Are you sure? This deletes everything, permanently. + Delete Vizably data in {storageLabel}? This removes vizably.json and all saved scans.

-
- - +
+ +
-

- Storage deletion from {storageLabel} is not wired yet — this signs you out for now. +

+ )} + + {deleteStep === 'ask-repo' && ( +
+

+ Also delete the GitHub repository {storageLabel}? +

+

+ Yes deletes the whole repo (needs Vizably’s GitHub App Administration permission). No only removes Vizably files and leaves the repo. If delete fails, nothing is wiped yet.

+
+ + +
)} + + {deleteStep === 'busy' && ( +

+ Working… +

+ )}

diff --git a/frontend/src/views/LandingView.jsx b/frontend/src/views/LandingView.jsx index 8f21961..e914af8 100644 --- a/frontend/src/views/LandingView.jsx +++ b/frontend/src/views/LandingView.jsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useLocation } from 'react-router-dom' import { Button, Input } from '../design-system' import ScanProgressIndicator from '../components/ScanProgressIndicator' import { Ico } from '../lib/icons' @@ -10,9 +11,17 @@ import { normalizeUrl } from '../utils/urlValidator' * when results are ready (the App navigates away on success). */ export default function LandingView({ onScan }) { + const location = useLocation() + const deletionNotice = location.state?.accountDeleted + ? location.state.deletedRepository + ? 'Your Vizably account data was removed and the GitHub repository was deleted.' + : 'Your Vizably account data was removed from storage.' + : null + const [url, setUrl] = useState('') const [status, setStatus] = useState('idle') const [err, setErr] = useState('') + const [notice, setNotice] = useState(deletionNotice) const examples = ['codrlabs.com', 'stripe.com', 'wikipedia.org'] @@ -36,6 +45,47 @@ export default function LandingView({ onScan }) { return (

+ {notice && ( +
+ {Ico('CircleCheck', 16, 'currentColor')} +
+ {notice} + +
+
+ )}
WCAG accessibility scanner
diff --git a/frontend/src/views/LegalView.jsx b/frontend/src/views/LegalView.jsx index a3ec78d..cf2bc74 100644 --- a/frontend/src/views/LegalView.jsx +++ b/frontend/src/views/LegalView.jsx @@ -21,7 +21,7 @@ export default function LegalView({ doc = 'privacy', onNav }) { 'We don’t run advertising trackers or third-party ad pixels.', 'We don’t store scans from signed-out visitors beyond the time it takes to show your results.', ] }, - { h: 'How long we keep it', p: 'Because saved reports live in your storage, you control retention: remove individual scans, clear them all, or turn on auto-delete for anything older than 90 days. Disconnecting your account removes the scans Vizably saved there and revokes our access.' }, + { h: 'How long we keep it', p: 'Because saved reports live in your storage, you control retention: remove individual scans, clear them all, or turn on auto-delete for anything older than 90 days. Deleting your Vizably account removes the scans Vizably saved there (and can optionally delete the connected repository), then revokes our session access.' }, { h: 'Your controls', p: 'You can view, export, or delete your data at any time from Account → Settings. Deleting is immediate and permanent.' }, { h: 'Contact', p: 'Questions about privacy? Open an issue on our GitHub repository and we’ll respond in the open.' }, ],