From 0789892b8aa1fc37b5ed440f4233458b18f405a1 Mon Sep 17 00:00:00 2001 From: Leon Bojanowski Date: Thu, 11 Jun 2026 13:48:06 +0200 Subject: [PATCH 1/2] Allow updating custom certificates from the UI --- backend/internal/certificate.js | 16 +- frontend/src/hooks/useCertificate.ts | 17 +- .../src/modals/CustomCertificateModal.tsx | 372 ++++++++++-------- frontend/src/pages/Certificates/Table.tsx | 22 +- .../src/pages/Certificates/TableWrapper.tsx | 3 +- test/cypress/e2e/api/Certificates.cy.js | 49 ++- 6 files changed, 288 insertions(+), 191 deletions(-) diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 6498422c61..f230df587c 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -595,7 +595,10 @@ const internalCertificate = { * @returns {Promise} */ upload: async (access, data) => { - const row = await internalCertificate.get(access, { id: data.id }); + const row = await internalCertificate.get(access, { + id: data.id, + expand: ["proxy_hosts", "redirection_hosts", "dead_hosts", "streams"], + }); if (row.provider !== "other") { throw new error.ValidationError("Cannot upload certificates for this type of provider"); } @@ -620,6 +623,17 @@ const internalCertificate = { certificate.meta = row.meta; await internalCertificate.writeCustomCert(certificate); + + // Reload nginx when the certificate is in use, so the new files are served + const inUseCount = + (row.proxy_hosts?.length || 0) + + (row.redirection_hosts?.length || 0) + + (row.dead_hosts?.length || 0) + + (row.streams?.length || 0); + if (inUseCount > 0) { + await internalNginx.reload(); + } + return _.pick(row.meta, internalCertificate.allowedSslFiles); }, diff --git a/frontend/src/hooks/useCertificate.ts b/frontend/src/hooks/useCertificate.ts index fc99c8402b..e943da89d6 100644 --- a/frontend/src/hooks/useCertificate.ts +++ b/frontend/src/hooks/useCertificate.ts @@ -1,11 +1,24 @@ import { useQuery } from "@tanstack/react-query"; import { type Certificate, getCertificate } from "src/api/backend"; -const fetchCertificate = (id: number) => { +const fetchCertificate = (id: number | "new") => { + if (id === "new") { + return Promise.resolve({ + id: 0, + createdOn: "", + modifiedOn: "", + ownerUserId: 0, + provider: "", + niceName: "", + domainNames: [], + expiresOn: "", + meta: {}, + } as Certificate); + } return getCertificate(id, ["owner"]); }; -const useCertificate = (id: number, options = {}) => { +const useCertificate = (id: number | "new", options = {}) => { return useQuery({ queryKey: ["certificate", id], queryFn: () => fetchCertificate(id), diff --git a/frontend/src/modals/CustomCertificateModal.tsx b/frontend/src/modals/CustomCertificateModal.tsx index deab1c5231..47c7013d63 100644 --- a/frontend/src/modals/CustomCertificateModal.tsx +++ b/frontend/src/modals/CustomCertificateModal.tsx @@ -6,17 +6,22 @@ import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; import { type Certificate, createCertificate, uploadCertificate, validateCertificate } from "src/api/backend"; -import { Button } from "src/components"; +import { Button, Loading } from "src/components"; +import { useCertificate } from "src/hooks"; import { T } from "src/locale"; import { validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; -const showCustomCertificateModal = () => { - EasyModal.show(CustomCertificateModal); +const showCustomCertificateModal = (id: number | "new") => { + EasyModal.show(CustomCertificateModal, { id }); }; -const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModalProps) => { +interface Props extends InnerModalProps { + id: number | "new"; +} +const CustomCertificateModal = EasyModal.create(({ id, visible, remove }: Props) => { const queryClient = useQueryClient(); + const { data, isLoading, error } = useCertificate(id); const [errorMsg, setErrorMsg] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -38,11 +43,15 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal // Validate await validateCertificate(formData); - // Create certificate, as other without anything else - const cert = await createCertificate({ niceName, provider } as Certificate); + let certId = data?.id; + if (!certId) { + // Create certificate, as other without anything else + const cert = await createCertificate({ niceName, provider } as Certificate); + certId = cert.id; + } - // Upload the certificates to the created certificate - await uploadCertificate(cert.id, formData); + // Upload the certificates to the new or existing certificate + await uploadCertificate(certId, formData); // Success showObjectSuccess("certificate", "saved"); @@ -52,179 +61,194 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal } queryClient.invalidateQueries({ queryKey: ["certificates"] }); + if (data?.id) { + queryClient.invalidateQueries({ queryKey: ["certificate", data.id] }); + } setIsSubmitting(false); setSubmitting(false); }; return ( - - {() => ( -
- - - - - - - setErrorMsg(null)} dismissible> - {errorMsg} - -
-
-

- - -

- - {({ field, form }: any) => ( -
- - - {form.errors.niceName ? ( -
- {form.errors.niceName && form.touched.niceName - ? form.errors.niceName - : null} -
- ) : null} -
- )} -
- - {({ field, form }: any) => ( -
- - { - form.setFieldValue( - field.name, - event.currentTarget.files?.length - ? event.currentTarget.files[0] - : null, - ); - }} - /> - {form.errors.certificateKey ? ( -
- {form.errors.certificateKey && form.touched.certificateKey - ? form.errors.certificateKey - : null} -
- ) : null} -
- )} -
- - {({ field, form }: any) => ( -
- - { - form.setFieldValue( - field.name, - event.currentTarget.files?.length - ? event.currentTarget.files[0] - : null, - ); - }} - /> - {form.errors.certificate ? ( -
- {form.errors.certificate && form.touched.certificate - ? form.errors.certificate - : null} -
- ) : null} -
- )} -
- - {({ field, form }: any) => ( -
- - { - form.setFieldValue( - field.name, - event.currentTarget.files?.length - ? event.currentTarget.files[0] - : null, - ); - }} - /> - {form.errors.intermediateCertificate ? ( -
- {form.errors.intermediateCertificate && - form.touched.intermediateCertificate - ? form.errors.intermediateCertificate - : null} -
- ) : null} -
- )} -
+ {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + {isLoading && } + {!isLoading && data && ( + + {() => ( + + + + + + + + setErrorMsg(null)} dismissible> + {errorMsg} + +
+
+

+ + +

+ + {({ field, form }: any) => ( +
+ + + {form.errors.niceName ? ( +
+ {form.errors.niceName && form.touched.niceName + ? form.errors.niceName + : null} +
+ ) : null} +
+ )} +
+ + {({ field, form }: any) => ( +
+ + { + form.setFieldValue( + field.name, + event.currentTarget.files?.length + ? event.currentTarget.files[0] + : null, + ); + }} + /> + {form.errors.certificateKey ? ( +
+ {form.errors.certificateKey && form.touched.certificateKey + ? form.errors.certificateKey + : null} +
+ ) : null} +
+ )} +
+ + {({ field, form }: any) => ( +
+ + { + form.setFieldValue( + field.name, + event.currentTarget.files?.length + ? event.currentTarget.files[0] + : null, + ); + }} + /> + {form.errors.certificate ? ( +
+ {form.errors.certificate && form.touched.certificate + ? form.errors.certificate + : null} +
+ ) : null} +
+ )} +
+ + {({ field, form }: any) => ( +
+ + { + form.setFieldValue( + field.name, + event.currentTarget.files?.length + ? event.currentTarget.files[0] + : null, + ); + }} + /> + {form.errors.intermediateCertificate ? ( +
+ {form.errors.intermediateCertificate && + form.touched.intermediateCertificate + ? form.errors.intermediateCertificate + : null} +
+ ) : null} +
+ )} +
+
-
- - - - - - - )} - + + + + + + + )} + + )} ); }); diff --git a/frontend/src/pages/Certificates/Table.tsx b/frontend/src/pages/Certificates/Table.tsx index 7eaeb77701..169e456232 100644 --- a/frontend/src/pages/Certificates/Table.tsx +++ b/frontend/src/pages/Certificates/Table.tsx @@ -1,4 +1,4 @@ -import { IconDotsVertical, IconDownload, IconRefresh, IconTrash } from "@tabler/icons-react"; +import { IconDotsVertical, IconDownload, IconEdit, IconRefresh, IconTrash } from "@tabler/icons-react"; import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { useMemo } from "react"; import type { Certificate } from "src/api/backend"; @@ -20,10 +20,11 @@ interface Props { isFiltered?: boolean; isFetching?: boolean; onDelete?: (id: number) => void; + onEdit?: (id: number) => void; onRenew?: (id: number) => void; onDownload?: (id: number) => void; } -export default function Table({ data, isFetching, onDelete, onRenew, onDownload, isFiltered }: Props) { +export default function Table({ data, isFetching, onDelete, onEdit, onRenew, onDownload, isFiltered }: Props) { const columnHelper = createColumnHelper(); const columns = useMemo( () => [ @@ -128,6 +129,19 @@ export default function Table({ data, isFetching, onDelete, onRenew, onDownload, + {info.row.original.provider === "other" ? ( + { + e.preventDefault(); + onEdit?.(info.row.original.id); + }} + > + + + + ) : null} ({ @@ -207,7 +221,7 @@ export default function Table({ data, isFetching, onDelete, onRenew, onDownload, href="#" onClick={(e) => { e.preventDefault(); - showCustomCertificateModal(); + showCustomCertificateModal("new"); }} > diff --git a/frontend/src/pages/Certificates/TableWrapper.tsx b/frontend/src/pages/Certificates/TableWrapper.tsx index 14dfc417fd..6f3ccb0322 100644 --- a/frontend/src/pages/Certificates/TableWrapper.tsx +++ b/frontend/src/pages/Certificates/TableWrapper.tsx @@ -127,7 +127,7 @@ export default function TableWrapper() { href="#" onClick={(e) => { e.preventDefault(); - showCustomCertificateModal(); + showCustomCertificateModal("new"); }} > @@ -144,6 +144,7 @@ export default function TableWrapper() { data={filtered ?? data ?? []} isFiltered={!!search} isFetching={isFetching} + onEdit={showCustomCertificateModal} onRenew={showRenewCertificateModal} onDownload={handleDownload} onDelete={(id: number) => diff --git a/test/cypress/e2e/api/Certificates.cy.js b/test/cypress/e2e/api/Certificates.cy.js index 25d1b8c41f..7c2a425d2f 100644 --- a/test/cypress/e2e/api/Certificates.cy.js +++ b/test/cypress/e2e/api/Certificates.cy.js @@ -6,6 +6,8 @@ describe('Certificates endpoints', () => { const certFile = 'test.example.com.pem'; const keyFile = 'test.example.com-key.pem'; + const updatedCertFile = 'test-updated.example.com.pem'; + const updatedKeyFile = 'test-updated.example.com-key.pem'; before(() => { cy.createCustomCerts({ @@ -14,6 +16,12 @@ describe('Certificates endpoints', () => { keyFile, }) + cy.createCustomCerts({ + domain: 'test-updated.example.com', + certFile: updatedCertFile, + keyFile: updatedKeyFile, + }) + cy.resetUsers(); cy.getToken().then((tok) => { token = tok; @@ -62,21 +70,44 @@ describe('Certificates endpoints', () => { expect(data).to.have.property('certificate'); expect(data).to.have.property('certificate_key'); - // Get all certs - cy.task('backendApiGet', { + // Upload replacement files to the same certificate + cy.task('backendApiPostFiles', { token: token, - path: '/api/nginx/certificates?expand=owner' + path: `/api/nginx/certificates/${certID}/upload`, + files: { + certificate: updatedCertFile, + certificate_key: updatedKeyFile, + }, }).then((data) => { - cy.validateSwaggerSchema('get', 200, '/nginx/certificates', data); - expect(data.length).to.be.greaterThan(0); + cy.validateSwaggerSchema('post', 200, '/nginx/certificates/{certID}/upload', data); + expect(data).to.have.property('certificate'); + expect(data).to.have.property('certificate_key'); - // Delete cert - cy.task('backendApiDelete', { + // Get the cert and check the updated domain + cy.task('backendApiGet', { token: token, path: `/api/nginx/certificates/${certID}` }).then((data) => { - cy.validateSwaggerSchema('delete', 200, '/nginx/certificates/{certID}', data); - expect(data).to.be.equal(true); + cy.validateSwaggerSchema('get', 200, '/nginx/certificates/{certID}', data); + expect(data.domain_names).to.contain('test-updated.example.com'); + + // Get all certs + cy.task('backendApiGet', { + token: token, + path: '/api/nginx/certificates?expand=owner' + }).then((data) => { + cy.validateSwaggerSchema('get', 200, '/nginx/certificates', data); + expect(data.length).to.be.greaterThan(0); + + // Delete cert + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/certificates/${certID}` + }).then((data) => { + cy.validateSwaggerSchema('delete', 200, '/nginx/certificates/{certID}', data); + expect(data).to.be.equal(true); + }); + }); }); }); }); From f9aebb78544704d19af2f642b5261a20eb8d135a Mon Sep 17 00:00:00 2001 From: Leon Bojanowski Date: Thu, 11 Jun 2026 14:03:45 +0200 Subject: [PATCH 2/2] Fix lifecycle test domain assertion for mkcert-generated certs --- test/cypress/e2e/api/Certificates.cy.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/cypress/e2e/api/Certificates.cy.js b/test/cypress/e2e/api/Certificates.cy.js index 7c2a425d2f..e459fca65d 100644 --- a/test/cypress/e2e/api/Certificates.cy.js +++ b/test/cypress/e2e/api/Certificates.cy.js @@ -69,6 +69,7 @@ describe('Certificates endpoints', () => { cy.validateSwaggerSchema('post', 200, '/nginx/certificates/{certID}/upload', data); expect(data).to.have.property('certificate'); expect(data).to.have.property('certificate_key'); + const originalCertificate = data.certificate; // Upload replacement files to the same certificate cy.task('backendApiPostFiles', { @@ -82,14 +83,16 @@ describe('Certificates endpoints', () => { cy.validateSwaggerSchema('post', 200, '/nginx/certificates/{certID}/upload', data); expect(data).to.have.property('certificate'); expect(data).to.have.property('certificate_key'); + expect(data.certificate).to.not.equal(originalCertificate); - // Get the cert and check the updated domain + // Get the cert and check it was replaced cy.task('backendApiGet', { token: token, path: `/api/nginx/certificates/${certID}` }).then((data) => { cy.validateSwaggerSchema('get', 200, '/nginx/certificates/{certID}', data); - expect(data.domain_names).to.contain('test-updated.example.com'); + expect(data.id).to.equal(certID); + expect(data.provider).to.equal('other'); // Get all certs cy.task('backendApiGet', {