Skip to content
Merged
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
4 changes: 2 additions & 2 deletions apps/web/app/(app)/admin/AdminUpgradeUserForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useCallback } from "react";
import { type SubmitHandler, useForm } from "react-hook-form";
import { Button } from "@/components/Button";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/Input";
import { changePremiumStatusAction } from "@/utils/actions/premium";
import { Select } from "@/components/Select";
Expand Down Expand Up @@ -129,8 +129,8 @@ export const AdminUpgradeUserForm = () => {
</Button>
<Button
type="button"
variant="destructive"
loading={isSubmitting}
color="red"
onClick={() => {
onSubmit({
email: getValues("email"),
Expand Down
20 changes: 18 additions & 2 deletions apps/web/app/(app)/admin/AdminUserControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import {
} from "@/app/(app)/admin/validation";
import { zodResolver } from "@hookform/resolvers/zod";
import { handleActionResult } from "@/utils/server-action";
import { adminProcessHistoryAction } from "@/utils/actions/admin";
import {
adminDeleteAccountAction,
adminProcessHistoryAction,
} from "@/utils/actions/admin";
import { adminCheckPermissionsAction } from "@/utils/actions/permissions";

export const AdminUserControls = () => {
const [isProcessing, setIsProcessing] = useState(false);
const [isCheckingPermissions, setIsCheckingPermissions] = useState(false);

const [isDeleting, setIsDeleting] = useState(false);
const {
register,
formState: { errors },
Expand Down Expand Up @@ -70,6 +73,19 @@ export const AdminUserControls = () => {
>
Check Permissions
</Button>
<Button
variant="destructive"
loading={isDeleting}
onClick={async () => {
setIsDeleting(true);
const email = getValues("email");
const result = await adminDeleteAccountAction(email);
handleActionResult(result, "Deleted user");
setIsDeleting(false);
}}
>
Delete User
</Button>
</div>
</form>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(app)/settings/DeleteSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function DeleteSection() {
<Button
color="red"
onClick={async () => {
onCancelLoadBatch();
const yes = window.confirm(
"Are you sure you want to delete your account?",
);
Expand All @@ -50,7 +51,6 @@ export function DeleteSection() {

toast.promise(
async () => {
onCancelLoadBatch();
const result = await deleteAccountAction();
await logOut("/");
if (isActionError(result)) throw new Error(result.error);
Expand Down
25 changes: 25 additions & 0 deletions apps/web/utils/actions/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { processHistoryForUser } from "@/app/api/google/webhook/process-history"
import { isAdmin } from "@/utils/admin";
import { withActionInstrumentation } from "@/utils/actions/middleware";
import { createScopedLogger } from "@/utils/logger";
import { deleteUser } from "@/utils/user/delete";
import prisma from "@/utils/prisma";

const logger = createScopedLogger("Admin Action");

Expand Down Expand Up @@ -37,3 +39,26 @@ export const adminProcessHistoryAction = withActionInstrumentation(
);
},
);

export const adminDeleteAccountAction = withActionInstrumentation(
"adminDeleteAccount",
async (email: string) => {
const session = await auth();
if (!session?.user) return { error: "Not logged in" };
if (!isAdmin(session.user.email)) return { error: "Not admin" };

try {
const userToDelete = await prisma.user.findUnique({ where: { email } });
if (!userToDelete) return { error: "User not found" };

await deleteUser({ userId: userToDelete.id, email });
} catch (error) {
logger.error("Failed to delete user", { email, error });
return {
error: `Failed to delete user: ${error instanceof Error ? error.message : String(error)}`,
};
}

return { success: "User deleted" };
},
);
35 changes: 5 additions & 30 deletions apps/web/utils/actions/user.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
"use server";

import { z } from "zod";
import { deleteContact as deleteLoopsContact } from "@inboxzero/loops";
import { deleteContact as deleteResendContact } from "@inboxzero/resend";
import { auth, signOut } from "@/app/api/auth/[...nextauth]/auth";
import prisma from "@/utils/prisma";
import { deleteInboxZeroLabels, deleteUserLabels } from "@/utils/redis/label";
import { deleteUserStats } from "@/utils/redis/stats";
import { deleteTinybirdEmails } from "@inboxzero/tinybird";
import { deleteTinybirdAiCalls } from "@inboxzero/tinybird-ai-analytics";
import { deletePosthogUser } from "@/utils/posthog";
import { captureException } from "@/utils/error";
import { withActionInstrumentation } from "@/utils/actions/middleware";
import { unwatchEmails } from "@/app/api/google/watch/controller";
import { deleteUser } from "@/utils/user/delete";

const saveAboutBody = z.object({ about: z.string().max(2_000) });
export type SaveAboutBody = z.infer<typeof saveAboutBody>;
Expand Down Expand Up @@ -49,32 +42,14 @@ export const deleteAccountAction = withActionInstrumentation(
const session = await auth();
if (!session?.user.email) return { error: "Not logged in" };

try {
await Promise.allSettled([
deleteUserLabels({ email: session.user.email }),
deleteInboxZeroLabels({ email: session.user.email }),
deleteUserStats({ email: session.user.email }),
deleteTinybirdEmails({ email: session.user.email }),
deleteTinybirdAiCalls({ userId: session.user.email }),
deletePosthogUser({ email: session.user.email }),
deleteLoopsContact(session.user.email),
deleteResendContact({ email: session.user.email }),
unwatchEmails({
userId: session.user.id,
access_token: session.accessToken ?? null,
refresh_token: null,
}),
]);
} catch (error) {
console.error("Error while deleting account:", error);
captureException(error, undefined, session.user.email);
}

try {
await signOut();
} catch (error) {}

await prisma.user.delete({ where: { email: session.user.email } });
await deleteUser({
userId: session.user.id,
email: session.user.email,
});
},
);

Expand Down
47 changes: 47 additions & 0 deletions apps/web/utils/user/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { deleteContact as deleteLoopsContact } from "@inboxzero/loops";
import { deleteContact as deleteResendContact } from "@inboxzero/resend";
import prisma from "@/utils/prisma";
import { deleteInboxZeroLabels, deleteUserLabels } from "@/utils/redis/label";
import { deleteUserStats } from "@/utils/redis/stats";
import { deleteTinybirdEmails } from "@inboxzero/tinybird";
import { deleteTinybirdAiCalls } from "@inboxzero/tinybird-ai-analytics";
import { deletePosthogUser } from "@/utils/posthog";
import { captureException } from "@/utils/error";
import { unwatchEmails } from "@/app/api/google/watch/controller";

export async function deleteUser({
userId,
email,
}: {
userId: string;
email: string;
}) {
const account = await prisma.account.findFirst({
where: { userId, provider: "google" },
select: { access_token: true },
});
if (!account) return;

try {
await Promise.allSettled([
deleteUserLabels({ email }),
deleteInboxZeroLabels({ email }),
deleteUserStats({ email }),
deleteTinybirdEmails({ email }),
deleteTinybirdAiCalls({ userId }),
deletePosthogUser({ email }),
deleteLoopsContact(email),
deleteResendContact({ email }),
unwatchEmails({
userId: userId,
access_token: account.access_token ?? null,
refresh_token: null,
}),
]);
} catch (error) {
console.error("Error while deleting account:", error);
captureException(error, undefined, email);
}

await prisma.user.delete({ where: { email } });
}