delete-project-button.tsx96 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState } from 'react';
5
6import { StepUpPrompt } from '../../../../../../components/step-up-prompt';
7
8interface Props {
9 projectId: string;
10 projectName: string;
11 apiOrigin: string;
12}
13
14/**
15 * Soft-deletes the project (api soft-delete kicks off a 30-day hard-
16 * delete grace window per services/account-deletion + cron). Gated by
17 * requireRecentMfa(10) on the api side; if the step-up window has
18 * elapsed, we surface a password prompt inline.
19 */
20export function DeleteProjectButton({ projectId, projectName, apiOrigin }: Props) {
21 const router = useRouter();
22 const [confirm, setConfirm] = useState('');
23 const [busy, setBusy] = useState(false);
24 const [error, setError] = useState<string | null>(null);
25 const [needStepUp, setNeedStepUp] = useState(false);
26
27 const matches = confirm === projectName;
28
29 async function performDelete() {
30 setBusy(true);
31 setError(null);
32 try {
33 const res = await fetch(`${apiOrigin}/v1/projects/${projectId}`, {
34 method: 'DELETE',
35 credentials: 'include',
36 });
37 if (res.ok) {
38 router.push('/dashboard/projects');
39 return;
40 }
41 if (res.status === 403) {
42 const body = (await res.json().catch(() => null)) as { code?: string } | null;
43 if (body?.code === 'step_up_required') {
44 setNeedStepUp(true);
45 return;
46 }
47 }
48 const text = await res.text().catch(() => '');
49 throw new Error(text || `delete failed: ${res.status}`);
50 } catch (err) {
51 setError(err instanceof Error ? err.message : 'delete failed');
52 } finally {
53 setBusy(false);
54 }
55 }
56
57 return (
58 <details className="font-mono text-xs">
59 <summary className="cursor-pointer rounded-md border border-red-400 px-3 py-2 text-red-400">
60 delete
61 </summary>
62 <div className="mt-3 flex flex-col gap-2">
63 <p className="text-[var(--color-text-muted)]">
64 type <span className="text-[var(--color-text)]">{projectName}</span> to confirm:
65 </p>
66 <input
67 value={confirm}
68 onChange={(e) => setConfirm(e.currentTarget.value)}
69 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-xs"
70 />
71 <button
72 type="button"
73 disabled={!matches || busy || needStepUp}
74 onClick={performDelete}
75 className="rounded-md bg-red-400 px-3 py-2 font-mono text-xs font-medium text-[var(--color-bg)] disabled:opacity-30"
76 >
77 {busy ? 'deleting...' : 'permanently delete'}
78 </button>
79 {error ? (
80 <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p>
81 ) : null}
82 {needStepUp ? (
83 <StepUpPrompt
84 apiOrigin={apiOrigin}
85 reason={`you're about to delete the project "${projectName}". this triggers the 30-day hard-delete grace window.`}
86 onSuccess={async () => {
87 setNeedStepUp(false);
88 await performDelete();
89 }}
90 onCancel={() => setNeedStepUp(false)}
91 />
92 ) : null}
93 </div>
94 </details>
95 );
96}