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..53f2de161e --- /dev/null +++ b/apps/webapp/app/components/admin/DeleteUserDialog.tsx @@ -0,0 +1,96 @@ +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]); + + // 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"; + + return ( + + + Delete user + + {user && ( + + + Target + + {user.email} + + {user.id} + + + )} + + + + + + + + Type {expected} to confirm + + setConfirmText(e.target.value)} + placeholder={expected} + autoFocus + autoComplete="off" + spellCheck={false} + /> + + + + onOpenChange(false)} + disabled={isSubmitting} + > + Cancel + + + Delete user + + + + + + ); +} diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index aafb818002..78b6279af2 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({ action: z.literal("impersonate"), 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,64 @@ export async function action({ request }: ActionFunctionArgs) { } const payload = Object.fromEntries(await request.formData()); - const { id } = FormSchema.parse(payload); - return redirectWithImpersonation(request, id, "/"); + 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 impersonateAttempt = ImpersonateSchema.safeParse(payload); + if (impersonateAttempt.success) { + return redirectWithImpersonation(request, impersonateAttempt.data.id, "/"); + } + + return typedjson({ error: "Unknown action." }, { status: 400 }); } 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 +173,7 @@ export default function AdminDashboardRoute() { ) : ( users.map((user) => { + const isSelf = user.id === currentUser.id; return ( @@ -136,23 +209,33 @@ export default function AdminDashboardRoute() { {user.admin ? "✅" : ""} - - - - Impersonate - - + + + + + Impersonate + + + {!isSelf && !user.admin && ( + openDeleteDialog({ id: user.id, email: user.email })} + > + Delete + + )} + ); @@ -163,6 +246,12 @@ export default function AdminDashboardRoute() { + + ); } diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index cceb576c9d..d950e82ab7 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -205,15 +205,24 @@ export async function authenticatePersonalAccessToken( return; } - await prisma.personalAccessToken.update({ - where: { - id: personalAccessToken.id, - }, - data: { - lastAccessedAt: new Date(), - }, + // 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, + }); + return; + } + const decryptedToken = decryptPersonalAccessToken(personalAccessToken); if (decryptedToken !== token) { 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", 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..17cee8fafb --- /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[] NOT NULL DEFAULT ARRAY[]::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 ee75ce82b5..e0aab73a72 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? @@ -2676,6 +2676,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[] @default([]) + + /// 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
{expected}