Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/admin-delete-user.md
Original file line number Diff line number Diff line change
@@ -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.
96 changes: 96 additions & 0 deletions apps/webapp/app/components/admin/DeleteUserDialog.tsx
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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>
);
}
143 changes: 116 additions & 27 deletions apps/webapp/app/routes/admin._index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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({
Expand All @@ -41,25 +43,79 @@ 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") {
return new Response("Method not allowed", { status: 405 });
}

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 }
);
}
Comment thread
isshaddad marked this conversation as resolved.
Comment thread
isshaddad marked this conversation as resolved.

const xff = request.headers.get("x-forwarded-for");
const ipAddress = extractClientIp(xff) ?? undefined;

try {
await deleteUserOnPlatform(targetId, {
adminUserId: admin.id,
adminEmail: admin.email,
ipAddress,
});
Comment thread
isshaddad marked this conversation as resolved.
} 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
Expand All @@ -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>
Expand All @@ -101,6 +173,7 @@ export default function AdminDashboardRoute() {
</TableBlankRow>
) : (
users.map((user) => {
const isSelf = user.id === currentUser.id;
return (
<TableRow key={user.id}>
<TableCell>
Expand Down Expand Up @@ -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>
);
Expand All @@ -163,6 +246,12 @@ export default function AdminDashboardRoute() {

<PaginationControls currentPage={page} totalPages={pageCount} />
</div>

<DeleteUserDialog
user={deleteTarget}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</main>
);
}
23 changes: 16 additions & 7 deletions apps/webapp/app/services/personalAccessToken.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
27 changes: 27 additions & 0 deletions apps/webapp/app/services/platform.v3.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading