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 && ( +
+
+ + + setDeletePassword(event.target.value) + } + disabled={isDeleting} + /> +
+
+ + + setDeleteTotpCode(event.target.value) + } + placeholder="123456" + disabled={isDeleting} + /> +
+
+ )} 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 && (
-
- - - setDeletePassword(event.target.value) - } - 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() { - 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() {
)} - Cancel + + Cancel +