Skip to content

Commit 987cb41

Browse files
authored
Merge pull request #3350 from Bima42/feat/3325-add-button-to-edit-certificates
feat: be able to edit certificate
2 parents eed36e5 + 199589d commit 987cb41

File tree

7 files changed

+185
-73
lines changed

7 files changed

+185
-73
lines changed

apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx renamed to apps/dokploy/components/dashboard/settings/certificates/handle-certificate.tsx

Lines changed: 100 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
2-
import { HelpCircle, PlusIcon } from "lucide-react";
2+
import { HelpCircle, PlusIcon, SquarePen } from "lucide-react";
33
import { useEffect, useState } from "react";
44
import { useForm } from "react-hook-form";
55
import { toast } from "sonner";
@@ -47,108 +47,157 @@ const certificateDataHolder =
4747
const privateKeyDataHolder =
4848
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
4949

50-
const addCertificate = z.object({
50+
const handleCertificateSchema = z.object({
5151
name: z.string().min(1, "Name is required"),
5252
certificateData: z.string().min(1, "Certificate data is required"),
5353
privateKey: z.string().min(1, "Private key is required"),
54-
autoRenew: z.boolean().optional(),
5554
serverId: z.string().optional(),
5655
});
5756

58-
type AddCertificate = z.infer<typeof addCertificate>;
57+
type HandleCertificateForm = z.infer<typeof handleCertificateSchema>;
5958

60-
export const AddCertificate = () => {
59+
interface Props {
60+
certificateId?: string;
61+
}
62+
63+
export const HandleCertificate = ({ certificateId }: Props) => {
6164
const [open, setOpen] = useState(false);
6265
const utils = api.useUtils();
6366

6467
const { data: isCloud } = api.settings.isCloud.useQuery();
65-
const { mutateAsync, isError, error, isPending } =
66-
api.certificates.create.useMutation();
6768
const { data: servers } = api.server.withSSHKey.useQuery();
6869
const hasServers = servers && servers.length > 0;
69-
// Show dropdown logic based on cloud environment
70-
// Cloud: show only if there are remote servers (no Dokploy option)
71-
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
72-
const shouldShowServerDropdown = hasServers;
70+
const shouldShowServerDropdown = hasServers && !certificateId; // Hide on edit
71+
72+
const { data: existingCert, refetch } = api.certificates.one.useQuery(
73+
{ certificateId: certificateId || "" },
74+
{ enabled: !!certificateId },
75+
);
7376

74-
const form = useForm<AddCertificate>({
77+
const createMutation = api.certificates.create.useMutation();
78+
const updateMutation = api.certificates.update.useMutation();
79+
const mutation = certificateId ? updateMutation : createMutation;
80+
const { mutateAsync, isError, error, isPending } = mutation;
81+
82+
const form = useForm<HandleCertificateForm>({
7583
defaultValues: {
7684
name: "",
7785
certificateData: "",
7886
privateKey: "",
79-
autoRenew: false,
8087
},
81-
resolver: zodResolver(addCertificate),
88+
resolver: zodResolver(handleCertificateSchema),
8289
});
90+
8391
useEffect(() => {
84-
form.reset();
85-
}, [form, form.formState.isSubmitSuccessful, form.reset]);
92+
if (existingCert) {
93+
form.reset({
94+
name: existingCert.name,
95+
certificateData: existingCert.certificateData,
96+
privateKey: existingCert.privateKey,
97+
});
98+
} else {
99+
form.reset({
100+
name: "",
101+
certificateData: "",
102+
privateKey: "",
103+
});
104+
}
105+
}, [existingCert, form, open]);
86106

87-
const onSubmit = async (data: AddCertificate) => {
88-
await mutateAsync({
107+
const onSubmit = async (data: HandleCertificateForm) => {
108+
const basePayload = {
89109
name: data.name,
90110
certificateData: data.certificateData,
91111
privateKey: data.privateKey,
92-
autoRenew: data.autoRenew,
93-
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
94-
organizationId: "",
95-
})
112+
};
113+
114+
const promise = certificateId
115+
? updateMutation.mutateAsync({
116+
certificateId,
117+
...basePayload,
118+
})
119+
: createMutation.mutateAsync({
120+
...basePayload,
121+
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
122+
organizationId: "",
123+
});
124+
125+
await promise
96126
.then(async () => {
97-
toast.success("Certificate Created");
127+
toast.success(
128+
certificateId ? "Certificate Updated" : "Certificate Created",
129+
);
98130
await utils.certificates.all.invalidate();
131+
if (certificateId) {
132+
refetch();
133+
}
99134
setOpen(false);
100135
})
101136
.catch(() => {
102-
toast.error("Error creating the Certificate");
137+
toast.error(
138+
certificateId
139+
? "Error updating the Certificate"
140+
: "Error creating the Certificate",
141+
);
103142
});
104143
};
144+
105145
return (
106146
<Dialog open={open} onOpenChange={setOpen}>
107-
<DialogTrigger className="" asChild>
108-
<Button>
109-
{" "}
110-
<PlusIcon className="h-4 w-4" />
111-
Add Certificate
112-
</Button>
147+
<DialogTrigger asChild>
148+
{certificateId ? (
149+
<Button
150+
variant="ghost"
151+
size="icon"
152+
className="group hover:bg-blue-500/10"
153+
>
154+
<SquarePen className="size-3.5 text-primary group-hover:text-blue-500" />
155+
</Button>
156+
) : (
157+
<Button>
158+
<PlusIcon className="h-4 w-4" />
159+
Add Certificate
160+
</Button>
161+
)}
113162
</DialogTrigger>
114163
<DialogContent className="sm:max-w-2xl">
115164
<DialogHeader>
116-
<DialogTitle>Add New Certificate</DialogTitle>
165+
<DialogTitle>
166+
{certificateId ? "Update" : "Add New"} Certificate
167+
</DialogTitle>
117168
<DialogDescription>
118-
Upload or generate a certificate to secure your application
169+
{certificateId
170+
? "Modify the certificate details"
171+
: "Upload or generate a certificate to secure your application"}
119172
</DialogDescription>
120173
</DialogHeader>
121174
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
122175

123176
<Form {...form}>
124177
<form
125-
id="hook-form-add-certificate"
178+
id="hook-form-handle-certificate"
126179
onSubmit={form.handleSubmit(onSubmit)}
127-
className="grid w-full gap-4 "
180+
className="grid w-full gap-4"
128181
>
129182
<FormField
130183
control={form.control}
131184
name="name"
132-
render={({ field }) => {
133-
return (
134-
<FormItem>
135-
<FormLabel>Certificate Name</FormLabel>
136-
<FormControl>
137-
<Input placeholder={"My Certificate"} {...field} />
138-
</FormControl>
139-
<FormMessage />
140-
</FormItem>
141-
);
142-
}}
185+
render={({ field }) => (
186+
<FormItem>
187+
<FormLabel>Certificate Name</FormLabel>
188+
<FormControl>
189+
<Input placeholder="My Certificate" {...field} />
190+
</FormControl>
191+
<FormMessage />
192+
</FormItem>
193+
)}
143194
/>
144195
<FormField
145196
control={form.control}
146197
name="certificateData"
147198
render={({ field }) => (
148199
<FormItem>
149-
<div className="space-y-0.5">
150-
<FormLabel>Certificate Data</FormLabel>
151-
</div>
200+
<FormLabel>Certificate Data</FormLabel>
152201
<FormControl>
153202
<Textarea
154203
className="h-32"
@@ -165,9 +214,7 @@ export const AddCertificate = () => {
165214
name="privateKey"
166215
render={({ field }) => (
167216
<FormItem>
168-
<div className="space-y-0.5">
169-
<FormLabel>Private Key</FormLabel>
170-
</div>
217+
<FormLabel>Private Key</FormLabel>
171218
<FormControl>
172219
<Textarea
173220
className="h-32"
@@ -248,10 +295,10 @@ export const AddCertificate = () => {
248295
<DialogFooter className="flex w-full flex-row !justify-end">
249296
<Button
250297
isLoading={isPending}
251-
form="hook-form-add-certificate"
298+
form="hook-form-handle-certificate"
252299
type="submit"
253300
>
254-
Create
301+
{certificateId ? "Update" : "Create"}
255302
</Button>
256303
</DialogFooter>
257304
</Form>

apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ChevronRight,
55
Link,
66
Loader2,
7+
Server,
78
ShieldCheck,
89
Trash2,
910
} from "lucide-react";
@@ -20,7 +21,7 @@ import {
2021
CardTitle,
2122
} from "@/components/ui/card";
2223
import { api } from "@/utils/api";
23-
import { AddCertificate } from "./add-certificate";
24+
import { HandleCertificate } from "./handle-certificate";
2425
import {
2526
extractLeafCommonName,
2627
getCertificateChainExpirationDetails,
@@ -69,7 +70,7 @@ export const ShowCertificates = () => {
6970
<span className="text-base text-muted-foreground text-center">
7071
You don't have any certificates created
7172
</span>
72-
{permissions?.certificate.create && <AddCertificate />}
73+
{permissions?.certificate.create && <HandleCertificate />}
7374
</div>
7475
) : (
7576
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -121,6 +122,12 @@ export const ShowCertificates = () => {
121122
CN: {commonName}
122123
</span>
123124
)}
125+
<span className="text-xs text-muted-foreground flex items-center gap-1">
126+
<Server className="size-3" />
127+
{certificate.server
128+
? `${certificate.server.name} (${certificate.server.ipAddress})`
129+
: "Dokploy (Local)"}
130+
</span>
124131
{chainInfo.isChain && (
125132
<div className="flex flex-col gap-1.5 mt-1">
126133
<button
@@ -181,8 +188,14 @@ export const ShowCertificates = () => {
181188
</div>
182189
</div>
183190

184-
{permissions?.certificate.delete && (
185-
<div className="flex flex-row gap-1">
191+
<div className="flex flex-row gap-1">
192+
{permissions?.certificate.update && (
193+
<HandleCertificate
194+
certificateId={certificate.certificateId}
195+
/>
196+
)}
197+
198+
{permissions?.certificate.delete && (
186199
<DialogAction
187200
title="Delete Certificate"
188201
description="Are you sure you want to delete this certificate?"
@@ -208,14 +221,14 @@ export const ShowCertificates = () => {
208221
<Button
209222
variant="ghost"
210223
size="icon"
211-
className="group hover:bg-red-500/10 "
224+
className="group hover:bg-red-500/10"
212225
isLoading={isRemoving}
213226
>
214227
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
215228
</Button>
216229
</DialogAction>
217-
</div>
218-
)}
230+
)}
231+
</div>
219232
</div>
220233
</div>
221234
);
@@ -224,7 +237,7 @@ export const ShowCertificates = () => {
224237

225238
{permissions?.certificate.create && (
226239
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
227-
<AddCertificate />
240+
<HandleCertificate />
228241
</div>
229242
)}
230243
</div>

apps/dokploy/components/dashboard/settings/certificates/utils.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@ export const extractExpirationDate = (certData: string): Date | null => {
3636
}
3737

3838
// Skip the outer certificate sequence
39-
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
39+
if (der[offset++] !== 0x30) return null;
4040
({ offset } = readLength(offset));
4141

4242
// Skip tbsCertificate sequence
43-
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
43+
if (der[offset++] !== 0x30) return null;
4444
({ offset } = readLength(offset));
4545

4646
// Check for optional version field (context-specific tag [0])
@@ -52,15 +52,14 @@ export const extractExpirationDate = (certData: string): Date | null => {
5252

5353
// Skip serialNumber, signature, issuer
5454
for (let i = 0; i < 3; i++) {
55-
if (der[offset] !== 0x30 && der[offset] !== 0x02)
56-
throw new Error("Unexpected structure");
55+
if (der[offset] !== 0x30 && der[offset] !== 0x02) return null;
5756
offset++;
5857
const fieldLen = readLength(offset);
5958
offset = fieldLen.offset + fieldLen.length;
6059
}
6160

6261
// Validity sequence (notBefore and notAfter)
63-
if (der[offset++] !== 0x30) throw new Error("Expected validity sequence");
62+
if (der[offset++] !== 0x30) return null;
6463
const validityLen = readLength(offset);
6564
offset = validityLen.offset;
6665

@@ -138,11 +137,11 @@ export const extractCommonName = (certData: string): string | null => {
138137
}
139138

140139
// Skip the outer certificate sequence
141-
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
140+
if (der[offset++] !== 0x30) return null;
142141
({ offset } = readLength(offset));
143142

144143
// Skip tbsCertificate sequence
145-
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
144+
if (der[offset++] !== 0x30) return null;
146145
({ offset } = readLength(offset));
147146

148147
// Check for optional version field (context-specific tag [0])
@@ -165,7 +164,7 @@ export const extractCommonName = (certData: string): string | null => {
165164
offset = skipField(offset);
166165

167166
// Subject sequence - where we find the CN
168-
if (der[offset++] !== 0x30) throw new Error("Expected subject sequence");
167+
if (der[offset++] !== 0x30) return null;
169168
const subjectLen = readLength(offset);
170169
const subjectEnd = subjectLen.offset + subjectLen.length;
171170
offset = subjectLen.offset;

0 commit comments

Comments
 (0)