Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 10 additions & 37 deletions web/actions/projects.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand All @@ -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}$/;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 8 additions & 3 deletions web/actions/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(
Expand All @@ -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 (
Expand Down Expand Up @@ -159,121 +100,55 @@ export default function ConfigurationPage() {
: "Once deleted, this service and all its deployments will be permanently removed."}
</p>
</ItemContent>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={(open) => {
if (isDeleting) return;
setDeleteDialogOpen(open);
if (!open) resetDeleteConfirmation();
}}
>
<AlertDialogTrigger render={<Button variant="destructive" />}>
Delete Service
</AlertDialogTrigger>
<AlertDialogContent className="sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>Delete {service.name}?</AlertDialogTitle>
<AlertDialogDescription>
{service.stateful ? (
<>
This starts a backup-first delete workflow. The service
will be restorable from Deleted services until its
retention window expires.
{willReuseCompletedBackups &&
hasCompletedBackupForEveryVolume && (
<>
<br />
<br />
<span className="font-medium text-foreground">
This service is not currently running.
</span>{" "}
Restore will use the latest completed backups for
its volumes. The oldest selected backup is from{" "}
<LocalDate
value={
service.deletionBackupFallback
?.oldestLatestBackupAt
}
fallback="an unknown time"
/>
; changes after that backup will not be restored.
</>
)}
{willReuseCompletedBackups &&
!hasCompletedBackupForEveryVolume && (
<>
<br />
<br />
<span className="font-medium text-destructive">
No completed backup is available for every
volume.
</span>{" "}
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 && (
<>
<br />
<br />
Enter your authenticator code to confirm this deletion.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
{requiresDeleteConfirmation && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="delete-service-totp-code">
Authenticator code
</Label>
<InputOTP
id="delete-service-totp-code"
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
inputMode="numeric"
autoComplete="one-time-code"
value={deleteTotpCode}
onChange={(value) =>
setDeleteTotpCode(value.replace(/\D/g, ""))
}
disabled={isDeleting}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={
isDeleting ||
isSessionLoading ||
isDeleteConfirmationIncomplete
}
>
{isDeleting ? <Spinner className="size-4" /> : null}
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmationDialog
resourceName={service.name}
triggerLabel="Delete Service"
description={
service.stateful ? (
<>
This starts a backup-first delete workflow. The service will
be restorable from Deleted services until its retention
window expires.
{willReuseCompletedBackups &&
hasCompletedBackupForEveryVolume && (
<>
<br />
<br />
<span className="font-medium text-foreground">
This service is not currently running.
</span>{" "}
Restore will use the latest completed backups for its
volumes. The oldest selected backup is from{" "}
<LocalDate
value={
service.deletionBackupFallback
?.oldestLatestBackupAt
}
fallback="an unknown time"
/>
; changes after that backup will not be restored.
</>
)}
{willReuseCompletedBackups &&
!hasCompletedBackupForEveryVolume && (
<>
<br />
<br />
<span className="font-medium text-destructive">
No completed backup is available for every volume.
</span>{" "}
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}
/>
</Item>
</div>
</div>
Expand Down
Loading
Loading