Skip to content

Commit 8583ba1

Browse files
committed
feat(claw-app): audit cleanup dialog with typed-confirmation gate
Adds a Storage button to the /audit header that opens a two-stage shadcn AlertDialog for the destructive /audit/cleanup endpoint. Stage 1 lets the user pick a threshold (15/30/90 days). Only ranges with at least one session appear as options, per Dani's spec; if none of the three has any candidates, the whole button is hidden. Stage 2 is a typed-confirmation gate: the user has to type 'delete N sessions older than D days' verbatim before the destructive Delete button unlocks. Case-sensitive exact match. The phrase mirrors the read-back line above the input and rebuilds when the range changes so muscle memory from a prior cleanup cannot unlock a new one. Same UX as GitHub repo delete / Stripe account delete. On success: the mutation invalidates useTasks + useAuditCleanupCandidates so the audit list, empty state, and Storage button all refetch on the same tick. On failure: dialog stays on Stage 2 with the typed input cleared so hitting Enter cannot silently retry. Client hooks (useAuditCleanupCandidates + useAuditCleanup) live in modules/api/audit.hooks.ts alongside the existing useTasks factories, per the react-query-kit rules in CLAUDE.md. Tests: 15 helper tests (phrase builder + case-sensitive matcher + byte formatter) + 3 button-visibility tests. The interactive two-stage flow is exercised by the pure-logic helpers plus the server-side cleanup tests.
1 parent 96966e8 commit 8583ba1

7 files changed

