diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 828a2575..a384a25f 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -1,9 +1,11 @@ "use server"; import { randomUUID } from "node:crypto"; +import { isAPIError } from "better-auth/api"; import cronstrue from "cronstrue"; import { and, desc, eq, inArray, isNotNull, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; import { ZodError, z } from "zod"; import { db } from "@/db"; import { @@ -28,7 +30,7 @@ import { volumeBackups, workQueue, } from "@/db/schema"; -import { requireDeveloperRole } from "@/lib/auth"; +import { auth, requireDeveloperRole } from "@/lib/auth"; import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants"; import { deployServiceInternal } from "@/lib/deploy-service"; import { @@ -47,15 +49,19 @@ import { nameSchema, volumeNameSchema, } from "@/lib/schemas"; -import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config"; import type { PortConfig, HealthCheckConfig as ServiceHealthCheckConfig, } from "@/lib/service-config"; +import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config"; import { getZodErrorMessage, slugify } from "@/lib/utils"; import { enqueueWork } from "@/lib/work-queue"; import { deleteBackup } from "./backups"; +type ServiceDeleteConfirmation = { + totpCode?: string; +}; + function isValidImageReferencePart(reference: string): boolean { const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; const digestPattern = /^[A-Za-z0-9_+.-]+:[0-9a-fA-F]{32,256}$/; @@ -574,8 +580,39 @@ async function hardDeleteService(serviceId: string) { return { success: true }; } -export async function deleteService(serviceId: string) { - await requireDeveloperRole(); +export async function deleteService( + serviceId: string, + confirmation?: ServiceDeleteConfirmation, +) { + const session = await requireDeveloperRole(); + if (!session) { + throw new Error("Unauthorized"); + } + + const twoFactorEnabled = Boolean( + (session.user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled, + ); + + if (twoFactorEnabled) { + const totpCode = confirmation?.totpCode ?? ""; + + if (!/^\d{6}$/.test(totpCode)) { + throw new Error("Authenticator code is required to delete this service"); + } + + try { + await auth.api.verifyTOTP({ + body: { code: totpCode }, + headers: await headers(), + }); + } catch (error) { + if (isAPIError(error)) { + throw new Error("Invalid authenticator code"); + } + throw error; + } + } + const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx index 77700a12..138c0c28 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { REGEXP_ONLY_DIGITS } from "input-otp"; import { Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useState } from "react"; @@ -30,10 +31,22 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { useSession } from "@/lib/auth-client"; const ACTIVE_DELETE_BACKUP_STATUSES = ["running", "healthy"] as const; +type TwoFactorSessionUser = { + twoFactorEnabled?: boolean | null; +}; + function formatBackupDate(value: Date | string | null | undefined) { if (!value) return "an unknown time"; return new Date(value).toLocaleString(); @@ -42,8 +55,15 @@ function formatBackupDate(value: Date | string | null | undefined) { export default function ConfigurationPage() { const router = useRouter(); const { mutate: globalMutate } = useSWRConfig(); + const { data: session, isPending: isSessionLoading } = useSession(); + const sessionUser = session?.user as TwoFactorSessionUser | undefined; const { service, projectSlug, envName, proxyDomain, onUpdate } = useService(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteTotpCode, setDeleteTotpCode] = useState(""); const [isDeleting, setIsDeleting] = useState(false); + const requiresDeleteConfirmation = Boolean(sessionUser?.twoFactorEnabled); + const isDeleteConfirmationIncomplete = + requiresDeleteConfirmation && deleteTotpCode.length !== 6; const hasActiveDeploymentForBackup = service.deployments.some( (deployment) => ACTIVE_DELETE_BACKUP_STATUSES.includes( @@ -63,15 +83,38 @@ export default function ConfigurationPage() { toast.info("Changes saved. Deploy to apply them."); }, [onUpdate]); + const resetDeleteConfirmation = useCallback(() => { + setDeleteTotpCode(""); + }, []); + const handleDelete = async () => { + if (isDeleteConfirmationIncomplete) { + toast.error("Enter your 6-digit authenticator code"); + return; + } + setIsDeleting(true); try { - await deleteService(service.id); + await deleteService( + service.id, + requiresDeleteConfirmation + ? { + totpCode: deleteTotpCode, + } + : undefined, + ); await globalMutate(`/api/projects/${service.projectId}/services`); toast.success( service.stateful ? "Delete workflow started" : "Service deleted", ); + setDeleteDialogOpen(false); + resetDeleteConfirmation(); router.push(`/dashboard/projects/${projectSlug}/${envName}`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete service", + ); + resetDeleteConfirmation(); } finally { setIsDeleting(false); } @@ -120,11 +163,18 @@ export default function ConfigurationPage() { : "Once deleted, this service and all its deployments will be permanently removed."}

- + { + if (isDeleting) return; + setDeleteDialogOpen(open); + if (!open) resetDeleteConfirmation(); + }} + > }> Delete Service - + Delete {service.name}? @@ -167,15 +217,59 @@ export default function ConfigurationPage() { ) : ( "This action cannot be undone. This will permanently delete the service and all its deployments." )} + {requiresDeleteConfirmation && ( + <> +
+
+ Enter your authenticator code to confirm this deletion. + + )}
+ {requiresDeleteConfirmation && ( +
+
+ + + setDeleteTotpCode(value.replace(/\D/g, "")) + } + disabled={isDeleting} + > + + + + + + + + + +
+
+ )} - Cancel + + Cancel + + {isDeleting ? : null} {isDeleting ? "Deleting..." : "Delete"} 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/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() {