From 7a0934eaf91a47c304e4a0ed3357ecc51a8d191f Mon Sep 17 00:00:00 2001
From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com>
Date: Thu, 9 Jul 2026 20:48:27 +1000
Subject: [PATCH 1/9] Require 2FA before deleting services
---
web/actions/projects.ts | 64 ++++++++++-
.../[serviceId]/configuration/page.tsx | 106 +++++++++++++++++-
web/lib/service-delete-confirmation.ts | 29 +++++
web/lib/two-factor.ts | 4 +
web/tests/service-delete-confirmation.test.ts | 36 ++++++
web/tests/two-factor.test.ts | 6 +
6 files changed, 237 insertions(+), 8 deletions(-)
create mode 100644 web/lib/service-delete-confirmation.ts
create mode 100644 web/tests/service-delete-confirmation.test.ts
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index 828a2575..6c73ba39 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
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 +29,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 +48,27 @@ 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 {
+ getServiceDeleteConfirmation,
+ type ServiceDeleteConfirmation,
+} from "@/lib/service-delete-confirmation";
import { getZodErrorMessage, slugify } from "@/lib/utils";
import { enqueueWork } from "@/lib/work-queue";
import { deleteBackup } from "./backups";
+type AuthenticatedDeveloperSession = Awaited<
+ ReturnType
+>;
+
+type TwoFactorSessionUser = {
+ twoFactorEnabled?: boolean | null;
+};
+
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 +587,51 @@ async function hardDeleteService(serviceId: string) {
return { success: true };
}
-export async function deleteService(serviceId: string) {
- await requireDeveloperRole();
+function hasTwoFactorEnabled(session: AuthenticatedDeveloperSession) {
+ return Boolean(
+ (session?.user as TwoFactorSessionUser | undefined)?.twoFactorEnabled,
+ );
+}
+
+async function verifyServiceDeleteConfirmation(
+ session: AuthenticatedDeveloperSession,
+ confirmation?: ServiceDeleteConfirmation,
+) {
+ const normalizedConfirmation = getServiceDeleteConfirmation(
+ hasTwoFactorEnabled(session),
+ confirmation,
+ );
+
+ if (!normalizedConfirmation) return;
+
+ const requestHeaders = await headers();
+
+ try {
+ const passwordVerification = await auth.api.verifyPassword({
+ body: { password: normalizedConfirmation.password },
+ headers: requestHeaders,
+ });
+
+ if (passwordVerification.status !== true) {
+ throw new Error("Invalid password");
+ }
+
+ await auth.api.verifyTOTP({
+ body: { code: normalizedConfirmation.totpCode },
+ headers: requestHeaders,
+ });
+ } catch {
+ throw new Error("Invalid password or authenticator code");
+ }
+}
+
+export async function deleteService(
+ serviceId: string,
+ confirmation?: ServiceDeleteConfirmation,
+) {
+ const session = await requireDeveloperRole();
+ await verifyServiceDeleteConfirmation(session, confirmation);
+
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..ad73c4ba 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
@@ -30,10 +30,19 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
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 { normalizeTwoFactorCode } from "@/lib/two-factor";
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 +51,19 @@ 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 [deletePassword, setDeletePassword] = useState("");
+ const [deleteTotpCode, setDeleteTotpCode] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
+ const requiresDeleteConfirmation = Boolean(sessionUser?.twoFactorEnabled);
+ const normalizedDeleteTotpCode = normalizeTwoFactorCode(deleteTotpCode);
+ const isDeleteConfirmationIncomplete =
+ requiresDeleteConfirmation &&
+ (deletePassword.trim().length === 0 ||
+ normalizedDeleteTotpCode.length === 0);
const hasActiveDeploymentForBackup = service.deployments.some(
(deployment) =>
ACTIVE_DELETE_BACKUP_STATUSES.includes(
@@ -63,15 +83,39 @@ export default function ConfigurationPage() {
toast.info("Changes saved. Deploy to apply them.");
}, [onUpdate]);
+ const resetDeleteConfirmation = useCallback(() => {
+ setDeletePassword("");
+ setDeleteTotpCode("");
+ }, []);
+
const handleDelete = async () => {
+ if (isDeleteConfirmationIncomplete) {
+ toast.error("Enter your current password and authenticator code");
+ return;
+ }
+
setIsDeleting(true);
try {
- await deleteService(service.id);
+ await deleteService(
+ service.id,
+ requiresDeleteConfirmation
+ ? {
+ password: deletePassword,
+ totpCode: normalizedDeleteTotpCode,
+ }
+ : 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",
+ );
} finally {
setIsDeleting(false);
}
@@ -120,11 +164,17 @@ export default function ConfigurationPage() {
: "Once deleted, this service and all its deployments will be permanently removed."}
-
+ {
+ setDeleteDialogOpen(open);
+ if (!open && !isDeleting) resetDeleteConfirmation();
+ }}
+ >
}>
Delete Service
-
+
Delete {service.name}?
@@ -167,15 +217,63 @@ export default function ConfigurationPage() {
) : (
"This action cannot be undone. This will permanently delete the service and all its deployments."
)}
+ {requiresDeleteConfirmation && (
+ <>
+
+
+ Enter your current password and authenticator code to
+ confirm this deletion.
+ >
+ )}
+ {requiresDeleteConfirmation && (
+
+ )}
Cancel
+ {isDeleting ? : null}
{isDeleting ? "Deleting..." : "Delete"}
diff --git a/web/lib/service-delete-confirmation.ts b/web/lib/service-delete-confirmation.ts
new file mode 100644
index 00000000..d71d0ff3
--- /dev/null
+++ b/web/lib/service-delete-confirmation.ts
@@ -0,0 +1,29 @@
+import { normalizeTwoFactorCode } from "@/lib/two-factor";
+
+export type ServiceDeleteConfirmation = {
+ password?: string;
+ totpCode?: string;
+};
+
+export type NormalizedServiceDeleteConfirmation = {
+ password: string;
+ totpCode: string;
+};
+
+export function getServiceDeleteConfirmation(
+ twoFactorEnabled: boolean,
+ confirmation?: ServiceDeleteConfirmation,
+): NormalizedServiceDeleteConfirmation | null {
+ if (!twoFactorEnabled) return null;
+
+ const password = confirmation?.password?.trim() ?? "";
+ const totpCode = normalizeTwoFactorCode(confirmation?.totpCode);
+
+ if (!password || !totpCode) {
+ throw new Error(
+ "Password and authenticator code are required to delete this service",
+ );
+ }
+
+ return { password, totpCode };
+}
diff --git a/web/lib/two-factor.ts b/web/lib/two-factor.ts
index c45e0502..11253b4e 100644
--- a/web/lib/two-factor.ts
+++ b/web/lib/two-factor.ts
@@ -37,3 +37,7 @@ export function getTotpSecret(totpURI: string) {
return "";
}
}
+
+export function normalizeTwoFactorCode(value: string | null | undefined) {
+ return (value ?? "").replace(/\s/g, "");
+}
diff --git a/web/tests/service-delete-confirmation.test.ts b/web/tests/service-delete-confirmation.test.ts
new file mode 100644
index 00000000..47be1f00
--- /dev/null
+++ b/web/tests/service-delete-confirmation.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { getServiceDeleteConfirmation } from "@/lib/service-delete-confirmation";
+
+describe("service delete confirmation", () => {
+ it("does not require confirmation when 2FA is disabled", () => {
+ expect(getServiceDeleteConfirmation(false)).toBeNull();
+ });
+
+ it("requires password and authenticator code when 2FA is enabled", () => {
+ expect(() => getServiceDeleteConfirmation(true)).toThrow(
+ "Password and authenticator code are required to delete this service",
+ );
+ expect(() =>
+ getServiceDeleteConfirmation(true, { password: "secret" }),
+ ).toThrow(
+ "Password and authenticator code are required to delete this service",
+ );
+ expect(() =>
+ getServiceDeleteConfirmation(true, { totpCode: "123456" }),
+ ).toThrow(
+ "Password and authenticator code are required to delete this service",
+ );
+ });
+
+ it("trims password and normalizes authenticator code", () => {
+ expect(
+ getServiceDeleteConfirmation(true, {
+ password: " secret ",
+ totpCode: "123 456",
+ }),
+ ).toEqual({
+ password: "secret",
+ totpCode: "123456",
+ });
+ });
+});
diff --git a/web/tests/two-factor.test.ts b/web/tests/two-factor.test.ts
index 2a25755f..c8b24107 100644
--- a/web/tests/two-factor.test.ts
+++ b/web/tests/two-factor.test.ts
@@ -3,6 +3,7 @@ import {
DEFAULT_AUTH_REDIRECT,
getSafeAuthRedirect,
getTotpSecret,
+ normalizeTwoFactorCode,
} from "@/lib/two-factor";
describe("two-factor helpers", () => {
@@ -35,4 +36,9 @@ describe("two-factor helpers", () => {
it("returns an empty secret for malformed TOTP URIs", () => {
expect(getTotpSecret("not a uri")).toBe("");
});
+
+ it("normalizes two-factor codes by removing whitespace", () => {
+ expect(normalizeTwoFactorCode(" 123 456\n")).toBe("123456");
+ expect(normalizeTwoFactorCode(null)).toBe("");
+ });
});
From 7066f158c0e54241ca0851b126e5741cdd91925f Mon Sep 17 00:00:00 2001
From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com>
Date: Thu, 9 Jul 2026 20:55:19 +1000
Subject: [PATCH 2/9] Inline service delete 2FA check
---
web/actions/projects.ts | 15 ++++-----------
1 file changed, 4 insertions(+), 11 deletions(-)
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index 6c73ba39..39d1a0f9 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -65,10 +65,6 @@ type AuthenticatedDeveloperSession = Awaited<
ReturnType
>;
-type TwoFactorSessionUser = {
- twoFactorEnabled?: boolean | null;
-};
-
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}$/;
@@ -587,18 +583,15 @@ async function hardDeleteService(serviceId: string) {
return { success: true };
}
-function hasTwoFactorEnabled(session: AuthenticatedDeveloperSession) {
- return Boolean(
- (session?.user as TwoFactorSessionUser | undefined)?.twoFactorEnabled,
- );
-}
-
async function verifyServiceDeleteConfirmation(
session: AuthenticatedDeveloperSession,
confirmation?: ServiceDeleteConfirmation,
) {
const normalizedConfirmation = getServiceDeleteConfirmation(
- hasTwoFactorEnabled(session),
+ Boolean(
+ (session?.user as { twoFactorEnabled?: boolean | null } | undefined)
+ ?.twoFactorEnabled,
+ ),
confirmation,
);
From 5bd3fcffe72046a1b4fc59908b74c997ed028fb6 Mon Sep 17 00:00:00 2001
From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com>
Date: Thu, 9 Jul 2026 21:34:35 +1000
Subject: [PATCH 3/9] Simplify service delete confirmation
---
web/actions/projects.ts | 32 ++++++++++-------
.../[serviceId]/configuration/page.tsx | 3 +-
web/lib/service-delete-confirmation.ts | 29 ---------------
web/lib/two-factor.ts | 4 ---
web/tests/service-delete-confirmation.test.ts | 36 -------------------
web/tests/two-factor.test.ts | 6 ----
6 files changed, 20 insertions(+), 90 deletions(-)
delete mode 100644 web/lib/service-delete-confirmation.ts
delete mode 100644 web/tests/service-delete-confirmation.test.ts
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index 39d1a0f9..552903d2 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -53,10 +53,6 @@ import type {
HealthCheckConfig as ServiceHealthCheckConfig,
} from "@/lib/service-config";
import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config";
-import {
- getServiceDeleteConfirmation,
- type ServiceDeleteConfirmation,
-} from "@/lib/service-delete-confirmation";
import { getZodErrorMessage, slugify } from "@/lib/utils";
import { enqueueWork } from "@/lib/work-queue";
import { deleteBackup } from "./backups";
@@ -65,6 +61,11 @@ type AuthenticatedDeveloperSession = Awaited<
ReturnType
>;
+type ServiceDeleteConfirmation = {
+ password?: string;
+ 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}$/;
@@ -587,21 +588,26 @@ async function verifyServiceDeleteConfirmation(
session: AuthenticatedDeveloperSession,
confirmation?: ServiceDeleteConfirmation,
) {
- const normalizedConfirmation = getServiceDeleteConfirmation(
- Boolean(
- (session?.user as { twoFactorEnabled?: boolean | null } | undefined)
- ?.twoFactorEnabled,
- ),
- confirmation,
+ const twoFactorEnabled = Boolean(
+ (session?.user as { twoFactorEnabled?: boolean | null } | undefined)
+ ?.twoFactorEnabled,
);
+ if (!twoFactorEnabled) return;
+
+ const password = confirmation?.password?.trim() ?? "";
+ const totpCode = (confirmation?.totpCode ?? "").replace(/\s/g, "");
- if (!normalizedConfirmation) return;
+ if (!password || !totpCode) {
+ throw new Error(
+ "Password and authenticator code are required to delete this service",
+ );
+ }
const requestHeaders = await headers();
try {
const passwordVerification = await auth.api.verifyPassword({
- body: { password: normalizedConfirmation.password },
+ body: { password },
headers: requestHeaders,
});
@@ -610,7 +616,7 @@ async function verifyServiceDeleteConfirmation(
}
await auth.api.verifyTOTP({
- body: { code: normalizedConfirmation.totpCode },
+ body: { code: totpCode },
headers: requestHeaders,
});
} catch {
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 ad73c4ba..917883be 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
@@ -35,7 +35,6 @@ 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 { normalizeTwoFactorCode } from "@/lib/two-factor";
const ACTIVE_DELETE_BACKUP_STATUSES = ["running", "healthy"] as const;
@@ -59,7 +58,7 @@ export default function ConfigurationPage() {
const [deleteTotpCode, setDeleteTotpCode] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const requiresDeleteConfirmation = Boolean(sessionUser?.twoFactorEnabled);
- const normalizedDeleteTotpCode = normalizeTwoFactorCode(deleteTotpCode);
+ const normalizedDeleteTotpCode = deleteTotpCode.replace(/\s/g, "");
const isDeleteConfirmationIncomplete =
requiresDeleteConfirmation &&
(deletePassword.trim().length === 0 ||
diff --git a/web/lib/service-delete-confirmation.ts b/web/lib/service-delete-confirmation.ts
deleted file mode 100644
index d71d0ff3..00000000
--- a/web/lib/service-delete-confirmation.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { normalizeTwoFactorCode } from "@/lib/two-factor";
-
-export type ServiceDeleteConfirmation = {
- password?: string;
- totpCode?: string;
-};
-
-export type NormalizedServiceDeleteConfirmation = {
- password: string;
- totpCode: string;
-};
-
-export function getServiceDeleteConfirmation(
- twoFactorEnabled: boolean,
- confirmation?: ServiceDeleteConfirmation,
-): NormalizedServiceDeleteConfirmation | null {
- if (!twoFactorEnabled) return null;
-
- const password = confirmation?.password?.trim() ?? "";
- const totpCode = normalizeTwoFactorCode(confirmation?.totpCode);
-
- if (!password || !totpCode) {
- throw new Error(
- "Password and authenticator code are required to delete this service",
- );
- }
-
- return { password, totpCode };
-}
diff --git a/web/lib/two-factor.ts b/web/lib/two-factor.ts
index 11253b4e..c45e0502 100644
--- a/web/lib/two-factor.ts
+++ b/web/lib/two-factor.ts
@@ -37,7 +37,3 @@ export function getTotpSecret(totpURI: string) {
return "";
}
}
-
-export function normalizeTwoFactorCode(value: string | null | undefined) {
- return (value ?? "").replace(/\s/g, "");
-}
diff --git a/web/tests/service-delete-confirmation.test.ts b/web/tests/service-delete-confirmation.test.ts
deleted file mode 100644
index 47be1f00..00000000
--- a/web/tests/service-delete-confirmation.test.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { getServiceDeleteConfirmation } from "@/lib/service-delete-confirmation";
-
-describe("service delete confirmation", () => {
- it("does not require confirmation when 2FA is disabled", () => {
- expect(getServiceDeleteConfirmation(false)).toBeNull();
- });
-
- it("requires password and authenticator code when 2FA is enabled", () => {
- expect(() => getServiceDeleteConfirmation(true)).toThrow(
- "Password and authenticator code are required to delete this service",
- );
- expect(() =>
- getServiceDeleteConfirmation(true, { password: "secret" }),
- ).toThrow(
- "Password and authenticator code are required to delete this service",
- );
- expect(() =>
- getServiceDeleteConfirmation(true, { totpCode: "123456" }),
- ).toThrow(
- "Password and authenticator code are required to delete this service",
- );
- });
-
- it("trims password and normalizes authenticator code", () => {
- expect(
- getServiceDeleteConfirmation(true, {
- password: " secret ",
- totpCode: "123 456",
- }),
- ).toEqual({
- password: "secret",
- totpCode: "123456",
- });
- });
-});
diff --git a/web/tests/two-factor.test.ts b/web/tests/two-factor.test.ts
index c8b24107..2a25755f 100644
--- a/web/tests/two-factor.test.ts
+++ b/web/tests/two-factor.test.ts
@@ -3,7 +3,6 @@ import {
DEFAULT_AUTH_REDIRECT,
getSafeAuthRedirect,
getTotpSecret,
- normalizeTwoFactorCode,
} from "@/lib/two-factor";
describe("two-factor helpers", () => {
@@ -36,9 +35,4 @@ describe("two-factor helpers", () => {
it("returns an empty secret for malformed TOTP URIs", () => {
expect(getTotpSecret("not a uri")).toBe("");
});
-
- it("normalizes two-factor codes by removing whitespace", () => {
- expect(normalizeTwoFactorCode(" 123 456\n")).toBe("123456");
- expect(normalizeTwoFactorCode(null)).toBe("");
- });
});
From 69ae8deffd45d07c4e2e502a2156a53205a26826 Mon Sep 17 00:00:00 2001
From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com>
Date: Thu, 9 Jul 2026 21:50:25 +1000
Subject: [PATCH 4/9] Use auth code only for service delete confirmation
---
web/actions/projects.ts | 55 +++++--------------
.../[serviceId]/configuration/page.tsx | 27 +--------
2 files changed, 18 insertions(+), 64 deletions(-)
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index 552903d2..1f598302 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -57,12 +57,7 @@ import { getZodErrorMessage, slugify } from "@/lib/utils";
import { enqueueWork } from "@/lib/work-queue";
import { deleteBackup } from "./backups";
-type AuthenticatedDeveloperSession = Awaited<
- ReturnType
->;
-
type ServiceDeleteConfirmation = {
- password?: string;
totpCode?: string;
};
@@ -584,52 +579,32 @@ async function hardDeleteService(serviceId: string) {
return { success: true };
}
-async function verifyServiceDeleteConfirmation(
- session: AuthenticatedDeveloperSession,
+export async function deleteService(
+ serviceId: string,
confirmation?: ServiceDeleteConfirmation,
) {
+ const session = await requireDeveloperRole();
const twoFactorEnabled = Boolean(
(session?.user as { twoFactorEnabled?: boolean | null } | undefined)
?.twoFactorEnabled,
);
- if (!twoFactorEnabled) return;
-
- const password = confirmation?.password?.trim() ?? "";
- const totpCode = (confirmation?.totpCode ?? "").replace(/\s/g, "");
-
- if (!password || !totpCode) {
- throw new Error(
- "Password and authenticator code are required to delete this service",
- );
- }
- const requestHeaders = await headers();
-
- try {
- const passwordVerification = await auth.api.verifyPassword({
- body: { password },
- headers: requestHeaders,
- });
+ if (twoFactorEnabled) {
+ const totpCode = (confirmation?.totpCode ?? "").replace(/\s/g, "");
- if (passwordVerification.status !== true) {
- throw new Error("Invalid password");
+ if (!totpCode) {
+ throw new Error("Authenticator code is required to delete this service");
}
- await auth.api.verifyTOTP({
- body: { code: totpCode },
- headers: requestHeaders,
- });
- } catch {
- throw new Error("Invalid password or authenticator code");
+ try {
+ await auth.api.verifyTOTP({
+ body: { code: totpCode },
+ headers: await headers(),
+ });
+ } catch {
+ throw new Error("Invalid authenticator code");
+ }
}
-}
-
-export async function deleteService(
- serviceId: string,
- confirmation?: ServiceDeleteConfirmation,
-) {
- const session = await requireDeveloperRole();
- await verifyServiceDeleteConfirmation(session, confirmation);
const service = await getService(serviceId);
if (!service) {
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 917883be..8b334d79 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
@@ -54,15 +54,12 @@ export default function ConfigurationPage() {
const sessionUser = session?.user as TwoFactorSessionUser | undefined;
const { service, projectSlug, envName, proxyDomain, onUpdate } = useService();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [deletePassword, setDeletePassword] = useState("");
const [deleteTotpCode, setDeleteTotpCode] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const requiresDeleteConfirmation = Boolean(sessionUser?.twoFactorEnabled);
const normalizedDeleteTotpCode = deleteTotpCode.replace(/\s/g, "");
const isDeleteConfirmationIncomplete =
- requiresDeleteConfirmation &&
- (deletePassword.trim().length === 0 ||
- normalizedDeleteTotpCode.length === 0);
+ requiresDeleteConfirmation && normalizedDeleteTotpCode.length === 0;
const hasActiveDeploymentForBackup = service.deployments.some(
(deployment) =>
ACTIVE_DELETE_BACKUP_STATUSES.includes(
@@ -83,13 +80,12 @@ export default function ConfigurationPage() {
}, [onUpdate]);
const resetDeleteConfirmation = useCallback(() => {
- setDeletePassword("");
setDeleteTotpCode("");
}, []);
const handleDelete = async () => {
if (isDeleteConfirmationIncomplete) {
- toast.error("Enter your current password and authenticator code");
+ toast.error("Enter your authenticator code");
return;
}
@@ -99,7 +95,6 @@ export default function ConfigurationPage() {
service.id,
requiresDeleteConfirmation
? {
- password: deletePassword,
totpCode: normalizedDeleteTotpCode,
}
: undefined,
@@ -220,29 +215,13 @@ export default function ConfigurationPage() {
<>
- Enter your current password and authenticator code to
- confirm this deletion.
+ Enter your authenticator code to confirm this deletion.
>
)}
{requiresDeleteConfirmation && (
-
-
- Current password
-
-
- setDeletePassword(event.target.value)
- }
- disabled={isDeleting}
- />
-
Authenticator code
From e51e1bf4aec5f16eb75c54ab479d38cec726ddc9 Mon Sep 17 00:00:00 2001
From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com>
Date: Thu, 9 Jul 2026 21:58:35 +1000
Subject: [PATCH 5/9] Use shadcn OTP input for service delete code
---
.../[serviceId]/configuration/page.tsx | 31 +++++--
web/app/globals.css | 17 +++-
web/components/ui/input-otp.tsx | 85 +++++++++++++++++++
web/package.json | 1 +
web/pnpm-lock.yaml | 14 +++
5 files changed, 138 insertions(+), 10 deletions(-)
create mode 100644 web/components/ui/input-otp.tsx
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 8b334d79..b1d153de 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,7 +31,11 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
+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";
@@ -59,7 +64,7 @@ export default function ConfigurationPage() {
const requiresDeleteConfirmation = Boolean(sessionUser?.twoFactorEnabled);
const normalizedDeleteTotpCode = deleteTotpCode.replace(/\s/g, "");
const isDeleteConfirmationIncomplete =
- requiresDeleteConfirmation && normalizedDeleteTotpCode.length === 0;
+ requiresDeleteConfirmation && normalizedDeleteTotpCode.length !== 6;
const hasActiveDeploymentForBackup = service.deployments.some(
(deployment) =>
ACTIVE_DELETE_BACKUP_STATUSES.includes(
@@ -85,7 +90,7 @@ export default function ConfigurationPage() {
const handleDelete = async () => {
if (isDeleteConfirmationIncomplete) {
- toast.error("Enter your authenticator code");
+ toast.error("Enter your 6-digit authenticator code");
return;
}
@@ -226,17 +231,27 @@ export default function ConfigurationPage() {
Authenticator code
-
- setDeleteTotpCode(event.target.value)
+ onChange={(value) =>
+ setDeleteTotpCode(value.replace(/\D/g, ""))
}
- placeholder="123456"
disabled={isDeleting}
- />
+ >
+
+
+
+
+
+
+
+
+
)}
diff --git a/web/app/globals.css b/web/app/globals.css
index 24cec57e..33dea139 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -8,8 +8,8 @@
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-sans--font-feature-settings: "cv11", "ss03";
--font-mono:
- var(--font-ioskeley-mono), ui-monospace, SFMono-Regular, "SF Mono",
- Menlo, Consolas, monospace;
+ var(--font-ioskeley-mono), ui-monospace, SFMono-Regular, "SF Mono", Menlo,
+ Consolas, monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-sidebar-ring: var(--sidebar-ring);
@@ -76,6 +76,7 @@
@theme {
--animate-shimmer: shimmer 1.4s ease-in-out infinite;
+ --animate-caret-blink: caret-blink 1.2s ease-out infinite;
@keyframes shimmer {
0% {
@@ -85,6 +86,18 @@
transform: translateX(300%);
}
}
+
+ @keyframes caret-blink {
+ 0%,
+ 70%,
+ 100% {
+ opacity: 1;
+ }
+ 20%,
+ 50% {
+ opacity: 0;
+ }
+ }
}
@layer theme {
diff --git a/web/components/ui/input-otp.tsx b/web/components/ui/input-otp.tsx
new file mode 100644
index 00000000..c2258750
--- /dev/null
+++ b/web/components/ui/input-otp.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { OTPInput, OTPInputContext } from "input-otp";
+import { MinusIcon } from "lucide-react";
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+function InputOTP({
+ className,
+ containerClassName,
+ ...props
+}: React.ComponentProps & {
+ containerClassName?: string;
+}) {
+ return (
+
+ );
+}
+
+function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function InputOTPSlot({
+ index,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ index: number;
+}) {
+ const inputOTPContext = React.useContext(OTPInputContext);
+ const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
+
+ return (
+
+ {char}
+ {hasFakeCaret && (
+
+ )}
+
+ );
+}
+
+function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
+ return (
+
+
+
+ );
+}
+
+export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
diff --git a/web/package.json b/web/package.json
index 69ff99b6..8bae61e0 100644
--- a/web/package.json
+++ b/web/package.json
@@ -29,6 +29,7 @@
"cronstrue": "^3.9.0",
"drizzle-orm": "^0.45.1",
"inngest": "^4.3.0",
+ "input-otp": "^1.4.2",
"ip-address": "^10.1.0",
"jose": "^6.1.3",
"lucide-react": "^0.562.0",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 37609859..83d1aea3 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -54,6 +54,9 @@ importers:
inngest:
specifier: ^4.3.0
version: 4.6.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(express@5.2.1)(hono@4.12.25)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3)(zod@4.4.3)
+ input-otp:
+ specifier: ^1.4.2
+ version: 1.4.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
ip-address:
specifier: ^10.1.0
version: 10.2.0
@@ -4239,6 +4242,12 @@ packages:
typescript:
optional: true
+ input-otp@1.4.2:
+ resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
+ peerDependencies:
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -10259,6 +10268,11 @@ snapshots:
- encoding
- supports-color
+ input-otp@1.4.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
From fd6ddd877b3d0e9531b3e26f4b5a71fa09a96d91 Mon Sep 17 00:00:00 2001
From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com>
Date: Thu, 9 Jul 2026 23:03:53 +1000
Subject: [PATCH 6/9] Use OTP input for auth codes
---
.../auth/two-factor-challenge-page.tsx | 56 +++++++++++++++----
.../settings/two-factor-settings.tsx | 28 ++++++++--
2 files changed, 67 insertions(+), 17 deletions(-)
diff --git a/web/components/auth/two-factor-challenge-page.tsx b/web/components/auth/two-factor-challenge-page.tsx
index f9eb22f0..e23ee920 100644
--- a/web/components/auth/two-factor-challenge-page.tsx
+++ b/web/components/auth/two-factor-challenge-page.tsx
@@ -1,5 +1,6 @@
"use client";
+import { REGEXP_ONLY_DIGITS } from "input-otp";
import Image from "next/image";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
@@ -14,6 +15,11 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
@@ -145,16 +151,39 @@ export function TwoFactorChallengePage() {
{mode === "totp" ? "Authenticator code" : "Backup code"}
- setCode(event.target.value)}
- placeholder={mode === "totp" ? "123456" : "XXXX-XXXX"}
- required
- autoFocus
- />
+ {mode === "totp" ? (
+ setCode(value.replace(/\D/g, ""))}
+ required
+ autoFocus
+ >
+
+
+
+
+
+
+
+
+
+ ) : (
+ setCode(event.target.value)}
+ placeholder="XXXX-XXXX"
+ required
+ autoFocus
+ />
+ )}
@@ -174,7 +203,12 @@ export function TwoFactorChallengePage() {
{loading ? : null}
Verify
diff --git a/web/components/settings/two-factor-settings.tsx b/web/components/settings/two-factor-settings.tsx
index eed12b1a..550f0a95 100644
--- a/web/components/settings/two-factor-settings.tsx
+++ b/web/components/settings/two-factor-settings.tsx
@@ -1,5 +1,6 @@
"use client";
+import { REGEXP_ONLY_DIGITS } from "input-otp";
import {
Copy,
KeyRound,
@@ -15,6 +16,11 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+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";
@@ -388,23 +394,33 @@ export function TwoFactorSettings() {
Authenticator code
-
- setVerificationCode(event.target.value)
+ onChange={(value) =>
+ setVerificationCode(value.replace(/\D/g, ""))
}
- placeholder="123456"
required
- />
+ >
+
+
+
+
+
+
+
+
+
{isVerifying ? : null}
From 13b97c6206d7963660d5c997ced6a5b5321c0b81 Mon Sep 17 00:00:00 2001
From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com>
Date: Thu, 9 Jul 2026 23:09:33 +1000
Subject: [PATCH 7/9] Throttle service delete TOTP attempts
---
web/actions/projects.ts | 70 +++++++++++++++++--
.../[serviceId]/configuration/page.tsx | 5 +-
2 files changed, 68 insertions(+), 7 deletions(-)
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index 1f598302..6f0220ca 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -26,6 +26,7 @@ import {
serviceReplicas,
services,
serviceVolumes,
+ twoFactor,
volumeBackups,
workQueue,
} from "@/db/schema";
@@ -61,6 +62,11 @@ type ServiceDeleteConfirmation = {
totpCode?: string;
};
+const SERVICE_DELETE_TOTP_MAX_FAILED_ATTEMPTS = 10;
+const SERVICE_DELETE_TOTP_LOCK_MS = 15 * 60 * 1000;
+const SERVICE_DELETE_TOTP_LOCK_MESSAGE =
+ "Too many invalid authenticator codes. Try again later.";
+
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}$/;
@@ -584,26 +590,82 @@ export async function deleteService(
confirmation?: ServiceDeleteConfirmation,
) {
const session = await requireDeveloperRole();
+ if (!session) {
+ throw new Error("Unauthorized");
+ }
+
const twoFactorEnabled = Boolean(
- (session?.user as { twoFactorEnabled?: boolean | null } | undefined)
- ?.twoFactorEnabled,
+ (session.user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled,
);
if (twoFactorEnabled) {
- const totpCode = (confirmation?.totpCode ?? "").replace(/\s/g, "");
+ const totpCode = confirmation?.totpCode ?? "";
- if (!totpCode) {
+ if (!/^\d{6}$/.test(totpCode)) {
throw new Error("Authenticator code is required to delete this service");
}
+ const [twoFactorRecord] = await db
+ .select({
+ id: twoFactor.id,
+ lockedUntil: twoFactor.lockedUntil,
+ })
+ .from(twoFactor)
+ .where(eq(twoFactor.userId, session.user.id))
+ .limit(1);
+
+ if (!twoFactorRecord) {
+ throw new Error("Authenticator code is not configured");
+ }
+
+ if (twoFactorRecord.lockedUntil) {
+ if (twoFactorRecord.lockedUntil > new Date()) {
+ throw new Error(SERVICE_DELETE_TOTP_LOCK_MESSAGE);
+ }
+
+ await db
+ .update(twoFactor)
+ .set({ failedVerificationCount: 0, lockedUntil: null })
+ .where(eq(twoFactor.id, twoFactorRecord.id));
+ }
+
try {
await auth.api.verifyTOTP({
body: { code: totpCode },
headers: await headers(),
});
} catch {
+ const [updatedTwoFactor] = await db
+ .update(twoFactor)
+ .set({
+ failedVerificationCount: sql`${twoFactor.failedVerificationCount} + 1`,
+ })
+ .where(eq(twoFactor.id, twoFactorRecord.id))
+ .returning({
+ failedVerificationCount: twoFactor.failedVerificationCount,
+ });
+
+ if (
+ (updatedTwoFactor?.failedVerificationCount ?? 0) >=
+ SERVICE_DELETE_TOTP_MAX_FAILED_ATTEMPTS
+ ) {
+ await db
+ .update(twoFactor)
+ .set({
+ lockedUntil: new Date(Date.now() + SERVICE_DELETE_TOTP_LOCK_MS),
+ })
+ .where(eq(twoFactor.id, twoFactorRecord.id));
+
+ throw new Error(SERVICE_DELETE_TOTP_LOCK_MESSAGE);
+ }
+
throw new Error("Invalid authenticator code");
}
+
+ await db
+ .update(twoFactor)
+ .set({ failedVerificationCount: 0, lockedUntil: null })
+ .where(eq(twoFactor.id, twoFactorRecord.id));
}
const service = await getService(serviceId);
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 b1d153de..38a81ba5 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
@@ -62,9 +62,8 @@ export default function ConfigurationPage() {
const [deleteTotpCode, setDeleteTotpCode] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const requiresDeleteConfirmation = Boolean(sessionUser?.twoFactorEnabled);
- const normalizedDeleteTotpCode = deleteTotpCode.replace(/\s/g, "");
const isDeleteConfirmationIncomplete =
- requiresDeleteConfirmation && normalizedDeleteTotpCode.length !== 6;
+ requiresDeleteConfirmation && deleteTotpCode.length !== 6;
const hasActiveDeploymentForBackup = service.deployments.some(
(deployment) =>
ACTIVE_DELETE_BACKUP_STATUSES.includes(
@@ -100,7 +99,7 @@ export default function ConfigurationPage() {
service.id,
requiresDeleteConfirmation
? {
- totpCode: normalizedDeleteTotpCode,
+ totpCode: deleteTotpCode,
}
: undefined,
);
From b6f6125d283dcbaf26cd3da2e0485d349051c7a2 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Thu, 9 Jul 2026 23:16:49 +1000
Subject: [PATCH 8/9] Clean up
---
web/actions/projects.ts | 65 ++++-------------------------------------
1 file changed, 5 insertions(+), 60 deletions(-)
diff --git a/web/actions/projects.ts b/web/actions/projects.ts
index 6f0220ca..a384a25f 100644
--- a/web/actions/projects.ts
+++ b/web/actions/projects.ts
@@ -1,6 +1,7 @@
"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";
@@ -26,7 +27,6 @@ import {
serviceReplicas,
services,
serviceVolumes,
- twoFactor,
volumeBackups,
workQueue,
} from "@/db/schema";
@@ -62,11 +62,6 @@ type ServiceDeleteConfirmation = {
totpCode?: string;
};
-const SERVICE_DELETE_TOTP_MAX_FAILED_ATTEMPTS = 10;
-const SERVICE_DELETE_TOTP_LOCK_MS = 15 * 60 * 1000;
-const SERVICE_DELETE_TOTP_LOCK_MESSAGE =
- "Too many invalid authenticator codes. Try again later.";
-
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}$/;
@@ -605,67 +600,17 @@ export async function deleteService(
throw new Error("Authenticator code is required to delete this service");
}
- const [twoFactorRecord] = await db
- .select({
- id: twoFactor.id,
- lockedUntil: twoFactor.lockedUntil,
- })
- .from(twoFactor)
- .where(eq(twoFactor.userId, session.user.id))
- .limit(1);
-
- if (!twoFactorRecord) {
- throw new Error("Authenticator code is not configured");
- }
-
- if (twoFactorRecord.lockedUntil) {
- if (twoFactorRecord.lockedUntil > new Date()) {
- throw new Error(SERVICE_DELETE_TOTP_LOCK_MESSAGE);
- }
-
- await db
- .update(twoFactor)
- .set({ failedVerificationCount: 0, lockedUntil: null })
- .where(eq(twoFactor.id, twoFactorRecord.id));
- }
-
try {
await auth.api.verifyTOTP({
body: { code: totpCode },
headers: await headers(),
});
- } catch {
- const [updatedTwoFactor] = await db
- .update(twoFactor)
- .set({
- failedVerificationCount: sql`${twoFactor.failedVerificationCount} + 1`,
- })
- .where(eq(twoFactor.id, twoFactorRecord.id))
- .returning({
- failedVerificationCount: twoFactor.failedVerificationCount,
- });
-
- if (
- (updatedTwoFactor?.failedVerificationCount ?? 0) >=
- SERVICE_DELETE_TOTP_MAX_FAILED_ATTEMPTS
- ) {
- await db
- .update(twoFactor)
- .set({
- lockedUntil: new Date(Date.now() + SERVICE_DELETE_TOTP_LOCK_MS),
- })
- .where(eq(twoFactor.id, twoFactorRecord.id));
-
- throw new Error(SERVICE_DELETE_TOTP_LOCK_MESSAGE);
+ } catch (error) {
+ if (isAPIError(error)) {
+ throw new Error("Invalid authenticator code");
}
-
- throw new Error("Invalid authenticator code");
+ throw error;
}
-
- await db
- .update(twoFactor)
- .set({ failedVerificationCount: 0, lockedUntil: null })
- .where(eq(twoFactor.id, twoFactorRecord.id));
}
const service = await getService(serviceId);
From a38ac10770e138b5f9a49c8ffb2ffd804d3a3573 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Thu, 9 Jul 2026 23:22:41 +1000
Subject: [PATCH 9/9] Clean up
---
.../[env]/services/[serviceId]/configuration/page.tsx | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
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 38a81ba5..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
@@ -114,6 +114,7 @@ export default function ConfigurationPage() {
toast.error(
error instanceof Error ? error.message : "Failed to delete service",
);
+ resetDeleteConfirmation();
} finally {
setIsDeleting(false);
}
@@ -165,8 +166,9 @@ export default function ConfigurationPage() {
{
+ if (isDeleting) return;
setDeleteDialogOpen(open);
- if (!open && !isDeleting) resetDeleteConfirmation();
+ if (!open) resetDeleteConfirmation();
}}
>
}>
@@ -255,7 +257,9 @@ export default function ConfigurationPage() {
)}
- Cancel
+
+ Cancel
+