From 5ad5e7fd32f42eb0cf6920ec83479669c5f99805 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Tue, 28 Apr 2026 07:36:46 -0400 Subject: [PATCH 1/7] feat(database): UserDeletionAuditLog + cascade FKs for admin user delete --- .../migration.sql | 35 +++++++++++++++++++ .../database/prisma/schema.prisma | 30 ++++++++++++++-- 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20260428112409_admin_user_deletion/migration.sql diff --git a/internal-packages/database/prisma/migrations/20260428112409_admin_user_deletion/migration.sql b/internal-packages/database/prisma/migrations/20260428112409_admin_user_deletion/migration.sql new file mode 100644 index 0000000000..d5d827ecb0 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260428112409_admin_user_deletion/migration.sql @@ -0,0 +1,35 @@ +-- DropForeignKey +ALTER TABLE "public"."MfaBackupCode" DROP CONSTRAINT "MfaBackupCode_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."PersonalAccessToken" DROP CONSTRAINT "PersonalAccessToken_userId_fkey"; + +-- CreateTable +CREATE TABLE "public"."UserDeletionAuditLog" ( + "id" TEXT NOT NULL, + "adminUserId" TEXT NOT NULL, + "adminEmail" TEXT NOT NULL, + "targetUserId" TEXT NOT NULL, + "targetEmail" TEXT NOT NULL, + "softDeletedOrgIds" TEXT[], + "reason" TEXT, + "ipAddress" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserDeletionAuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "UserDeletionAuditLog_adminUserId_idx" ON "public"."UserDeletionAuditLog"("adminUserId"); + +-- CreateIndex +CREATE INDEX "UserDeletionAuditLog_targetUserId_idx" ON "public"."UserDeletionAuditLog"("targetUserId"); + +-- CreateIndex +CREATE INDEX "UserDeletionAuditLog_createdAt_idx" ON "public"."UserDeletionAuditLog"("createdAt"); + +-- AddForeignKey +ALTER TABLE "public"."MfaBackupCode" ADD CONSTRAINT "MfaBackupCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 9ccf2495d3..3737f828d1 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -74,7 +74,7 @@ model MfaBackupCode { /// Hash of the actual code code String - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String usedAt DateTime? @@ -131,7 +131,7 @@ model PersonalAccessToken { /// This is used to find the token in the database hashedToken String @unique - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String revokedAt DateTime? @@ -2590,6 +2590,32 @@ model ImpersonationAuditLog { @@index([createdAt]) } +model UserDeletionAuditLog { + id String @id @default(cuid()) + + /// Denormalized — audit row must survive after the target user is deleted. + /// No FKs to User; store IDs and emails as plain text. + adminUserId String + adminEmail String + targetUserId String + targetEmail String + + /// Organization IDs that were soft-deleted as part of this user delete + /// (orgs where the deleted user was the sole member). + softDeletedOrgIds String[] + + /// Optional: ticket link / GDPR request id / free-text SE note. + reason String? + + ipAddress String? + + createdAt DateTime @default(now()) + + @@index([adminUserId]) + @@index([targetUserId]) + @@index([createdAt]) +} + enum CustomerQuerySource { DASHBOARD API From e58092ffcd1724eaa0a501a8f58809fe9dce918c Mon Sep 17 00:00:00 2001 From: isshaddad Date: Tue, 28 Apr 2026 07:49:40 -0400 Subject: [PATCH 2/7] feat(webapp): admin Delete user button calling billing's delete endpoint --- .server-changes/admin-delete-user.md | 6 + .../app/components/admin/DeleteUserDialog.tsx | 86 +++++++++++ apps/webapp/app/routes/admin._index.tsx | 137 ++++++++++++++---- .../webapp/app/services/platform.v3.server.ts | 27 ++++ 4 files changed, 230 insertions(+), 26 deletions(-) create mode 100644 .server-changes/admin-delete-user.md create mode 100644 apps/webapp/app/components/admin/DeleteUserDialog.tsx diff --git a/.server-changes/admin-delete-user.md b/.server-changes/admin-delete-user.md new file mode 100644 index 0000000000..c93b46ccc4 --- /dev/null +++ b/.server-changes/admin-delete-user.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +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. diff --git a/apps/webapp/app/components/admin/DeleteUserDialog.tsx b/apps/webapp/app/components/admin/DeleteUserDialog.tsx new file mode 100644 index 0000000000..ddc69ed889 --- /dev/null +++ b/apps/webapp/app/components/admin/DeleteUserDialog.tsx @@ -0,0 +1,86 @@ +import { Form, useNavigation } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, +} from "~/components/primitives/Dialog"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; + +type DeleteUserDialogProps = { + user: { id: string; email: string } | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function DeleteUserDialog({ user, open, onOpenChange }: DeleteUserDialogProps) { + const navigation = useNavigation(); + const [confirmText, setConfirmText] = useState(""); + + useEffect(() => { + if (!open) setConfirmText(""); + }, [open]); + + const expected = user ? `delete ${user.email}` : ""; + const confirmed = !!user && confirmText === expected; + const isSubmitting = navigation.state !== "idle"; + + return ( + + + Delete user + + {user && ( +
+ + Target + + {user.email} + + {user.id} + +
+ )} + +
+ + + +
+ + setConfirmText(e.target.value)} + placeholder={expected} + autoFocus + autoComplete="off" + spellCheck={false} + /> +
+ + + + + +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index aafb818002..06991927fd 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -2,11 +2,12 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/server-runtime"; -import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { useState } from "react"; +import { typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; +import { DeleteUserDialog } from "~/components/admin/DeleteUserDialog"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { CopyableText } from "~/components/primitives/CopyableText"; -import { Header1 } from "~/components/primitives/Headers"; import { Input } from "~/components/primitives/Input"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -21,8 +22,9 @@ import { } from "~/components/primitives/Table"; import { useUser } from "~/hooks/useUser"; import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server"; -import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server"; -import { requireUserId } from "~/services/session.server"; +import { deleteUser as deleteUserOnPlatform } from "~/services/platform.v3.server"; +import { requireUser, requireUserId } from "~/services/session.server"; +import { extractClientIp } from "~/utils/extractClientIp.server"; import { createSearchParams } from "~/utils/searchParams"; export const SearchParams = z.object({ @@ -41,10 +43,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } const result = await adminGetUsers(userId, searchParams.params.getAll()); - return typedjson(result); + const url = new URL(request.url); + const justDeleted = url.searchParams.get("deleted") === "1"; + + return typedjson({ ...result, justDeleted }); }; -const FormSchema = z.object({ id: z.string() }); +const ImpersonateSchema = z.object({ id: z.string() }); +const DeleteSchema = z.object({ intent: z.literal("delete"), id: z.string() }); export async function action({ request }: ActionFunctionArgs) { if (request.method.toLowerCase() !== "post") { @@ -52,14 +58,60 @@ export async function action({ request }: ActionFunctionArgs) { } const payload = Object.fromEntries(await request.formData()); - const { id } = FormSchema.parse(payload); + const deleteAttempt = DeleteSchema.safeParse(payload); + if (deleteAttempt.success) { + const admin = await requireUser(request); + if (!admin.admin) { + return redirect("/"); + } + + const targetId = deleteAttempt.data.id; + + if (targetId === admin.id) { + return typedjson( + { error: "You can't delete your own account from the admin UI." }, + { status: 400 } + ); + } + + const xff = request.headers.get("x-forwarded-for"); + const ipAddress = extractClientIp(xff) ?? undefined; + + try { + await deleteUserOnPlatform(targetId, { + adminUserId: admin.id, + adminEmail: admin.email, + ipAddress, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete user."; + return typedjson({ error: message }, { status: 500 }); + } + + return redirect("/admin?deleted=1"); + } + + const { id } = ImpersonateSchema.parse(payload); return redirectWithImpersonation(request, id, "/"); } export default function AdminDashboardRoute() { - const user = useUser(); - const { users, filters, page, pageCount } = useTypedLoaderData(); + const currentUser = useUser(); + const { users, filters, page, pageCount, justDeleted } = useTypedLoaderData(); + const actionData = useTypedActionData(); + const actionError = + actionData && "error" in actionData && typeof actionData.error === "string" + ? actionData.error + : null; + + const [deleteTarget, setDeleteTarget] = useState<{ id: string; email: string } | null>(null); + const [deleteOpen, setDeleteOpen] = useState(false); + + const openDeleteDialog = (user: { id: string; email: string }) => { + setDeleteTarget(user); + setDeleteOpen(true); + }; return (
+ {justDeleted && ( +
+ + User deleted. + +
+ )} + + {actionError && ( +
+ + {actionError} + +
+ )} + @@ -101,6 +169,7 @@ export default function AdminDashboardRoute() { ) : ( users.map((user) => { + const isSelf = user.id === currentUser.id; return ( @@ -136,23 +205,33 @@ export default function AdminDashboardRoute() { {user.admin ? "✅" : ""} -
- - - +
+
+ + + + {!isSelf && ( + + )} +
); @@ -163,6 +242,12 @@ export default function AdminDashboardRoute() { + + ); } diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 9f8037a151..e1521e2f9a 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -17,6 +17,7 @@ import { type UsageResult, type UsageSeriesParams, type CurrentPlan, + type DeleteUserResponse, } from "@trigger.dev/platform"; import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache"; import { createLRUMemoryStore } from "@internal/cache"; @@ -770,6 +771,32 @@ export async function triggerInitialDeployment( } } +export async function deleteUser( + userId: string, + body: { + adminUserId: string; + adminEmail: string; + reason?: string; + ipAddress?: string; + } +): Promise { + if (!client) throw new Error("Platform client not configured"); + + const [error, result] = await tryCatch(client.deleteUser(userId, body)); + + if (error) { + logger.error("Error deleting user", { userId, error }); + throw error; + } + + if (!result.success) { + logger.error("Error deleting user - no success", { userId, error: result.error }); + throw new Error(result.error ?? "Failed to delete user"); + } + + return result; +} + function isCloud(): boolean { const acceptableHosts = [ "https://cloud.trigger.dev", From 97ad9a51b56331d8409f2e7c408319a17f94b8aa Mon Sep 17 00:00:00 2001 From: isshaddad Date: Tue, 28 Apr 2026 12:29:29 -0400 Subject: [PATCH 3/7] fix: address coderabbit review on admin user deletion --- .../app/components/admin/DeleteUserDialog.tsx | 12 ++++++++- apps/webapp/app/routes/admin._index.tsx | 6 +++++ .../services/personalAccessToken.server.ts | 26 +++++++++++++------ .../migration.sql | 2 +- .../database/prisma/schema.prisma | 2 +- 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/components/admin/DeleteUserDialog.tsx b/apps/webapp/app/components/admin/DeleteUserDialog.tsx index ddc69ed889..53f2de161e 100644 --- a/apps/webapp/app/components/admin/DeleteUserDialog.tsx +++ b/apps/webapp/app/components/admin/DeleteUserDialog.tsx @@ -26,6 +26,16 @@ export function DeleteUserDialog({ user, open, onOpenChange }: DeleteUserDialogP if (!open) setConfirmText(""); }, [open]); + // Close the modal as soon as our own submit starts. Without reloadDocument + // the component stays mounted across the post-redirect soft navigation, so + // `open` would otherwise stay true after the action redirects to /admin. + const isSubmittingDelete = + navigation.state === "submitting" && + navigation.formData?.get("intent") === "delete"; + useEffect(() => { + if (isSubmittingDelete) onOpenChange(false); + }, [isSubmittingDelete, onOpenChange]); + const expected = user ? `delete ${user.email}` : ""; const confirmed = !!user && confirmText === expected; const isSubmitting = navigation.state !== "idle"; @@ -47,7 +57,7 @@ export function DeleteUserDialog({ user, open, onOpenChange }: DeleteUserDialogP )} -
+ diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index 06991927fd..76308946ce 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -92,6 +92,12 @@ export async function action({ request }: ActionFunctionArgs) { return redirect("/admin?deleted=1"); } + // Reject any POST that set `intent` to something we don't recognise so + // unknown intents don't fall through to the impersonate flow. + if (typeof payload.intent === "string") { + return typedjson({ error: "Unknown action." }, { status: 400 }); + } + const { id } = ImpersonateSchema.parse(payload); return redirectWithImpersonation(request, id, "/"); } diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index cceb576c9d..6c4eabe4f0 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -205,14 +205,24 @@ export async function authenticatePersonalAccessToken( return; } - await prisma.personalAccessToken.update({ - where: { - id: personalAccessToken.id, - }, - data: { - lastAccessedAt: new Date(), - }, - }); + // Best-effort touch. The token can vanish between the findFirst above and + // this update if a User cascade-delete happens concurrently (admin delete + // flow), so swallow not-found errors rather than 500-ing the auth path. + try { + await prisma.personalAccessToken.update({ + where: { + id: personalAccessToken.id, + }, + data: { + lastAccessedAt: new Date(), + }, + }); + } catch (error) { + logger.warn("Failed to touch PersonalAccessToken.lastAccessedAt", { + personalAccessTokenId: personalAccessToken.id, + error, + }); + } const decryptedToken = decryptPersonalAccessToken(personalAccessToken); diff --git a/internal-packages/database/prisma/migrations/20260428112409_admin_user_deletion/migration.sql b/internal-packages/database/prisma/migrations/20260428112409_admin_user_deletion/migration.sql index d5d827ecb0..17cee8fafb 100644 --- a/internal-packages/database/prisma/migrations/20260428112409_admin_user_deletion/migration.sql +++ b/internal-packages/database/prisma/migrations/20260428112409_admin_user_deletion/migration.sql @@ -11,7 +11,7 @@ CREATE TABLE "public"."UserDeletionAuditLog" ( "adminEmail" TEXT NOT NULL, "targetUserId" TEXT NOT NULL, "targetEmail" TEXT NOT NULL, - "softDeletedOrgIds" TEXT[], + "softDeletedOrgIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], "reason" TEXT, "ipAddress" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 3737f828d1..81f3539670 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2602,7 +2602,7 @@ model UserDeletionAuditLog { /// Organization IDs that were soft-deleted as part of this user delete /// (orgs where the deleted user was the sole member). - softDeletedOrgIds String[] + softDeletedOrgIds String[] @default([]) /// Optional: ticket link / GDPR request id / free-text SE note. reason String? From 1235a6f7f84c76a9811776edc0e2a7d7f3432bcb Mon Sep 17 00:00:00 2001 From: isshaddad Date: Tue, 28 Apr 2026 12:46:21 -0400 Subject: [PATCH 4/7] fix(webapp): tighten /admin POST validation to stop impersonate fall-through --- apps/webapp/app/routes/admin._index.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index 76308946ce..fb4c5e2f26 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -49,7 +49,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson({ ...result, justDeleted }); }; -const ImpersonateSchema = z.object({ id: z.string() }); +const ImpersonateSchema = z.object({ action: z.literal("impersonate"), id: z.string() }); const DeleteSchema = z.object({ intent: z.literal("delete"), id: z.string() }); export async function action({ request }: ActionFunctionArgs) { @@ -92,14 +92,12 @@ export async function action({ request }: ActionFunctionArgs) { return redirect("/admin?deleted=1"); } - // Reject any POST that set `intent` to something we don't recognise so - // unknown intents don't fall through to the impersonate flow. - if (typeof payload.intent === "string") { - return typedjson({ error: "Unknown action." }, { status: 400 }); + const impersonateAttempt = ImpersonateSchema.safeParse(payload); + if (impersonateAttempt.success) { + return redirectWithImpersonation(request, impersonateAttempt.data.id, "/"); } - const { id } = ImpersonateSchema.parse(payload); - return redirectWithImpersonation(request, id, "/"); + return typedjson({ error: "Unknown action." }, { status: 400 }); } export default function AdminDashboardRoute() { From 9130c0da36b2ed524366cd19e1249378f01cf220 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Tue, 28 Apr 2026 13:12:10 -0400 Subject: [PATCH 5/7] fix(webapp): treat cascade-deleted PAT as auth miss, not stale success --- .../services/personalAccessToken.server.ts | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index 6c4eabe4f0..d950e82ab7 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -205,23 +205,22 @@ export async function authenticatePersonalAccessToken( return; } - // Best-effort touch. The token can vanish between the findFirst above and - // this update if a User cascade-delete happens concurrently (admin delete - // flow), so swallow not-found errors rather than 500-ing the auth path. - try { - await prisma.personalAccessToken.update({ - where: { - id: personalAccessToken.id, - }, - data: { - lastAccessedAt: new Date(), - }, - }); - } catch (error) { - logger.warn("Failed to touch PersonalAccessToken.lastAccessedAt", { + // Touch lastAccessedAt with updateMany rather than update so a missing + // row (e.g. the PAT was cascade-deleted by a concurrent User delete + // between the findFirst above and this call) yields count = 0 instead + // of throwing. count = 0 means the token no longer exists — treat that + // as an authentication miss rather than handing a userId for a deleted + // user back to callers that don't re-verify the user. + const touchResult = await prisma.personalAccessToken.updateMany({ + where: { id: personalAccessToken.id }, + data: { lastAccessedAt: new Date() }, + }); + + if (touchResult.count === 0) { + logger.warn("PersonalAccessToken vanished between findFirst and update", { personalAccessTokenId: personalAccessToken.id, - error, }); + return; } const decryptedToken = decryptPersonalAccessToken(personalAccessToken); From 5c98cbfba539ba4515277a99a6984aa4ea8be255 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Tue, 28 Apr 2026 18:44:21 -0400 Subject: [PATCH 6/7] fix(webapp): block user deletion while impersonating --- apps/webapp/app/routes/admin._index.tsx | 41 ++++++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index fb4c5e2f26..64253a3ec4 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -20,6 +20,8 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { useIsImpersonating } from "~/hooks/useOrganizations"; import { useUser } from "~/hooks/useUser"; import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server"; import { deleteUser as deleteUserOnPlatform } from "~/services/platform.v3.server"; @@ -66,6 +68,13 @@ export async function action({ request }: ActionFunctionArgs) { return redirect("/"); } + if (admin.isImpersonating) { + return typedjson( + { error: "Stop impersonating before deleting users." }, + { status: 400 } + ); + } + const targetId = deleteAttempt.data.id; if (targetId === admin.id) { @@ -102,6 +111,7 @@ export async function action({ request }: ActionFunctionArgs) { export default function AdminDashboardRoute() { const currentUser = useUser(); + const isImpersonating = useIsImpersonating(); const { users, filters, page, pageCount, justDeleted } = useTypedLoaderData(); const actionData = useTypedActionData(); const actionError = @@ -226,14 +236,29 @@ export default function AdminDashboardRoute() { Impersonate - {!isSelf && ( - + {!isSelf && !user.admin && ( + isImpersonating ? ( + + + + } + content="Stop impersonating to delete users" + /> + ) : ( + + ) )} From 8a11f9e38525a9e00e86b11db466f54abb332d62 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Tue, 28 Apr 2026 18:57:54 -0400 Subject: [PATCH 7/7] revert(webapp): drop impersonation guard from /admin Delete --- apps/webapp/app/routes/admin._index.tsx | 39 +++++-------------------- 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index 64253a3ec4..78b6279af2 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -20,8 +20,6 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; -import { SimpleTooltip } from "~/components/primitives/Tooltip"; -import { useIsImpersonating } from "~/hooks/useOrganizations"; import { useUser } from "~/hooks/useUser"; import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server"; import { deleteUser as deleteUserOnPlatform } from "~/services/platform.v3.server"; @@ -68,13 +66,6 @@ export async function action({ request }: ActionFunctionArgs) { return redirect("/"); } - if (admin.isImpersonating) { - return typedjson( - { error: "Stop impersonating before deleting users." }, - { status: 400 } - ); - } - const targetId = deleteAttempt.data.id; if (targetId === admin.id) { @@ -111,7 +102,6 @@ export async function action({ request }: ActionFunctionArgs) { export default function AdminDashboardRoute() { const currentUser = useUser(); - const isImpersonating = useIsImpersonating(); const { users, filters, page, pageCount, justDeleted } = useTypedLoaderData(); const actionData = useTypedActionData(); const actionError = @@ -237,28 +227,13 @@ export default function AdminDashboardRoute() { {!isSelf && !user.admin && ( - isImpersonating ? ( - - - - } - content="Stop impersonating to delete users" - /> - ) : ( - - ) + )}