From 3f1a13d261637fb1b84108d262cec724298d86b2 Mon Sep 17 00:00:00 2001
From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com>
Date: Fri, 10 Jul 2026 08:31:43 +1000
Subject: [PATCH 1/2] Require 2FA for project and server deletion
---
web/actions/projects.ts | 47 +---
web/actions/servers.ts | 11 +-
.../[serviceId]/configuration/page.tsx | 243 +++++-------------
.../core/delete-confirmation-dialog.tsx | 162 ++++++++++++
.../project/project-settings-panel.tsx | 69 ++---
web/components/server/server-danger-zone.tsx | 65 +----
web/lib/auth.ts | 33 +++
web/lib/two-factor.ts | 21 ++
web/tests/two-factor.test.ts | 22 ++
9 files changed, 345 insertions(+), 328 deletions(-)
create mode 100644 web/components/core/delete-confirmation-dialog.tsx
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index a384a25f..3d8385aa 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -1,11 +1,9 @@
"use server";
import { randomUUID } from "node:crypto";
-import { isAPIError } from "better-auth/api";
import cronstrue from "cronstrue";
import { and, desc, eq, inArray, isNotNull, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
-import { headers } from "next/headers";
import { ZodError, z } from "zod";
import { db } from "@/db";
import {
@@ -30,7 +28,7 @@ import {
volumeBackups,
workQueue,
} from "@/db/schema";
-import { auth, requireDeveloperRole } from "@/lib/auth";
+import { requireDeveloperRole, verifyDeleteConfirmation } from "@/lib/auth";
import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants";
import { deployServiceInternal } from "@/lib/deploy-service";
import {
@@ -54,14 +52,11 @@ import type {
HealthCheckConfig as ServiceHealthCheckConfig,
} from "@/lib/service-config";
import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config";
+import type { DeleteConfirmation } from "@/lib/two-factor";
import { getZodErrorMessage, slugify } from "@/lib/utils";
import { enqueueWork } from "@/lib/work-queue";
import { deleteBackup } from "./backups";
-type ServiceDeleteConfirmation = {
- totpCode?: string;
-};
-
function isValidImageReferencePart(reference: string): boolean {
const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
const digestPattern = /^[A-Za-z0-9_+.-]+:[0-9a-fA-F]{32,256}$/;
@@ -268,8 +263,12 @@ export async function createProject(name: string) {
}
}
-export async function deleteProject(id: string) {
- await requireDeveloperRole();
+export async function deleteProject(
+ id: string,
+ confirmation?: DeleteConfirmation,
+) {
+ const session = await requireDeveloperRole();
+ await verifyDeleteConfirmation(session, confirmation, "project");
const projectServices = await db
.select()
.from(services)
@@ -582,36 +581,10 @@ async function hardDeleteService(serviceId: string) {
export async function deleteService(
serviceId: string,
- confirmation?: ServiceDeleteConfirmation,
+ confirmation?: DeleteConfirmation,
) {
const session = await requireDeveloperRole();
- if (!session) {
- throw new Error("Unauthorized");
- }
-
- const twoFactorEnabled = Boolean(
- (session.user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled,
- );
-
- if (twoFactorEnabled) {
- const totpCode = confirmation?.totpCode ?? "";
-
- if (!/^\d{6}$/.test(totpCode)) {
- throw new Error("Authenticator code is required to delete this service");
- }
-
- try {
- await auth.api.verifyTOTP({
- body: { code: totpCode },
- headers: await headers(),
- });
- } catch (error) {
- if (isAPIError(error)) {
- throw new Error("Invalid authenticator code");
- }
- throw error;
- }
- }
+ await verifyDeleteConfirmation(session, confirmation, "service");
const service = await getService(serviceId);
if (!service) {
diff --git a/web/actions/servers.ts b/web/actions/servers.ts
index 1868e47e..f0014f54 100644
--- a/web/actions/servers.ts
+++ b/web/actions/servers.ts
@@ -7,8 +7,9 @@ import { ZodError } from "zod";
import { db } from "@/db";
import { servers } from "@/db/schema";
import { enqueueAgentUpgrade } from "@/lib/agent-upgrades";
-import { requireDeveloperRole } from "@/lib/auth";
+import { requireDeveloperRole, verifyDeleteConfirmation } from "@/lib/auth";
import { nameSchema } from "@/lib/schemas";
+import type { DeleteConfirmation } from "@/lib/two-factor";
import { getZodErrorMessage } from "@/lib/utils";
function generateId(): string {
@@ -48,8 +49,12 @@ export async function createServer(name: string) {
}
}
-export async function deleteServer(id: string) {
- await requireDeveloperRole();
+export async function deleteServer(
+ id: string,
+ confirmation?: DeleteConfirmation,
+) {
+ const session = await requireDeveloperRole();
+ await verifyDeleteConfirmation(session, confirmation, "server");
await db.delete(servers).where(eq(servers.id, id));
}
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
index 38b937ec..35d98e0e 100644
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
@@ -1,12 +1,12 @@
"use client";
-import { REGEXP_ONLY_DIGITS } from "input-otp";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
-import { useCallback, useState } from "react";
+import { useCallback } from "react";
import { toast } from "sonner";
import { useSWRConfig } from "swr";
import { deleteService } from "@/actions/projects";
+import { DeleteConfirmationDialog } from "@/components/core/delete-confirmation-dialog";
import { LocalDate } from "@/components/core/local-date";
import { HealthCheckSection } from "@/components/service/details/health-check-section";
import { PortsSection } from "@/components/service/details/ports-section";
@@ -20,46 +20,15 @@ import { StartCommandSection } from "@/components/service/details/start-command-
import { TCPProxySection } from "@/components/service/details/tcp-proxy-section";
import { VolumesSection } from "@/components/service/details/volumes-section";
import { useService } from "@/components/service/service-layout-client";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
-import { Button } from "@/components/ui/button";
-import {
- InputOTP,
- InputOTPGroup,
- InputOTPSlot,
-} from "@/components/ui/input-otp";
import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item";
-import { Label } from "@/components/ui/label";
-import { Spinner } from "@/components/ui/spinner";
-import { useSession } from "@/lib/auth-client";
+import type { DeleteConfirmation } from "@/lib/two-factor";
const ACTIVE_DELETE_BACKUP_STATUSES = ["running", "healthy"] as const;
-type TwoFactorSessionUser = {
- twoFactorEnabled?: boolean | null;
-};
-
export default function ConfigurationPage() {
const router = useRouter();
const { mutate: globalMutate } = useSWRConfig();
- const { data: session, isPending: isSessionLoading } = useSession();
- const sessionUser = session?.user as TwoFactorSessionUser | undefined;
const { service, projectSlug, envName, proxyDomain, onUpdate } = useService();
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [deleteTotpCode, setDeleteTotpCode] = useState("");
- const [isDeleting, setIsDeleting] = useState(false);
- const requiresDeleteConfirmation = Boolean(sessionUser?.twoFactorEnabled);
- const isDeleteConfirmationIncomplete =
- requiresDeleteConfirmation && deleteTotpCode.length !== 6;
const hasActiveDeploymentForBackup = service.deployments.some(
(deployment) =>
ACTIVE_DELETE_BACKUP_STATUSES.includes(
@@ -79,41 +48,13 @@ export default function ConfigurationPage() {
toast.info("Changes saved. Deploy to apply them.");
}, [onUpdate]);
- const resetDeleteConfirmation = useCallback(() => {
- setDeleteTotpCode("");
- }, []);
-
- const handleDelete = async () => {
- if (isDeleteConfirmationIncomplete) {
- toast.error("Enter your 6-digit authenticator code");
- return;
- }
-
- setIsDeleting(true);
- try {
- await deleteService(
- service.id,
- requiresDeleteConfirmation
- ? {
- totpCode: deleteTotpCode,
- }
- : undefined,
- );
- await globalMutate(`/api/projects/${service.projectId}/services`);
- toast.success(
- service.stateful ? "Delete workflow started" : "Service deleted",
- );
- setDeleteDialogOpen(false);
- resetDeleteConfirmation();
- router.push(`/dashboard/projects/${projectSlug}/${envName}`);
- } catch (error) {
- toast.error(
- error instanceof Error ? error.message : "Failed to delete service",
- );
- resetDeleteConfirmation();
- } finally {
- setIsDeleting(false);
- }
+ const handleDelete = async (confirmation?: DeleteConfirmation) => {
+ await deleteService(service.id, confirmation);
+ await globalMutate(`/api/projects/${service.projectId}/services`);
+ toast.success(
+ service.stateful ? "Delete workflow started" : "Service deleted",
+ );
+ router.push(`/dashboard/projects/${projectSlug}/${envName}`);
};
return (
@@ -159,121 +100,55 @@ export default function ConfigurationPage() {
: "Once deleted, this service and all its deployments will be permanently removed."}
- {
- if (isDeleting) return;
- setDeleteDialogOpen(open);
- if (!open) resetDeleteConfirmation();
- }}
- >
- }>
- Delete Service
-
-
-
- Delete {service.name}?
-
- {service.stateful ? (
- <>
- This starts a backup-first delete workflow. The service
- will be restorable from Deleted services until its
- retention window expires.
- {willReuseCompletedBackups &&
- hasCompletedBackupForEveryVolume && (
- <>
-
-
-
- This service is not currently running.
- {" "}
- Restore will use the latest completed backups for
- its volumes. The oldest selected backup is from{" "}
-
- ; changes after that backup will not be restored.
- >
- )}
- {willReuseCompletedBackups &&
- !hasCompletedBackupForEveryVolume && (
- <>
-
-
-
- No completed backup is available for every
- volume.
- {" "}
- Delete will fail unless the service is running so
- a fresh deletion backup can be created.
- >
- )}
- >
- ) : (
- "This action cannot be undone. This will permanently delete the service and all its deployments."
- )}
- {requiresDeleteConfirmation && (
- <>
-
-
- Enter your authenticator code to confirm this deletion.
- >
- )}
-
-
- {requiresDeleteConfirmation && (
-