Lines changed: 628 additions & 2 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* @license
3+
* Copyright 2025 BrowserOS
4+
* SPDX-License-Identifier: AGPL-3.0-or-later
5+
*
6+
* Static-markup checks for the audit CleanupButton's visibility gate.
7+
* Interactive two-stage dialog behaviour is covered by the pure-logic
8+
* unit tests in `cleanup.helpers.test.ts` (phrase builder + comparator)
9+
* plus the server-side audit-cleanup tests. This file only asserts the
10+
* outer rule: the button hides itself when there is nothing to clean.
11+
*/
12+
13+
import { describe, expect, it, mock } from 'bun:test'
14+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
15+
import { renderToStaticMarkup } from 'react-dom/server'
16+
import type { CleanupCandidatesResponse } from '@/modules/api/audit.hooks'
17+
import * as auditHooks from '@/modules/api/audit.hooks'
18+
19+
let candidatesOverride: CleanupCandidatesResponse | undefined
20+
21+
// Re-export the real module surface with only the two cleanup hooks
22+
// stubbed. Preserves useTasks, useTaskDetail, useDispatches etc. for
23+
// any downstream consumer (Audit.test.tsx runs in the same process
24+
// and would otherwise resolve to a mock that's missing those exports).
25+
mock.module('@/modules/api/audit.hooks', () => ({
26+
...auditHooks,
27+
useAuditCleanupCandidates: () => ({ data: candidatesOverride }),
28+
useAuditCleanup: () => ({ mutate: () => {}, isPending: false }),
29+
}))
30+
31+
const { CleanupButton } = await import('./CleanupButton')
32+
33+
function render(): string {
34+
const client = new QueryClient({
35+
defaultOptions: { queries: { retry: false } },
36+
})
37+
return renderToStaticMarkup(
38+
<QueryClientProvider client={client}>
39+
<CleanupButton />
40+
</QueryClientProvider>,
41+
)
42+
}
43+
44+
describe('CleanupButton visibility', () => {
45+
it('renders nothing when candidates data is still loading', () => {
46+
candidatesOverride = undefined
47+
expect(render()).toBe('')
48+
})
49+
50+
it('renders nothing when every range has zero sessions', () => {
51+
candidatesOverride = {
52+
ranges: [
53+
{
54+
olderThanDays: 15,
55+
sessionCount: 0,
56+
dispatchCount: 0,
57+
bytesOnDisk: 0,
58+
},
59+
{
60+
olderThanDays: 30,
61+
sessionCount: 0,
62+
dispatchCount: 0,
63+
bytesOnDisk: 0,
64+
},
65+
{
66+
olderThanDays: 90,
67+
sessionCount: 0,
68+
dispatchCount: 0,
69+
bytesOnDisk: 0,
70+
},
71+
],
72+
}
73+
expect(render()).toBe('')
74+
})
75+
76+
it('renders the Storage button when at least one range has sessions', () => {
77+
candidatesOverride = {
78+
ranges: [
79+
{
80+
olderThanDays: 15,
81+
sessionCount: 3,
82+
dispatchCount: 12,
83+
bytesOnDisk: 1000,
84+
},
85+
{
86+
olderThanDays: 30,
87+
sessionCount: 0,
88+
dispatchCount: 0,
89+
bytesOnDisk: 0,
90+
},
91+
{
92+
olderThanDays: 90,
93+
sessionCount: 0,
94+
dispatchCount: 0,
95+
bytesOnDisk: 0,
96+
},
97+
],
98+
}
99+
const html = render()
100+
expect(html).toContain('Storage')
101+
})
102+
})
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @license
3+
* Copyright 2025 BrowserOS
4+
* SPDX-License-Identifier: AGPL-3.0-or-later
5+
*
6+
* The "Storage" button in the audit header. Only renders when the
7+
* candidates query returns at least one non-empty range (i.e. there
8+
* IS data older than the smallest threshold). Owns the dialog open
9+
* state so the header component doesn't have to.
10+
*/
11+
12+
import { Trash2 } from 'lucide-react'
13+
import { useState } from 'react'
14+
import { Button } from '@/components/ui/button'
15+
import type { CleanupCandidateStats } from '@/modules/api/audit.hooks'
16+
import { useAuditCleanupCandidates } from '@/modules/api/audit.hooks'
17+
import { CleanupDialog } from './CleanupDialog'
18+
19+
/**
20+
* A range is worth showing to the user only when at least one session
21+
* would be affected. Ranges with zero sessions are omitted entirely
22+
* from the dialog (per Dani's spec).
23+
*/
24+
function nonEmptyRanges(
25+
ranges: CleanupCandidateStats[] | undefined,
26+
): CleanupCandidateStats[] {
27+
return (ranges ?? []).filter((r) => r.sessionCount > 0)
28+
}
29+
30+
export function CleanupButton() {
31+
const [open, setOpen] = useState(false)
32+
const candidates = useAuditCleanupCandidates()
33+
const ranges = nonEmptyRanges(candidates.data?.ranges)
34+
35+
if (ranges.length === 0) return null
36+
37+
return (
38+
<>
39+
<Button
40+
variant="ghost"
41+
size="sm"
42+
onClick={() => setOpen(true)}
43+
className="h-8 gap-1.5 font-mono text-[11px] text-ink-2 uppercase tracking-[0.08em] hover:bg-card-tint"
44+
>
45+
<Trash2 className="size-3.5" />
46+
Storage
47+
</Button>
48+
<CleanupDialog open={open} onOpenChange={setOpen} ranges={ranges} />
49+
</>
50+
)
51+
}
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/**
2+
* @license
3+
* Copyright 2025 BrowserOS
4+
* SPDX-License-Identifier: AGPL-3.0-or-later
5+
*
6+
* Two-stage confirmation for the destructive /audit/cleanup call.
7+
*
8+
* Stage 1 Pick a range. Radios for whichever thresholds have data.
9+
* Nothing dangerous happens here; Continue is neutral.
10+
*
11+
* Stage 2 Typed-confirmation gate. Read-back line + input where the
12+
* user has to type "delete N sessions older than D days"
13+
* verbatim before Delete unlocks. Case-sensitive exact
14+
* match. Rebuilds when the range changes so muscle memory
15+
* from a prior cleanup cannot unlock a new one. Same
16+
* pattern as GitHub repo delete / Stripe account delete.
17+
*
18+
* Failure clears the typed input so hitting Enter without re-reading
19+
* cannot retry silently.
20+
*/
21+
22+
import { AlertTriangle } from 'lucide-react'
23+
import { useEffect, useState } from 'react'
24+
import {
25+
AlertDialog,
26+
AlertDialogAction,
27+
AlertDialogCancel,
28+
AlertDialogContent,
29+
AlertDialogDescription,
30+
AlertDialogFooter,
31+
AlertDialogHeader,
32+
AlertDialogTitle,
33+
} from '@/components/ui/alert-dialog'
34+
import { Button } from '@/components/ui/button'
35+
import { Input } from '@/components/ui/input'
36+
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
37+
import type {
38+
CleanupCandidateStats,
39+
CleanupResult,
40+
} from '@/modules/api/audit.hooks'
41+
import { useAuditCleanup } from '@/modules/api/audit.hooks'
42+
import {
43+
buildConfirmationPhrase,
44+
formatBytes,
45+
matchesConfirmationPhrase,
46+
} from './cleanup.helpers'
47+
48+
interface CleanupDialogProps {
49+
open: boolean
50+
onOpenChange: (open: boolean) => void
51+
/** Only the non-empty ranges. Empty ones are filtered by the caller. */
52+
ranges: CleanupCandidateStats[]
53+
onSuccess?: (result: CleanupResult) => void
54+
}
55+
56+
type Stage = 'pick' | 'confirm'
57+
58+
export function CleanupDialog({
59+
open,
60+
onOpenChange,
61+
ranges,
62+
onSuccess,
63+
}: CleanupDialogProps) {
64+
const [stage, setStage] = useState<Stage>('pick')
65+
const [selectedDays, setSelectedDays] = useState<number | null>(
66+
ranges.length === 1 ? (ranges[0]?.olderThanDays ?? null) : null,
67+
)
68+
const [typed, setTyped] = useState('')
69+
const [errorText, setErrorText] = useState<string | null>(null)
70+
const cleanup = useAuditCleanup()
71+
72+
// Reset internal state whenever the dialog opens so a previous
73+
// typed phrase or error banner never survives across sessions.
74+
useEffect(() => {
75+
if (!open) return
76+
setStage('pick')
77+
setSelectedDays(
78+
ranges.length === 1 ? (ranges[0]?.olderThanDays ?? null) : null,
79+
)
80+
setTyped('')
81+
setErrorText(null)
82+
}, [open, ranges])
83+
84+
const selected = ranges.find((r) => r.olderThanDays === selectedDays) ?? null
85+
const phrase = selected
86+
? buildConfirmationPhrase(selected.sessionCount, selected.olderThanDays)
87+
: ''
88+
const canDelete =
89+
stage === 'confirm' &&
90+
!!selected &&
91+
!cleanup.isPending &&
92+
matchesConfirmationPhrase(typed, phrase)
93+
94+
const handleContinue = () => {
95+
if (!selected) return
96+
setStage('confirm')
97+
}
98+
99+
const handleBack = () => {
100+
setStage('pick')
101+
setTyped('')
102+
setErrorText(null)
103+
}
104+
105+
const handleDelete = () => {
106+
if (!canDelete || !selected) return
107+
setErrorText(null)
108+
cleanup.mutate(
109+
{ olderThanDays: selected.olderThanDays },
110+
{
111+
onSuccess: (res) => {
112+
onSuccess?.(res)
113+
onOpenChange(false)
114+
},
115+
onError: () => {
116+
setErrorText(
117+
'The cleanup failed. Read the confirmation again and try once more.',
118+
)
119+
setTyped('')
120+
},
121+
},
122+
)
123+
}
124+
125+
return (
126+
<AlertDialog open={open} onOpenChange={onOpenChange}>
127+
<AlertDialogContent>
128+
{stage === 'pick' ? (
129+
<>
130+
<AlertDialogHeader>
131+
<AlertDialogTitle>Delete old audit data</AlertDialogTitle>
132+
<AlertDialogDescription>
133+
Deletes sessions, replays, and screenshots older than the
134+
selected age. This cannot be undone.
135+
</AlertDialogDescription>
136+
</AlertDialogHeader>
137+
<RadioGroup
138+
value={selectedDays !== null ? String(selectedDays) : undefined}
139+
onValueChange={(v) => setSelectedDays(Number(v))}
140+
className="gap-2"
141+
>
142+
{ranges.map((r) => (
143+
<label
144+
key={r.olderThanDays}
145+
htmlFor={`cleanup-range-${r.olderThanDays}`}
146+
className="flex cursor-pointer items-start gap-3 rounded-md border border-border p-3 hover:bg-muted"
147+
>
148+
<RadioGroupItem
149+
id={`cleanup-range-${r.olderThanDays}`}
150+
value={String(r.olderThanDays)}
151+
className="mt-0.5"
152+
/>
153+
<span className="flex flex-col text-left">
154+
<span className="font-medium">
155+
Older than {r.olderThanDays} days
156+
</span>
157+
<span className="text-muted-foreground text-xs">
158+
{r.sessionCount}{' '}
159+
{r.sessionCount === 1 ? 'session' : 'sessions'} · up to{' '}
160+
{formatBytes(r.bytesOnDisk)}
161+
</span>
162+
</span>
163+
</label>
164+
))}
165+
</RadioGroup>
166+
<AlertDialogFooter>
167+
<AlertDialogCancel>Cancel</AlertDialogCancel>
168+
<Button
169+
type="button"
170+
onClick={handleContinue}
171+
disabled={selected === null}
172+
>
173+
Continue
174+
</Button>
175+
</AlertDialogFooter>
176+
</>
177+
) : (
178+
<>
179+
<AlertDialogHeader>
180+
<AlertDialogTitle className="flex items-center gap-2">
181+
<AlertTriangle className="size-5 text-destructive" />
182+
Type to confirm
183+
</AlertDialogTitle>
184+
<AlertDialogDescription>
185+
You are about to delete{' '}
186+
<strong>
187+
{selected?.sessionCount}{' '}
188+
{selected?.sessionCount === 1 ? 'session' : 'sessions'} older
189+
than {selected?.olderThanDays} days
190+
</strong>
191+
. This frees up to {formatBytes(selected?.bytesOnDisk ?? 0)} and
192+
cannot be undone.
193+
</AlertDialogDescription>
194+
</AlertDialogHeader>
195+
<div className="flex flex-col gap-2">
196+
<p className="text-muted-foreground text-xs">
197+
Type the phrase below exactly to enable the Delete button:
198+
</p>
199+
<code className="rounded bg-muted px-2 py-1.5 font-mono text-xs">
200+
{phrase}
201+
</code>
202+
<Input
203+
autoFocus
204+
spellCheck={false}
205+
autoComplete="off"
206+
value={typed}
207+
onChange={(e) => setTyped(e.target.value)}
208+
placeholder=""
209+
className="font-mono text-xs"
210+
data-testid="cleanup-confirm-input"
211+
/>
212+
{errorText && (
213+
<p className="text-destructive text-xs" role="alert">
214+
{errorText}
215+
</p>
216+
)}
217+
</div>
218+
<AlertDialogFooter>
219+
<Button
220+
type="button"
221+
variant="outline"
222+
onClick={handleBack}
223+
disabled={cleanup.isPending}
224+
>
225+
Back
226+
</Button>
227+
<AlertDialogAction
228+
variant="destructive"
229+
onClick={handleDelete}
230+
disabled={!canDelete}
231+
data-testid="cleanup-confirm-delete"
232+
>
233+
{cleanup.isPending ? 'Deleting…' : 'Delete'}
234+
</AlertDialogAction>
235+
</AlertDialogFooter>
236+
</>
237+
)}
238+
</AlertDialogContent>
239+
</AlertDialog>
240+
)
241+
}

0 commit comments

Comments
 (0)