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
45 changes: 41 additions & 4 deletions web/actions/projects.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand All @@ -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}$/;
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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(
Expand All @@ -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);
}
Expand Down Expand Up @@ -120,11 +163,18 @@ export default function ConfigurationPage() {
: "Once deleted, this service and all its deployments will be permanently removed."}
</p>
</ItemContent>
<AlertDialog>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={(open) => {
if (isDeleting) return;
setDeleteDialogOpen(open);
if (!open) resetDeleteConfirmation();
}}
>
<AlertDialogTrigger render={<Button variant="destructive" />}>
Delete Service
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogContent className="sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>Delete {service.name}?</AlertDialogTitle>
<AlertDialogDescription>
Expand Down Expand Up @@ -167,15 +217,59 @@ export default function ConfigurationPage() {
) : (
"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>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
disabled={
isDeleting ||
isSessionLoading ||
isDeleteConfirmationIncomplete
}
>
{isDeleting ? <Spinner className="size-4" /> : null}
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
Expand Down
17 changes: 15 additions & 2 deletions web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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% {
Expand All @@ -85,6 +86,18 @@
transform: translateX(300%);
}
}

@keyframes caret-blink {
0%,
70%,
100% {
opacity: 1;
}
20%,
50% {
opacity: 0;
}
}
}

@layer theme {
Expand Down
56 changes: 45 additions & 11 deletions web/components/auth/two-factor-challenge-page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -145,16 +151,39 @@ export function TwoFactorChallengePage() {
<Label htmlFor="two-factor-code">
{mode === "totp" ? "Authenticator code" : "Backup code"}
</Label>
<Input
id="two-factor-code"
inputMode={mode === "totp" ? "numeric" : "text"}
autoComplete="one-time-code"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder={mode === "totp" ? "123456" : "XXXX-XXXX"}
required
autoFocus
/>
{mode === "totp" ? (
<InputOTP
id="two-factor-code"
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
inputMode="numeric"
autoComplete="one-time-code"
value={code}
onChange={(value) => setCode(value.replace(/\D/g, ""))}
required
autoFocus
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
) : (
<Input
id="two-factor-code"
inputMode="text"
autoComplete="one-time-code"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="XXXX-XXXX"
required
autoFocus
/>
)}
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div>
Expand All @@ -174,7 +203,12 @@ export function TwoFactorChallengePage() {
<Button
type="submit"
className="w-full"
disabled={loading || normalizedCode.length === 0}
disabled={
loading ||
(mode === "totp"
? normalizedCode.length !== 6
: normalizedCode.length === 0)
}
>
{loading ? <Spinner className="size-4" /> : null}
Verify
Expand Down
Loading
Loading