user-actions.tsx145 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState, useTransition } from 'react';
5
6import { StepUpPrompt } from '@/components/step-up-prompt';
7
8interface AdminUser {
9 id: string;
10 isAdmin: boolean;
11 suspendedAt: string | null;
12}
13
14interface Props {
15 user: AdminUser;
16 apiOrigin: string;
17}
18
19export function UserActions({ user, apiOrigin }: Props) {
20 const router = useRouter();
21 const [busy, setBusy] = useState(false);
22 const [pendingAction, setPendingAction] = useState<string | null>(null);
23 const [error, setError] = useState<string | null>(null);
24 const [, startTransition] = useTransition();
25
26 async function run(action: string) {
27 setBusy(true);
28 setError(null);
29 try {
30 const res = await fetch(`${apiOrigin}/v1/admin/users/${action}`, {
31 method: 'POST',
32 credentials: 'include',
33 headers: { 'content-type': 'application/json' },
34 body: JSON.stringify({ userId: user.id }),
35 });
36 if (res.status === 403) {
37 const body = (await res.json().catch(() => null)) as { code?: string } | null;
38 if (body?.code === 'step_up_required') {
39 setPendingAction(action);
40 return;
41 }
42 }
43 if (!res.ok) {
44 const body = await res.text().catch(() => '');
45 throw new Error(body || `${action} failed: ${res.status}`);
46 }
47 startTransition(() => router.refresh());
48 } catch (err) {
49 setError(err instanceof Error ? err.message : `${action} failed`);
50 } finally {
51 setBusy(false);
52 }
53 }
54
55 function trigger(action: string) {
56 if (!confirm(`confirm ${action} for ${user.id}?`)) return;
57 void run(action);
58 }
59
60 return (
61 <div className="flex flex-col items-end gap-1 font-mono text-xs">
62 <div className="flex gap-2">
63 {user.suspendedAt ? (
64 <>
65 <button
66 type="button"
67 disabled={busy}
68 onClick={() => trigger('unsuspend')}
69 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[var(--color-primary)] disabled:opacity-50"
70 >
71 unsuspend
72 </button>
73 <button
74 type="button"
75 disabled={busy}
76 onClick={() => {
77 if (
78 !confirm(
79 `delete ${user.id}? the account is soft-deleted (30-day reversible grace, then purged). only for a suspended account you want gone.`,
80 )
81 )
82 return;
83 void run('delete');
84 }}
85 className="rounded-md border border-red-500/40 px-2 py-1 text-red-400 disabled:opacity-50"
86 >
87 delete
88 </button>
89 </>
90 ) : (
91 <button
92 type="button"
93 disabled={busy}
94 onClick={() => trigger('suspend')}
95 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-red-400 disabled:opacity-50"
96 >
97 suspend
98 </button>
99 )}
100 <button
101 type="button"
102 disabled={busy}
103 onClick={() => trigger('force-sign-out')}
104 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[var(--color-text-muted)] disabled:opacity-50"
105 >
106 force sign-out
107 </button>
108 {user.isAdmin ? (
109 <button
110 type="button"
111 disabled={busy}
112 onClick={() => trigger('revoke-admin')}
113 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[var(--color-text-muted)] disabled:opacity-50"
114 >
115 revoke admin
116 </button>
117 ) : (
118 <button
119 type="button"
120 disabled={busy}
121 onClick={() => trigger('grant-admin')}
122 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[var(--color-primary)] disabled:opacity-50"
123 >
124 grant admin
125 </button>
126 )}
127 </div>
128 {error ? (
129 <span className="text-[10px] text-[var(--color-error)]">{error}</span>
130 ) : null}
131 {pendingAction ? (
132 <StepUpPrompt
133 apiOrigin={apiOrigin}
134 reason={`${pendingAction.replace(/-/g, ' ')} a user requires fresh step-up auth.`}
135 onSuccess={async () => {
136 const action = pendingAction;
137 setPendingAction(null);
138 await run(action);
139 }}
140 onCancel={() => setPendingAction(null)}
141 />
142 ) : null}
143 </div>
144 );
145}