diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index 828a2575..a384a25f 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -1,9 +1,11 @@
"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 {
@@ -28,7 +30,7 @@ import {
volumeBackups,
workQueue,
} from "@/db/schema";
-import { requireDeveloperRole } from "@/lib/auth";
+import { auth, requireDeveloperRole } from "@/lib/auth";
import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants";
import { deployServiceInternal } from "@/lib/deploy-service";
import {
@@ -47,15 +49,19 @@ import {
nameSchema,
volumeNameSchema,
} from "@/lib/schemas";
-import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config";
import type {
PortConfig,
HealthCheckConfig as ServiceHealthCheckConfig,
} from "@/lib/service-config";
+import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config";
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}$/;
@@ -574,8 +580,39 @@ async function hardDeleteService(serviceId: string) {
return { success: true };
}
-export async function deleteService(serviceId: string) {
- await requireDeveloperRole();
+export async function deleteService(
+ serviceId: string,
+ confirmation?: ServiceDeleteConfirmation,
+) {
+ 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;
+ }
+ }
+
const service = await getService(serviceId);
if (!service) {
throw new Error("Service not found");
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 77700a12..138c0c28 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,5 +1,6 @@
"use client";
+import { REGEXP_ONLY_DIGITS } from "input-otp";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
@@ -30,10 +31,22 @@ import {
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";
const ACTIVE_DELETE_BACKUP_STATUSES = ["running", "healthy"] as const;
+type TwoFactorSessionUser = {
+ twoFactorEnabled?: boolean | null;
+};
+
function formatBackupDate(value: Date | string | null | undefined) {
if (!value) return "an unknown time";
return new Date(value).toLocaleString();
@@ -42,8 +55,15 @@ function formatBackupDate(value: Date | string | null | undefined) {
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(
@@ -63,15 +83,38 @@ 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);
+ 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);
}
@@ -120,11 +163,18 @@ 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}?
@@ -167,15 +217,59 @@ export default function ConfigurationPage() {
) : (
"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 && (
+