Merge pull request #3350 from Bima42/feat/3325-add-button-to-edit-certificates

feat: be able to edit certificate
This commit is contained in:
Mauricio Siu
2026-04-03 23:50:55 -06:00
committed by GitHub
7 changed files with 186 additions and 74 deletions

View File

@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, PlusIcon } from "lucide-react"; import { HelpCircle, PlusIcon, SquarePen } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -47,108 +47,157 @@ const certificateDataHolder =
const privateKeyDataHolder = const privateKeyDataHolder =
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----"; "-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
const addCertificate = z.object({ const handleCertificateSchema = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
certificateData: z.string().min(1, "Certificate data is required"), certificateData: z.string().min(1, "Certificate data is required"),
privateKey: z.string().min(1, "Private key is required"), privateKey: z.string().min(1, "Private key is required"),
autoRenew: z.boolean().optional(),
serverId: z.string().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 [open, setOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync, isError, error, isPending } =
api.certificates.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery();
const hasServers = servers && servers.length > 0; const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment const shouldShowServerDropdown = hasServers && !certificateId; // Hide on edit
// 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 form = useForm<AddCertificate>({ const { data: existingCert, refetch } = api.certificates.one.useQuery(
{ certificateId: certificateId || "" },
{ enabled: !!certificateId },
);
const createMutation = api.certificates.create.useMutation();
const updateMutation = api.certificates.update.useMutation();
const mutation = certificateId ? updateMutation : createMutation;
const { mutateAsync, isError, error, isPending } = mutation;
const form = useForm<HandleCertificateForm>({
defaultValues: { defaultValues: {
name: "", name: "",
certificateData: "", certificateData: "",
privateKey: "", privateKey: "",
autoRenew: false,
}, },
resolver: zodResolver(addCertificate), resolver: zodResolver(handleCertificateSchema),
}); });
useEffect(() => {
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (data: AddCertificate) => { useEffect(() => {
await mutateAsync({ if (existingCert) {
form.reset({
name: existingCert.name,
certificateData: existingCert.certificateData,
privateKey: existingCert.privateKey,
});
} else {
form.reset({
name: "",
certificateData: "",
privateKey: "",
});
}
}, [existingCert, form, open]);
const onSubmit = async (data: HandleCertificateForm) => {
const basePayload = {
name: data.name, name: data.name,
certificateData: data.certificateData, certificateData: data.certificateData,
privateKey: data.privateKey, privateKey: data.privateKey,
autoRenew: data.autoRenew, };
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
organizationId: "", const promise = certificateId
}) ? updateMutation.mutateAsync({
certificateId,
...basePayload,
})
: createMutation.mutateAsync({
...basePayload,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
organizationId: "",
});
await promise
.then(async () => { .then(async () => {
toast.success("Certificate Created"); toast.success(
certificateId ? "Certificate Updated" : "Certificate Created",
);
await utils.certificates.all.invalidate(); await utils.certificates.all.invalidate();
if (certificateId) {
refetch();
}
setOpen(false); setOpen(false);
}) })
.catch(() => { .catch(() => {
toast.error("Error creating the Certificate"); toast.error(
certificateId
? "Error updating the Certificate"
: "Error creating the Certificate",
);
}); });
}; };
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild> <DialogTrigger asChild>
<Button> {certificateId ? (
{" "} <Button
<PlusIcon className="h-4 w-4" /> variant="ghost"
Add Certificate size="icon"
</Button> 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> </DialogTrigger>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Add New Certificate</DialogTitle> <DialogTitle>
{certificateId ? "Update" : "Add New"} Certificate
</DialogTitle>
<DialogDescription> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}> <Form {...form}>
<form <form
id="hook-form-add-certificate" id="hook-form-handle-certificate"
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 " className="grid w-full gap-4"
> >
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => { render={({ field }) => (
return ( <FormItem>
<FormItem> <FormLabel>Certificate Name</FormLabel>
<FormLabel>Certificate Name</FormLabel> <FormControl>
<FormControl> <Input placeholder="My Certificate" {...field} />
<Input placeholder={"My Certificate"} {...field} /> </FormControl>
</FormControl> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
);
}}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="certificateData" name="certificateData"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="space-y-0.5"> <FormLabel>Certificate Data</FormLabel>
<FormLabel>Certificate Data</FormLabel>
</div>
<FormControl> <FormControl>
<Textarea <Textarea
className="h-32" className="h-32"
@@ -165,9 +214,7 @@ export const AddCertificate = () => {
name="privateKey" name="privateKey"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="space-y-0.5"> <FormLabel>Private Key</FormLabel>
<FormLabel>Private Key</FormLabel>
</div>
<FormControl> <FormControl>
<Textarea <Textarea
className="h-32" className="h-32"
@@ -248,10 +295,10 @@ export const AddCertificate = () => {
<DialogFooter className="flex w-full flex-row !justify-end"> <DialogFooter className="flex w-full flex-row !justify-end">
<Button <Button
isLoading={isPending} isLoading={isPending}
form="hook-form-add-certificate" form="hook-form-handle-certificate"
type="submit" type="submit"
> >
Create {certificateId ? "Update" : "Create"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</Form> </Form>

View File

@@ -4,6 +4,7 @@ import {
ChevronRight, ChevronRight,
Link, Link,
Loader2, Loader2,
Server,
ShieldCheck, ShieldCheck,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
@@ -20,7 +21,7 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { AddCertificate } from "./add-certificate"; import { HandleCertificate } from "./handle-certificate";
import { import {
extractLeafCommonName, extractLeafCommonName,
getCertificateChainExpirationDetails, getCertificateChainExpirationDetails,
@@ -69,7 +70,7 @@ export const ShowCertificates = () => {
<span className="text-base text-muted-foreground text-center"> <span className="text-base text-muted-foreground text-center">
You don't have any certificates created You don't have any certificates created
</span> </span>
{permissions?.certificate.create && <AddCertificate />} {permissions?.certificate.create && <HandleCertificate />}
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4 min-h-[25vh]"> <div className="flex flex-col gap-4 min-h-[25vh]">
@@ -121,6 +122,12 @@ export const ShowCertificates = () => {
CN: {commonName} CN: {commonName}
</span> </span>
)} )}
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Server className="size-3" />
{certificate.server
? `${certificate.server.name} (${certificate.server.ipAddress})`
: "Dokploy (Local)"}
</span>
{chainInfo.isChain && ( {chainInfo.isChain && (
<div className="flex flex-col gap-1.5 mt-1"> <div className="flex flex-col gap-1.5 mt-1">
<button <button
@@ -181,8 +188,14 @@ export const ShowCertificates = () => {
</div> </div>
</div> </div>
{permissions?.certificate.delete && ( <div className="flex flex-row gap-1">
<div className="flex flex-row gap-1"> {permissions?.certificate.update && (
<HandleCertificate
certificateId={certificate.certificateId}
/>
)}
{permissions?.certificate.delete && (
<DialogAction <DialogAction
title="Delete Certificate" title="Delete Certificate"
description="Are you sure you want to delete this certificate?" description="Are you sure you want to delete this certificate?"
@@ -208,14 +221,14 @@ export const ShowCertificates = () => {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="group hover:bg-red-500/10 " className="group hover:bg-red-500/10"
isLoading={isRemoving} isLoading={isRemoving}
> >
<Trash2 className="size-4 text-primary group-hover:text-red-500" /> <Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button> </Button>
</DialogAction> </DialogAction>
</div> )}
)} </div>
</div> </div>
</div> </div>
); );
@@ -224,7 +237,7 @@ export const ShowCertificates = () => {
{permissions?.certificate.create && ( {permissions?.certificate.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4"> <div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<AddCertificate /> <HandleCertificate />
</div> </div>
)} )}
</div> </div>

View File

@@ -36,11 +36,11 @@ export const extractExpirationDate = (certData: string): Date | null => {
} }
// Skip the outer certificate sequence // Skip the outer certificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected sequence"); if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset)); ({ offset } = readLength(offset));
// Skip tbsCertificate sequence // Skip tbsCertificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate"); if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset)); ({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0]) // Check for optional version field (context-specific tag [0])
@@ -52,15 +52,14 @@ export const extractExpirationDate = (certData: string): Date | null => {
// Skip serialNumber, signature, issuer // Skip serialNumber, signature, issuer
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
if (der[offset] !== 0x30 && der[offset] !== 0x02) if (der[offset] !== 0x30 && der[offset] !== 0x02) return null;
throw new Error("Unexpected structure");
offset++; offset++;
const fieldLen = readLength(offset); const fieldLen = readLength(offset);
offset = fieldLen.offset + fieldLen.length; offset = fieldLen.offset + fieldLen.length;
} }
// Validity sequence (notBefore and notAfter) // Validity sequence (notBefore and notAfter)
if (der[offset++] !== 0x30) throw new Error("Expected validity sequence"); if (der[offset++] !== 0x30) return null;
const validityLen = readLength(offset); const validityLen = readLength(offset);
offset = validityLen.offset; offset = validityLen.offset;
@@ -138,11 +137,11 @@ export const extractCommonName = (certData: string): string | null => {
} }
// Skip the outer certificate sequence // Skip the outer certificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected sequence"); if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset)); ({ offset } = readLength(offset));
// Skip tbsCertificate sequence // Skip tbsCertificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate"); if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset)); ({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0]) // Check for optional version field (context-specific tag [0])
@@ -165,7 +164,7 @@ export const extractCommonName = (certData: string): string | null => {
offset = skipField(offset); offset = skipField(offset);
// Subject sequence - where we find the CN // Subject sequence - where we find the CN
if (der[offset++] !== 0x30) throw new Error("Expected subject sequence"); if (der[offset++] !== 0x30) return null;
const subjectLen = readLength(offset); const subjectLen = readLength(offset);
const subjectEnd = subjectLen.offset + subjectLen.length; const subjectEnd = subjectLen.offset + subjectLen.length;
offset = subjectLen.offset; offset = subjectLen.offset;

View File

@@ -3,6 +3,7 @@ import {
findCertificateById, findCertificateById,
IS_CLOUD, IS_CLOUD,
removeCertificateById, removeCertificateById,
updateCertificate,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -12,6 +13,7 @@ import { audit } from "@/server/api/utils/audit";
import { import {
apiCreateCertificate, apiCreateCertificate,
apiFindCertificate, apiFindCertificate,
apiUpdateCertificate,
certificates, certificates,
} from "@/server/db/schema"; } from "@/server/db/schema";
@@ -72,6 +74,25 @@ export const certificateRouter = createTRPCRouter({
all: withPermission("certificate", "read").query(async ({ ctx }) => { all: withPermission("certificate", "read").query(async ({ ctx }) => {
return await db.query.certificates.findMany({ return await db.query.certificates.findMany({
where: eq(certificates.organizationId, ctx.session.activeOrganizationId), where: eq(certificates.organizationId, ctx.session.activeOrganizationId),
with: {
server: true,
},
}); });
}), }),
update: withPermission("certificate", "update")
.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,
});
}),
}); });

View File

@@ -56,7 +56,6 @@ export const apiUpdateCertificate = z.object({
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
certificateData: z.string().min(1).optional(), certificateData: z.string().min(1).optional(),
privateKey: z.string().min(1).optional(), privateKey: z.string().min(1).optional(),
autoRenew: z.boolean().optional(),
}); });
export const apiDeleteCertificate = z.object({ export const apiDeleteCertificate = z.object({

View File

@@ -37,7 +37,7 @@ export const statements = {
environmentEnvVars: ["read", "write"], environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"], server: ["read", "create", "delete"],
registry: ["read", "create", "delete"], registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"], certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"], backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"], volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"], schedule: ["read", "create", "update", "delete"],
@@ -102,7 +102,7 @@ export const ownerRole = ac.newRole({
environmentEnvVars: ["read", "write"], environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"], server: ["read", "create", "delete"],
registry: ["read", "create", "delete"], registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"], certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"], backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"], volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"], schedule: ["read", "create", "update", "delete"],
@@ -139,7 +139,7 @@ export const adminRole = ac.newRole({
environmentEnvVars: ["read", "write"], environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"], server: ["read", "create", "delete"],
registry: ["read", "create", "delete"], registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"], certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"], backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"], volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"], schedule: ["read", "create", "update", "delete"],

View File

@@ -126,3 +126,36 @@ const createCertificateFiles = async (certificate: Certificate) => {
fs.writeFileSync(configFile, yamlConfig); fs.writeFileSync(configFile, yamlConfig);
} }
}; };
export const updateCertificate = async (
certificateId: string,
updates: {
name?: string;
certificateData?: string;
privateKey?: string;
},
) => {
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;
};