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 && ( -
-
- - - setDeleteTotpCode(value.replace(/\D/g, "")) - } - disabled={isDeleting} - > - - - - - - - - - -
-
- )} - - - Cancel - - - {isDeleting ? : null} - {isDeleting ? "Deleting..." : "Delete"} - - -
-
+ + 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." + ) + } + fallbackError="Failed to delete service" + onDelete={handleDelete} + /> diff --git a/web/components/core/delete-confirmation-dialog.tsx b/web/components/core/delete-confirmation-dialog.tsx new file mode 100644 index 00000000..ffc095d3 --- /dev/null +++ b/web/components/core/delete-confirmation-dialog.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { REGEXP_ONLY_DIGITS } from "input-otp"; +import type { ReactNode } from "react"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +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 { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { useSession } from "@/lib/auth-client"; +import type { DeleteConfirmation } from "@/lib/two-factor"; + +type TwoFactorSessionUser = { + twoFactorEnabled?: boolean | null; +}; + +export function DeleteConfirmationDialog({ + resourceName, + triggerLabel, + description, + fallbackError, + onDelete, +}: { + resourceName: string; + triggerLabel: string; + description: ReactNode; + fallbackError: string; + onDelete: (confirmation?: DeleteConfirmation) => Promise; +}) { + const { + data: session, + isPending: isSessionLoading, + refetch: refetchSession, + } = useSession(); + const sessionUser = session?.user as TwoFactorSessionUser | undefined; + const totpInputId = useId(); + const [open, setOpen] = useState(false); + const [totpCode, setTotpCode] = useState(""); + const [isDeleting, setIsDeleting] = useState(false); + const requiresConfirmation = Boolean(sessionUser?.twoFactorEnabled); + const isConfirmationIncomplete = + requiresConfirmation && totpCode.length !== 6; + + const resetConfirmation = () => setTotpCode(""); + + const handleDelete = async () => { + if (isConfirmationIncomplete) { + toast.error("Enter your 6-digit authenticator code"); + return; + } + + setIsDeleting(true); + try { + await onDelete( + requiresConfirmation + ? { + totpCode, + } + : undefined, + ); + setOpen(false); + resetConfirmation(); + } catch (error) { + if (!requiresConfirmation) { + await refetchSession(); + } + toast.error(error instanceof Error ? error.message : fallbackError); + resetConfirmation(); + } finally { + setIsDeleting(false); + } + }; + + return ( + { + if (isDeleting) return; + setOpen(nextOpen); + if (!nextOpen) resetConfirmation(); + }} + > + }> + {triggerLabel} + + + + Delete {resourceName}? + + {description} + {requiresConfirmation && ( + <> +
+
+ Enter your authenticator code to confirm this deletion. + + )} +
+
+ {requiresConfirmation && ( +
+ + setTotpCode(value.replace(/\D/g, ""))} + disabled={isDeleting} + > + + + + + + + + + +
+ )} + + Cancel + { + void handleDelete(); + }} + disabled={ + isDeleting || isSessionLoading || isConfirmationIncomplete + } + > + {isDeleting ? : null} + {isDeleting ? "Deleting..." : "Delete"} + + +
+
+ ); +} diff --git a/web/components/project/project-settings-panel.tsx b/web/components/project/project-settings-panel.tsx index 91ae3574..5de5bb50 100644 --- a/web/components/project/project-settings-panel.tsx +++ b/web/components/project/project-settings-panel.tsx @@ -1,28 +1,17 @@ "use client"; -import { useState } from "react"; +import { Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { Trash2 } from "lucide-react"; import { + deleteProject, updateProjectName, updateProjectSlug, - deleteProject, } from "@/actions/projects"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; +import { DeleteConfirmationDialog } from "@/components/core/delete-confirmation-dialog"; import { EditableText } from "@/components/core/editable-text"; +import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; +import type { DeleteConfirmation } from "@/lib/two-factor"; type Project = { id: string; @@ -87,20 +76,11 @@ export function ProjectSettingsPanel({ project }: { project: Project }) { export function ProjectDangerZone({ project }: { project: Project }) { const router = useRouter(); - const [isDeleting, setIsDeleting] = useState(false); - const handleDelete = async () => { - setIsDeleting(true); - try { - await deleteProject(project.id); - toast.success("Project deleted"); - router.push("/dashboard"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to delete project", - ); - setIsDeleting(false); - } + const handleDelete = async (confirmation?: DeleteConfirmation) => { + await deleteProject(project.id, confirmation); + toast.success("Project deleted"); + router.push("/dashboard"); }; return ( @@ -118,30 +98,13 @@ export function ProjectDangerZone({ project }: { project: Project }) { deployments will be permanently removed.

- - }> - Delete Project - - - - Delete {project.name}? - - This action cannot be undone. This will permanently delete the - project and all its environments, services, and deployments. - - - - Cancel - - {isDeleting ? "Deleting..." : "Delete"} - - - - + diff --git a/web/components/server/server-danger-zone.tsx b/web/components/server/server-danger-zone.tsx index f956d682..b0b07ba1 100644 --- a/web/components/server/server-danger-zone.tsx +++ b/web/components/server/server-danger-zone.tsx @@ -1,23 +1,12 @@ "use client"; -import { useState } from "react"; +import { Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { Trash2 } from "lucide-react"; import { deleteServer } from "@/actions/servers"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; +import { DeleteConfirmationDialog } from "@/components/core/delete-confirmation-dialog"; import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; +import type { DeleteConfirmation } from "@/lib/two-factor"; export function ServerDangerZone({ serverId, @@ -27,20 +16,11 @@ export function ServerDangerZone({ serverName: string; }) { const router = useRouter(); - const [isDeleting, setIsDeleting] = useState(false); - const handleDelete = async () => { - setIsDeleting(true); - try { - await deleteServer(serverId); - toast.success("Server deleted"); - router.push("/dashboard"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to delete server", - ); - setIsDeleting(false); - } + const handleDelete = async (confirmation?: DeleteConfirmation) => { + await deleteServer(serverId, confirmation); + toast.success("Server deleted"); + router.push("/dashboard"); }; return ( @@ -58,30 +38,13 @@ export function ServerDangerZone({ longer be available for deployments.

- - }> - Delete Server - - - - Delete {serverName}? - - This action cannot be undone. This will permanently delete the - server and remove it from your infrastructure. - - - - Cancel - - {isDeleting ? "Deleting..." : "Delete"} - - - - + diff --git a/web/lib/auth.ts b/web/lib/auth.ts index 73bc27fb..8e38b566 100644 --- a/web/lib/auth.ts +++ b/web/lib/auth.ts @@ -1,6 +1,7 @@ import { apiKey } from "@better-auth/api-key"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { isAPIError } from "better-auth/api"; import { bearer, deviceAuthorization, twoFactor } from "better-auth/plugins"; import { admin } from "better-auth/plugins/admin"; import { userAc } from "better-auth/plugins/admin/access"; @@ -9,6 +10,7 @@ import { db } from "@/db"; import * as schema from "@/db/schema"; import type { MemberRole } from "@/db/types"; import { getUserRole, hasAnyRole } from "@/lib/members"; +import { type DeleteConfirmation, getDeleteTotpCode } from "@/lib/two-factor"; const TECHULUS_CLI_CLIENT_ID = "techulus-cli"; const APP_NAME = "Techulus Cloud"; @@ -96,6 +98,37 @@ export async function requireDeveloperRole() { return requireRole(["admin", "developer"]); } +export async function verifyDeleteConfirmation( + session: Awaited>, + confirmation: DeleteConfirmation | undefined, + resource: string, +) { + if (!session) { + throw new Error("Unauthorized"); + } + + const totpCode = getDeleteTotpCode( + Boolean( + (session.user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled, + ), + confirmation, + resource, + ); + if (!totpCode) return; + + try { + await auth.api.verifyTOTP({ + body: { code: totpCode }, + headers: await headers(), + }); + } catch (error) { + if (isAPIError(error)) { + throw new Error("Invalid authenticator code"); + } + throw error; + } +} + export async function requireAdminRole() { return requireRole(["admin"]); } diff --git a/web/lib/two-factor.ts b/web/lib/two-factor.ts index c45e0502..2ba72485 100644 --- a/web/lib/two-factor.ts +++ b/web/lib/two-factor.ts @@ -1,5 +1,9 @@ export const DEFAULT_AUTH_REDIRECT = "/dashboard"; +export type DeleteConfirmation = { + totpCode?: string; +}; + const AUTH_REDIRECT_ORIGIN = "https://auth.local"; function hasUnsafeAuthRedirectCharacter(value: string) { @@ -37,3 +41,20 @@ export function getTotpSecret(totpURI: string) { return ""; } } + +export function getDeleteTotpCode( + twoFactorEnabled: boolean, + confirmation: DeleteConfirmation | undefined, + resource: string, +) { + if (!twoFactorEnabled) return null; + + const totpCode = confirmation?.totpCode; + if (typeof totpCode !== "string" || !/^\d{6}$/.test(totpCode)) { + throw new Error( + `Authenticator code is required to delete this ${resource}`, + ); + } + + return totpCode; +} diff --git a/web/tests/two-factor.test.ts b/web/tests/two-factor.test.ts index 2a25755f..ed1cbeae 100644 --- a/web/tests/two-factor.test.ts +++ b/web/tests/two-factor.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_AUTH_REDIRECT, + type DeleteConfirmation, + getDeleteTotpCode, getSafeAuthRedirect, getTotpSecret, } from "@/lib/two-factor"; @@ -35,4 +37,24 @@ describe("two-factor helpers", () => { it("returns an empty secret for malformed TOTP URIs", () => { expect(getTotpSecret("not a uri")).toBe(""); }); + + it("requires a valid delete code only when 2FA is enabled", () => { + expect(getDeleteTotpCode(false, undefined, "project")).toBeNull(); + expect(() => getDeleteTotpCode(true, undefined, "project")).toThrow( + "Authenticator code is required to delete this project", + ); + expect(() => + getDeleteTotpCode(true, { totpCode: "12345" }, "project"), + ).toThrow("Authenticator code is required to delete this project"); + expect(() => + getDeleteTotpCode( + true, + { totpCode: 123456 } as unknown as DeleteConfirmation, + "project", + ), + ).toThrow("Authenticator code is required to delete this project"); + expect(getDeleteTotpCode(true, { totpCode: "123456" }, "project")).toBe( + "123456", + ); + }); });