Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { HelpCircle, PlusIcon } from "lucide-react";
import { HelpCircle, PlusIcon, SquarePen } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
Expand Down Expand Up @@ -47,108 +47,151 @@ const certificateDataHolder =
const privateKeyDataHolder =
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";

const addCertificate = z.object({
const handleCertificateSchema = z.object({
name: z.string().min(1, "Name is required"),
certificateData: z.string().min(1, "Certificate data is required"),
privateKey: z.string().min(1, "Private key is required"),
autoRenew: z.boolean().optional(),
serverId: z.string().optional(),
});

type AddCertificate = z.infer<typeof addCertificate>;
type HandleCertificateForm = z.infer<typeof handleCertificateSchema>;

export const AddCertificate = () => {
interface Props {
certificateId?: string;
}

export const HandleCertificate = ({ certificateId }: Props) => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();

const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync, isError, error, isLoading } =
api.certificates.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery();
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const shouldShowServerDropdown = hasServers && !certificateId; // Hide on edit

const { data: existingCert, refetch } = api.certificates.one.useQuery(
{ certificateId: certificateId || "" },
{ enabled: !!certificateId },
);

const { mutateAsync, isError, error, isLoading } = certificateId
? api.certificates.update.useMutation()
: api.certificates.create.useMutation();

const form = useForm<AddCertificate>({
const form = useForm<HandleCertificateForm>({
defaultValues: {
name: "",
certificateData: "",
privateKey: "",
autoRenew: false,
},
resolver: zodResolver(addCertificate),
resolver: zodResolver(handleCertificateSchema),
});

useEffect(() => {
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
if (existingCert) {
form.reset({
name: existingCert.name,
certificateData: existingCert.certificateData,
privateKey: existingCert.privateKey,
autoRenew: existingCert.autoRenew ?? false,
});
} else {
form.reset({
name: "",
certificateData: "",
privateKey: "",
autoRenew: false,
});
}
}, [existingCert, form, open]);

const onSubmit = async (data: AddCertificate) => {
const onSubmit = async (data: HandleCertificateForm) => {
await mutateAsync({
...(certificateId ? { certificateId } : {}),
name: data.name,
certificateData: data.certificateData,
privateKey: data.privateKey,
autoRenew: data.autoRenew,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
organizationId: "",
})
} as any)
.then(async () => {
toast.success("Certificate Created");
toast.success(
certificateId ? "Certificate Updated" : "Certificate Created",
);
await utils.certificates.all.invalidate();
if (certificateId) {
refetch();
}
setOpen(false);
})
.catch(() => {
toast.error("Error creating the Certificate");
toast.error(
certificateId
? "Error updating the Certificate"
: "Error creating the Certificate",
);
});
};

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild>
<Button>
{" "}
<PlusIcon className="h-4 w-4" />
Add Certificate
</Button>
<DialogTrigger asChild>
{certificateId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<SquarePen className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button>
<PlusIcon className="h-4 w-4" />
Add Certificate
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add New Certificate</DialogTitle>
<DialogTitle>
{certificateId ? "Update" : "Add New"} Certificate
</DialogTitle>
<DialogDescription>
Upload or generate a certificate to secure your application
{certificateId
? "Modify the certificate details"
: "Upload or generate a certificate to secure your application"}
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}

<Form {...form}>
<form
id="hook-form-add-certificate"
id="hook-form-handle-certificate"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Certificate Name</FormLabel>
<FormControl>
<Input placeholder={"My Certificate"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
render={({ field }) => (
<FormItem>
<FormLabel>Certificate Name</FormLabel>
<FormControl>
<Input placeholder="My Certificate" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="certificateData"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Certificate Data</FormLabel>
</div>
<FormLabel>Certificate Data</FormLabel>
<FormControl>
<Textarea
className="h-32"
Expand All @@ -165,9 +208,7 @@ export const AddCertificate = () => {
name="privateKey"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Private Key</FormLabel>
</div>
<FormLabel>Private Key</FormLabel>
<FormControl>
<Textarea
className="h-32"
Expand All @@ -179,6 +220,23 @@ export const AddCertificate = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="autoRenew"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Auto-renew</FormLabel>
<FormMessage />
</FormItem>
)}
/>
{shouldShowServerDropdown && (
<FormField
control={form.control}
Expand Down Expand Up @@ -248,10 +306,10 @@ export const AddCertificate = () => {
<DialogFooter className="flex w-full flex-row !justify-end">
<Button
isLoading={isLoading}
form="hook-form-add-certificate"
form="hook-form-handle-certificate"
type="submit"
>
Create
{certificateId ? "Update" : "Create"}
</Button>
</DialogFooter>
</Form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { AddCertificate } from "./add-certificate";
import { HandleCertificate } from "./handle-certificate";
import { getCertificateChainInfo, getExpirationStatus } from "./utils";

export const ShowCertificates = () => {
Expand Down Expand Up @@ -53,7 +53,7 @@ export const ShowCertificates = () => {
<span className="text-base text-muted-foreground text-center">
You don't have any certificates created
</span>
<AddCertificate />
<HandleCertificate />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
Expand Down Expand Up @@ -102,6 +102,10 @@ export const ShowCertificates = () => {
</div>

<div className="flex flex-row gap-1">
<HandleCertificate
certificateId={certificate.certificateId}
/>

<DialogAction
title="Delete Certificate"
description="Are you sure you want to delete this certificate?"
Expand All @@ -126,7 +130,7 @@ export const ShowCertificates = () => {
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
Expand All @@ -140,7 +144,7 @@ export const ShowCertificates = () => {
</div>

<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<AddCertificate />
<HandleCertificate />
</div>
</div>
)}
Expand Down
19 changes: 19 additions & 0 deletions apps/dokploy/server/api/routers/certificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
findCertificateById,
IS_CLOUD,
removeCertificateById,
updateCertificate,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
Expand All @@ -11,6 +12,7 @@ import { db } from "@/server/db";
import {
apiCreateCertificate,
apiFindCertificate,
apiUpdateCertificate,
certificates,
} from "@/server/db/schema";

Expand Down Expand Up @@ -57,4 +59,21 @@ export const certificateRouter = createTRPCRouter({
where: eq(certificates.organizationId, ctx.session.activeOrganizationId),
});
}),
update: adminProcedure
.input(apiUpdateCertificate)
.mutation(async ({ input, ctx }) => {
const certificate = await findCertificateById(input.certificateId);
if (certificate.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to update this certificate",
});
}
return await updateCertificate(input.certificateId, {
name: input.name,
certificateData: input.certificateData,
privateKey: input.privateKey,
autoRenew: input.autoRenew,
});
}),
});
36 changes: 36 additions & 0 deletions packages/server/src/services/certificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,39 @@ const createCertificateFiles = async (certificate: Certificate) => {
fs.writeFileSync(configFile, yamlConfig);
}
};

export const updateCertificate = async (
certificateId: string,
updates: {
name?: string;
certificateData?: string;
privateKey?: string;
autoRenew?: boolean;
},
) => {
const existing = await findCertificateById(certificateId);

const updated = await db
.update(certificates)
.set({
...updates,
})
.where(eq(certificates.certificateId, certificateId))
.returning();

if (!updated || updated[0] === undefined) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Failed to update the certificate",
});
}

const cert = updated[0];

// If cert data or private key changed, rewrite files
if (updates.certificateData || updates.privateKey) {
await createCertificateFiles(cert);
}

return cert;
};