Compare commits

...

9 Commits

7 changed files with 324 additions and 36 deletions
+6
View File
@@ -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.
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>Delete user</DialogHeader>
{user && (
<div className="flex flex-col gap-1 rounded-md border border-charcoal-700 bg-charcoal-800 px-3 py-2">
<Paragraph variant="small" className="text-text-dimmed">
Target
</Paragraph>
<Paragraph variant="base">{user.email}</Paragraph>
<Paragraph variant="extra-small" className="text-text-dimmed">
{user.id}
</Paragraph>
</div>
)}
<Form method="post" className="flex flex-col gap-3">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={user?.id ?? ""} />
<div className="flex flex-col gap-1">
<Label>
Type <code className="rounded bg-charcoal-700 px-1">{expected}</code> to confirm
</Label>
<Input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={expected}
autoFocus
autoComplete="off"
spellCheck={false}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="tertiary/medium"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" variant="danger/medium" disabled={!confirmed || isSubmitting}>
Delete user
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
}
+116 -27
View File
@@ -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<typeof loader>();
const currentUser = useUser();
const { users, filters, page, pageCount, justDeleted } = useTypedLoaderData<typeof loader>();
const actionData = useTypedActionData<typeof action>();
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 (
<main
@@ -82,6 +138,22 @@ export default function AdminDashboardRoute() {
</Button>
</Form>
{justDeleted && (
<div className="rounded-md border border-green-600/40 bg-green-600/10 px-3 py-2">
<Paragraph variant="small" className="text-green-500">
User deleted.
</Paragraph>
</div>
)}
{actionError && (
<div className="rounded-md border border-red-600/40 bg-red-600/10 px-3 py-2">
<Paragraph variant="small" className="text-red-500">
{actionError}
</Paragraph>
</div>
)}
<Table>
<TableHeader>
<TableRow>
@@ -101,6 +173,7 @@ export default function AdminDashboardRoute() {
</TableBlankRow>
) : (
users.map((user) => {
const isSelf = user.id === currentUser.id;
return (
<TableRow key={user.id}>
<TableCell>
@@ -136,23 +209,33 @@ export default function AdminDashboardRoute() {
</TableCell>
<TableCell>{user.admin ? "✅" : ""}</TableCell>
<TableCell isSticky={true}>
<Form method="post" reloadDocument>
<input type="hidden" name="id" value={user.id} />
<Button
type="submit"
name="action"
value="impersonate"
className="mr-2"
variant="tertiary/small"
shortcut={
users.length === 1
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
: undefined
}
>
Impersonate
</Button>
</Form>
<div className="flex items-center gap-2">
<Form method="post" reloadDocument>
<input type="hidden" name="id" value={user.id} />
<Button
type="submit"
name="action"
value="impersonate"
variant="tertiary/small"
shortcut={
users.length === 1
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
: undefined
}
>
Impersonate
</Button>
</Form>
{!isSelf && !user.admin && (
<Button
type="button"
variant="danger/small"
onClick={() => openDeleteDialog({ id: user.id, email: user.email })}
>
Delete
</Button>
)}
</div>
</TableCell>
</TableRow>
);
@@ -163,6 +246,12 @@ export default function AdminDashboardRoute() {
<PaginationControls currentPage={page} totalPages={pageCount} />
</div>
<DeleteUserDialog
user={deleteTarget}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</main>
);
}
@@ -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) {
@@ -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<DeleteUserResponse> {
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",
@@ -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;
@@ -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