@@ -2,11 +2,12 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
22import { Form } from "@remix-run/react" ;
33import type { ActionFunctionArgs , LoaderFunctionArgs } from "@remix-run/server-runtime" ;
44import { 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" ;
67import { z } from "zod" ;
8+ import { DeleteUserDialog } from "~/components/admin/DeleteUserDialog" ;
79import { Button , LinkButton } from "~/components/primitives/Buttons" ;
810import { CopyableText } from "~/components/primitives/CopyableText" ;
9- import { Header1 } from "~/components/primitives/Headers" ;
1011import { Input } from "~/components/primitives/Input" ;
1112import { PaginationControls } from "~/components/primitives/Pagination" ;
1213import { Paragraph } from "~/components/primitives/Paragraph" ;
@@ -21,8 +22,9 @@ import {
2122} from "~/components/primitives/Table" ;
2223import { useUser } from "~/hooks/useUser" ;
2324import { 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" ;
2628import { createSearchParams } from "~/utils/searchParams" ;
2729
2830export 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
4955export 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
6099export 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}
0 commit comments