Skip to content

Commit e58092f

Browse files
committed
feat(webapp): admin Delete user button calling billing's delete endpoint
1 parent 5ad5e7f commit e58092f

4 files changed

Lines changed: 230 additions & 26 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Admin can delete a user from `/admin`. Hard-deletes the User row, soft-deletes any organization the user is the sole member of. Action proxies to the billing service which runs the deletion in a single transaction.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Form, useNavigation } from "@remix-run/react";
2+
import { useEffect, useState } from "react";
3+
import { Button } from "~/components/primitives/Buttons";
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogDescription,
8+
DialogFooter,
9+
DialogHeader,
10+
} from "~/components/primitives/Dialog";
11+
import { Input } from "~/components/primitives/Input";
12+
import { Label } from "~/components/primitives/Label";
13+
import { Paragraph } from "~/components/primitives/Paragraph";
14+
15+
type DeleteUserDialogProps = {
16+
user: { id: string; email: string } | null;
17+
open: boolean;
18+
onOpenChange: (open: boolean) => void;
19+
};
20+
21+
export function DeleteUserDialog({ user, open, onOpenChange }: DeleteUserDialogProps) {
22+
const navigation = useNavigation();
23+
const [confirmText, setConfirmText] = useState("");
24+
25+
useEffect(() => {
26+
if (!open) setConfirmText("");
27+
}, [open]);
28+
29+
const expected = user ? `delete ${user.email}` : "";
30+
const confirmed = !!user && confirmText === expected;
31+
const isSubmitting = navigation.state !== "idle";
32+
33+
return (
34+
<Dialog open={open} onOpenChange={onOpenChange}>
35+
<DialogContent>
36+
<DialogHeader>Delete user</DialogHeader>
37+
38+
{user && (
39+
<div className="flex flex-col gap-1 rounded-md border border-charcoal-700 bg-charcoal-800 px-3 py-2">
40+
<Paragraph variant="small" className="text-text-dimmed">
41+
Target
42+
</Paragraph>
43+
<Paragraph variant="base">{user.email}</Paragraph>
44+
<Paragraph variant="extra-small" className="text-text-dimmed">
45+
{user.id}
46+
</Paragraph>
47+
</div>
48+
)}
49+
50+
<Form method="post" className="flex flex-col gap-3" reloadDocument>
51+
<input type="hidden" name="intent" value="delete" />
52+
<input type="hidden" name="id" value={user?.id ?? ""} />
53+
54+
<div className="flex flex-col gap-1">
55+
<Label>
56+
Type <code className="rounded bg-charcoal-700 px-1">{expected}</code> to confirm
57+
</Label>
58+
<Input
59+
type="text"
60+
value={confirmText}
61+
onChange={(e) => setConfirmText(e.target.value)}
62+
placeholder={expected}
63+
autoFocus
64+
autoComplete="off"
65+
spellCheck={false}
66+
/>
67+
</div>
68+
69+
<DialogFooter>
70+
<Button
71+
type="button"
72+
variant="tertiary/medium"
73+
onClick={() => onOpenChange(false)}
74+
disabled={isSubmitting}
75+
>
76+
Cancel
77+
</Button>
78+
<Button type="submit" variant="danger/medium" disabled={!confirmed || isSubmitting}>
79+
Delete user
80+
</Button>
81+
</DialogFooter>
82+
</Form>
83+
</DialogContent>
84+
</Dialog>
85+
);
86+
}

apps/webapp/app/routes/admin._index.tsx

Lines changed: 111 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
22
import { Form } from "@remix-run/react";
33
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
44
import { redirect } from "@remix-run/server-runtime";
5-
import { typedjson, useTypedLoaderData } from "remix-typedjson";
5+
import { useState } from "react";
6+
import { typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson";
67
import { z } from "zod";
8+
import { DeleteUserDialog } from "~/components/admin/DeleteUserDialog";
79
import { Button, LinkButton } from "~/components/primitives/Buttons";
810
import { CopyableText } from "~/components/primitives/CopyableText";
9-
import { Header1 } from "~/components/primitives/Headers";
1011
import { Input } from "~/components/primitives/Input";
1112
import { PaginationControls } from "~/components/primitives/Pagination";
1213
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -21,8 +22,9 @@ import {
2122
} from "~/components/primitives/Table";
2223
import { useUser } from "~/hooks/useUser";
2324
import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server";
24-
import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server";
25-
import { requireUserId } from "~/services/session.server";
25+
import { deleteUser as deleteUserOnPlatform } from "~/services/platform.v3.server";
26+
import { requireUser, requireUserId } from "~/services/session.server";
27+
import { extractClientIp } from "~/utils/extractClientIp.server";
2628
import { createSearchParams } from "~/utils/searchParams";
2729

2830
export const SearchParams = z.object({
@@ -41,25 +43,75 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
4143
}
4244
const result = await adminGetUsers(userId, searchParams.params.getAll());
4345

44-
return typedjson(result);
46+
const url = new URL(request.url);
47+
const justDeleted = url.searchParams.get("deleted") === "1";
48+
49+
return typedjson({ ...result, justDeleted });
4550
};
4651

47-
const FormSchema = z.object({ id: z.string() });
52+
const ImpersonateSchema = z.object({ id: z.string() });
53+
const DeleteSchema = z.object({ intent: z.literal("delete"), id: z.string() });
4854

4955
export async function action({ request }: ActionFunctionArgs) {
5056
if (request.method.toLowerCase() !== "post") {
5157
return new Response("Method not allowed", { status: 405 });
5258
}
5359

5460
const payload = Object.fromEntries(await request.formData());
55-
const { id } = FormSchema.parse(payload);
5661

62+
const deleteAttempt = DeleteSchema.safeParse(payload);
63+
if (deleteAttempt.success) {
64+
const admin = await requireUser(request);
65+
if (!admin.admin) {
66+
return redirect("/");
67+
}
68+
69+
const targetId = deleteAttempt.data.id;
70+
71+
if (targetId === admin.id) {
72+
return typedjson(
73+
{ error: "You can't delete your own account from the admin UI." },
74+
{ status: 400 }
75+
);
76+
}
77+
78+
const xff = request.headers.get("x-forwarded-for");
79+
const ipAddress = extractClientIp(xff) ?? undefined;
80+
81+
try {
82+
await deleteUserOnPlatform(targetId, {
83+
adminUserId: admin.id,
84+
adminEmail: admin.email,
85+
ipAddress,
86+
});
87+
} catch (error) {
88+
const message = error instanceof Error ? error.message : "Failed to delete user.";
89+
return typedjson({ error: message }, { status: 500 });
90+
}
91+
92+
return redirect("/admin?deleted=1");
93+
}
94+
95+
const { id } = ImpersonateSchema.parse(payload);
5796
return redirectWithImpersonation(request, id, "/");
5897
}
5998

6099
export default function AdminDashboardRoute() {
61-
const user = useUser();
62-
const { users, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
100+
const currentUser = useUser();
101+
const { users, filters, page, pageCount, justDeleted } = useTypedLoaderData<typeof loader>();
102+
const actionData = useTypedActionData<typeof action>();
103+
const actionError =
104+
actionData && "error" in actionData && typeof actionData.error === "string"
105+
? actionData.error
106+
: null;
107+
108+
const [deleteTarget, setDeleteTarget] = useState<{ id: string; email: string } | null>(null);
109+
const [deleteOpen, setDeleteOpen] = useState(false);
110+
111+
const openDeleteDialog = (user: { id: string; email: string }) => {
112+
setDeleteTarget(user);
113+
setDeleteOpen(true);
114+
};
63115

64116
return (
65117
<main
@@ -82,6 +134,22 @@ export default function AdminDashboardRoute() {
82134
</Button>
83135
</Form>
84136

137+
{justDeleted && (
138+
<div className="rounded-md border border-green-600/40 bg-green-600/10 px-3 py-2">
139+
<Paragraph variant="small" className="text-green-500">
140+
User deleted.
141+
</Paragraph>
142+
</div>
143+
)}
144+
145+
{actionError && (
146+
<div className="rounded-md border border-red-600/40 bg-red-600/10 px-3 py-2">
147+
<Paragraph variant="small" className="text-red-500">
148+
{actionError}
149+
</Paragraph>
150+
</div>
151+
)}
152+
85153
<Table>
86154
<TableHeader>
87155
<TableRow>
@@ -101,6 +169,7 @@ export default function AdminDashboardRoute() {
101169
</TableBlankRow>
102170
) : (
103171
users.map((user) => {
172+
const isSelf = user.id === currentUser.id;
104173
return (
105174
<TableRow key={user.id}>
106175
<TableCell>
@@ -136,23 +205,33 @@ export default function AdminDashboardRoute() {
136205
</TableCell>
137206
<TableCell>{user.admin ? "✅" : ""}</TableCell>
138207
<TableCell isSticky={true}>
139-
<Form method="post" reloadDocument>
140-
<input type="hidden" name="id" value={user.id} />
141-
<Button
142-
type="submit"
143-
name="action"
144-
value="impersonate"
145-
className="mr-2"
146-
variant="tertiary/small"
147-
shortcut={
148-
users.length === 1
149-
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
150-
: undefined
151-
}
152-
>
153-
Impersonate
154-
</Button>
155-
</Form>
208+
<div className="flex items-center gap-2">
209+
<Form method="post" reloadDocument>
210+
<input type="hidden" name="id" value={user.id} />
211+
<Button
212+
type="submit"
213+
name="action"
214+
value="impersonate"
215+
variant="tertiary/small"
216+
shortcut={
217+
users.length === 1
218+
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
219+
: undefined
220+
}
221+
>
222+
Impersonate
223+
</Button>
224+
</Form>
225+
{!isSelf && (
226+
<Button
227+
type="button"
228+
variant="danger/small"
229+
onClick={() => openDeleteDialog({ id: user.id, email: user.email })}
230+
>
231+
Delete
232+
</Button>
233+
)}
234+
</div>
156235
</TableCell>
157236
</TableRow>
158237
);
@@ -163,6 +242,12 @@ export default function AdminDashboardRoute() {
163242

164243
<PaginationControls currentPage={page} totalPages={pageCount} />
165244
</div>
245+
246+
<DeleteUserDialog
247+
user={deleteTarget}
248+
open={deleteOpen}
249+
onOpenChange={setDeleteOpen}
250+
/>
166251
</main>
167252
);
168253
}

apps/webapp/app/services/platform.v3.server.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
type UsageResult,
1818
type UsageSeriesParams,
1919
type CurrentPlan,
20+
type DeleteUserResponse,
2021
} from "@trigger.dev/platform";
2122
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
2223
import { createLRUMemoryStore } from "@internal/cache";
@@ -770,6 +771,32 @@ export async function triggerInitialDeployment(
770771
}
771772
}
772773

774+
export async function deleteUser(
775+
userId: string,
776+
body: {
777+
adminUserId: string;
778+
adminEmail: string;
779+
reason?: string;
780+
ipAddress?: string;
781+
}
782+
): Promise<DeleteUserResponse> {
783+
if (!client) throw new Error("Platform client not configured");
784+
785+
const [error, result] = await tryCatch(client.deleteUser(userId, body));
786+
787+
if (error) {
788+
logger.error("Error deleting user", { userId, error });
789+
throw error;
790+
}
791+
792+
if (!result.success) {
793+
logger.error("Error deleting user - no success", { userId, error: result.error });
794+
throw new Error(result.error ?? "Failed to delete user");
795+
}
796+
797+
return result;
798+
}
799+
773800
function isCloud(): boolean {
774801
const acceptableHosts = [
775802
"https://cloud.trigger.dev",

0 commit comments

Comments
 (0)