Skip to content

Commit 977e992

Browse files
feat(ui): clavis pca certitificate get-by-id (#844)
* feat(ui): list certificates issued by ca Signed-off-by: Vladislav Schur <u.shchur@sap.com> * feat(ui): get-by-id cert of ca register route Signed-off-by: Vladislav Schur <u.shchur@sap.com> * feat(aurora-portal): cert details view page Signed-off-by: Vladislav Schur <u.shchur@sap.com> * fix(bff): get-by-id certificate schema Signed-off-by: Vladislav Schur <u.shchur@sap.com> * refactor(ui): certificate-id structure with tests * chore(docs): update clavis docs Signed-off-by: Vladislav Schur <u.shchur@sap.com> * fix(ui): code-rabbit comment --------- Signed-off-by: Vladislav Schur <u.shchur@sap.com>
1 parent ff1f658 commit 977e992

12 files changed

Lines changed: 361 additions & 22 deletions

File tree

apps/aurora-portal/docs/0011_clavis.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ Implemented screens and interactions:
3131
- certificate list view via `PcaCertificatesListContainer` displays certificates issued by a CA
3232
- certificates list shows CA ID and certificate ID columns with loading, error, and empty states
3333
- disabled "Issue End Entity Certificate" button (placeholder for future issue-certificate task)
34-
- individual certificate rows rendered via `PcaCertificatesTableRow` component
34+
- individual certificate rows rendered via `PcaCertificatesTableRow` component, clicking a row navigates to the certificate detail page
35+
- certificate detail page at `/projects/$projectId/services/pca/$pcaId/$certificateId` shows CA ID, certificate ID, duration/validity, and CSR content with loading, error, and not-found states
3536

3637
The PCA list page renders the CA state, id, and common name with translated empty states when no PCAs are available for the current project.
3738
The certificate list view integrates within the CA details view and fetches certificates via the `listCertificates` endpoint.
@@ -82,9 +83,8 @@ Error states are surfaced directly in the modal or list view when the BFF call f
8283

8384
## Next Areas To Document
8485

85-
The backend already exposes certificate and import operations, but the UI does not yet have dedicated screens for:
86+
The backend already exposes import operations, but the UI does not yet have dedicated screens for:
8687

87-
- certificate detail view
8888
- certificate import flow
8989
- list filtering, sorting, and search controls
9090

apps/aurora-portal/src/client/routeTree.gen.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { Route as AuthProjectsProjectIdStorageProviderContainersIndexRouteImport
3939
import { Route as AuthProjectsProjectIdServicesPcaPcaIdIndexRouteImport } from "./routes/_auth/projects/$projectId/services/pca/$pcaId/index"
4040
import { Route as AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRouteImport } from "./routes/_auth/projects/$projectId/network/securitygroups/$securityGroupId/index"
4141
import { Route as AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRouteImport } from "./routes/_auth/projects/$projectId/network/floatingips/$floatingIpId/index"
42+
import { Route as AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRouteImport } from "./routes/_auth/projects/$projectId/services/pca/$pcaId/$certificateId"
4243
import { Route as AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRouteImport } from "./routes/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/index"
4344
import { Route as AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRouteImport } from "./routes/_auth/projects/$projectId/storage/$provider/containers/$containerName/objects/index"
4445

@@ -216,6 +217,12 @@ const AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute =
216217
path: "/floatingips/$floatingIpId/",
217218
getParentRoute: () => AuthProjectsProjectIdNetworkRoute,
218219
} as any)
220+
const AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute =
221+
AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRouteImport.update({
222+
id: "/services/pca/$pcaId/$certificateId",
223+
path: "/services/pca/$pcaId/$certificateId",
224+
getParentRoute: () => AuthProjectsProjectIdRoute,
225+
} as any)
219226
const AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRoute =
220227
AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRouteImport.update(
221228
{
@@ -258,6 +265,7 @@ export interface FileRoutesByFullPath {
258265
"/projects/$projectId/network/securitygroups/": typeof AuthProjectsProjectIdNetworkSecuritygroupsIndexRoute
259266
"/projects/$projectId/services/pca/": typeof AuthProjectsProjectIdServicesPcaIndexRoute
260267
"/projects/$projectId/storage/ceph/": typeof AuthProjectsProjectIdStorageCephIndexRoute
268+
"/projects/$projectId/services/pca/$pcaId/$certificateId": typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute
261269
"/projects/$projectId/network/floatingips/$floatingIpId/": typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute
262270
"/projects/$projectId/network/securitygroups/$securityGroupId/": typeof AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute
263271
"/projects/$projectId/services/pca/$pcaId/": typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute
@@ -287,6 +295,7 @@ export interface FileRoutesByTo {
287295
"/projects/$projectId/network/securitygroups": typeof AuthProjectsProjectIdNetworkSecuritygroupsIndexRoute
288296
"/projects/$projectId/services/pca": typeof AuthProjectsProjectIdServicesPcaIndexRoute
289297
"/projects/$projectId/storage/ceph": typeof AuthProjectsProjectIdStorageCephIndexRoute
298+
"/projects/$projectId/services/pca/$pcaId/$certificateId": typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute
290299
"/projects/$projectId/network/floatingips/$floatingIpId": typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute
291300
"/projects/$projectId/network/securitygroups/$securityGroupId": typeof AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute
292301
"/projects/$projectId/services/pca/$pcaId": typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute
@@ -322,6 +331,7 @@ export interface FileRoutesById {
322331
"/_auth/projects/$projectId/network/securitygroups/": typeof AuthProjectsProjectIdNetworkSecuritygroupsIndexRoute
323332
"/_auth/projects/$projectId/services/pca/": typeof AuthProjectsProjectIdServicesPcaIndexRoute
324333
"/_auth/projects/$projectId/storage/ceph/": typeof AuthProjectsProjectIdStorageCephIndexRoute
334+
"/_auth/projects/$projectId/services/pca/$pcaId/$certificateId": typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute
325335
"/_auth/projects/$projectId/network/floatingips/$floatingIpId/": typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute
326336
"/_auth/projects/$projectId/network/securitygroups/$securityGroupId/": typeof AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute
327337
"/_auth/projects/$projectId/services/pca/$pcaId/": typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute
@@ -357,6 +367,7 @@ export interface FileRouteTypes {
357367
| "/projects/$projectId/network/securitygroups/"
358368
| "/projects/$projectId/services/pca/"
359369
| "/projects/$projectId/storage/ceph/"
370+
| "/projects/$projectId/services/pca/$pcaId/$certificateId"
360371
| "/projects/$projectId/network/floatingips/$floatingIpId/"
361372
| "/projects/$projectId/network/securitygroups/$securityGroupId/"
362373
| "/projects/$projectId/services/pca/$pcaId/"
@@ -386,6 +397,7 @@ export interface FileRouteTypes {
386397
| "/projects/$projectId/network/securitygroups"
387398
| "/projects/$projectId/services/pca"
388399
| "/projects/$projectId/storage/ceph"
400+
| "/projects/$projectId/services/pca/$pcaId/$certificateId"
389401
| "/projects/$projectId/network/floatingips/$floatingIpId"
390402
| "/projects/$projectId/network/securitygroups/$securityGroupId"
391403
| "/projects/$projectId/services/pca/$pcaId"
@@ -420,6 +432,7 @@ export interface FileRouteTypes {
420432
| "/_auth/projects/$projectId/network/securitygroups/"
421433
| "/_auth/projects/$projectId/services/pca/"
422434
| "/_auth/projects/$projectId/storage/ceph/"
435+
| "/_auth/projects/$projectId/services/pca/$pcaId/$certificateId"
423436
| "/_auth/projects/$projectId/network/floatingips/$floatingIpId/"
424437
| "/_auth/projects/$projectId/network/securitygroups/$securityGroupId/"
425438
| "/_auth/projects/$projectId/services/pca/$pcaId/"
@@ -647,6 +660,13 @@ declare module "@tanstack/react-router" {
647660
preLoaderRoute: typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRouteImport
648661
parentRoute: typeof AuthProjectsProjectIdNetworkRoute
649662
}
663+
"/_auth/projects/$projectId/services/pca/$pcaId/$certificateId": {
664+
id: "/_auth/projects/$projectId/services/pca/$pcaId/$certificateId"
665+
path: "/services/pca/$pcaId/$certificateId"
666+
fullPath: "/projects/$projectId/services/pca/$pcaId/$certificateId"
667+
preLoaderRoute: typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRouteImport
668+
parentRoute: typeof AuthProjectsProjectIdRoute
669+
}
650670
"/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/": {
651671
id: "/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/"
652672
path: "/storage/ceph/containers/$containerName/objects"
@@ -741,6 +761,7 @@ interface AuthProjectsProjectIdRouteChildren {
741761
AuthProjectsProjectIdServicesIndexRoute: typeof AuthProjectsProjectIdServicesIndexRoute
742762
AuthProjectsProjectIdServicesPcaIndexRoute: typeof AuthProjectsProjectIdServicesPcaIndexRoute
743763
AuthProjectsProjectIdStorageCephIndexRoute: typeof AuthProjectsProjectIdStorageCephIndexRoute
764+
AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute: typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute
744765
AuthProjectsProjectIdServicesPcaPcaIdIndexRoute: typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute
745766
AuthProjectsProjectIdStorageProviderContainersIndexRoute: typeof AuthProjectsProjectIdStorageProviderContainersIndexRoute
746767
AuthProjectsProjectIdStorageCephContainersIndexRoute: typeof AuthProjectsProjectIdStorageCephContainersIndexRoute
@@ -768,6 +789,8 @@ const AuthProjectsProjectIdRouteChildren: AuthProjectsProjectIdRouteChildren = {
768789
AuthProjectsProjectIdServicesPcaIndexRoute,
769790
AuthProjectsProjectIdStorageCephIndexRoute:
770791
AuthProjectsProjectIdStorageCephIndexRoute,
792+
AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute:
793+
AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute,
771794
AuthProjectsProjectIdServicesPcaPcaIdIndexRoute:
772795
AuthProjectsProjectIdServicesPcaPcaIdIndexRoute,
773796
AuthProjectsProjectIdStorageProviderContainersIndexRoute:
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import { render, screen } from "@testing-library/react"
3+
import userEvent from "@testing-library/user-event"
4+
import { I18nProvider } from "@lingui/react"
5+
import { i18n } from "@lingui/core"
6+
import { PortalProvider } from "@cloudoperators/juno-ui-components"
7+
import { trpcReact } from "@/client/trpcClient"
8+
import { RouteComponent } from "./$certificateId"
9+
10+
const mockSetPageTitle = vi.fn()
11+
const mockNavigate = vi.fn()
12+
13+
vi.mock("@tanstack/react-router", () => ({
14+
createFileRoute: () => () => ({
15+
staticData: {},
16+
useParams: () => ({ projectId: "project-1", pcaId: "ca-1", certificateId: "cert-1" }),
17+
useRouteContext: () => ({ setPageTitle: mockSetPageTitle }),
18+
}),
19+
useNavigate: () => mockNavigate,
20+
}))
21+
22+
vi.mock("@/client/trpcClient", async (importOriginal) => {
23+
const actual = await importOriginal<typeof import("@/client/trpcClient")>()
24+
return {
25+
...actual,
26+
trpcReact: {
27+
...actual.trpcReact,
28+
services: {
29+
...actual.trpcReact.services,
30+
pca: {
31+
...actual.trpcReact.services.pca,
32+
getByIdCertificate: {
33+
...actual.trpcReact.services.pca.getByIdCertificate,
34+
useQuery: vi.fn(),
35+
},
36+
},
37+
},
38+
},
39+
}
40+
})
41+
42+
const renderComponent = () =>
43+
render(
44+
<I18nProvider i18n={i18n}>
45+
<PortalProvider>
46+
<RouteComponent />
47+
</PortalProvider>
48+
</I18nProvider>
49+
)
50+
51+
const mockQuery = (overrides: object) =>
52+
vi.mocked(trpcReact.services.pca.getByIdCertificate.useQuery).mockReturnValue({
53+
data: undefined,
54+
isLoading: false,
55+
isError: false,
56+
error: null,
57+
...overrides,
58+
} as never)
59+
60+
describe("$certificateId page", () => {
61+
beforeEach(() => {
62+
vi.clearAllMocks()
63+
})
64+
65+
it("renders loading state", () => {
66+
mockQuery({ isLoading: true })
67+
renderComponent()
68+
69+
expect(screen.getByText("Loading Certificate Details...")).toBeInTheDocument()
70+
})
71+
72+
it("renders error state with message", () => {
73+
mockQuery({ isError: true, error: { message: "Not found" } })
74+
renderComponent()
75+
76+
expect(screen.getByText("Not found")).toBeInTheDocument()
77+
expect(screen.getByRole("button", { name: "Back to Certificate Authorities Details page" })).toBeInTheDocument()
78+
})
79+
80+
it("renders not-found state", () => {
81+
mockQuery({ data: undefined })
82+
renderComponent()
83+
84+
expect(screen.getByText("Certificate not found")).toBeInTheDocument()
85+
})
86+
87+
it("renders certificate details", () => {
88+
mockQuery({
89+
data: {
90+
id: "cert-1",
91+
certificate_authority_id: "ca-1",
92+
project_id: "project-1",
93+
csr: "-----BEGIN CERTIFICATE REQUEST-----\nABC\n-----END CERTIFICATE REQUEST-----",
94+
configuration: { validity: { not_before: 0, not_after: 86400 } },
95+
},
96+
})
97+
renderComponent()
98+
99+
expect(screen.getByText("cert-1 Certificate Details")).toBeInTheDocument()
100+
expect(screen.getByText("ca-1")).toBeInTheDocument()
101+
expect(screen.getByText("1 days")).toBeInTheDocument()
102+
})
103+
104+
it("navigates back on error back button click", async () => {
105+
const user = userEvent.setup()
106+
mockQuery({ isError: true, error: { message: "error" } })
107+
renderComponent()
108+
109+
await user.click(screen.getByRole("button", { name: "Back to Certificate Authorities Details page" }))
110+
111+
expect(mockNavigate).toHaveBeenCalledWith({
112+
to: "/projects/$projectId/services/pca/$pcaId",
113+
params: { projectId: "project-1", pcaId: "ca-1" },
114+
})
115+
})
116+
})
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { createFileRoute, useNavigate } from "@tanstack/react-router"
2+
import type { RouteInfo } from "@/client/routes/routeInfo"
3+
import { trpcReact } from "@/client/trpcClient"
4+
import {
5+
Button,
6+
DescriptionDefinition,
7+
DescriptionList,
8+
DescriptionTerm,
9+
Divider,
10+
Spinner,
11+
Stack,
12+
} from "@cloudoperators/juno-ui-components/index"
13+
import { Trans, useLingui } from "@lingui/react/macro"
14+
import { MdContentCopy, MdDownload } from "react-icons/md"
15+
import { Fragment } from "react/jsx-runtime"
16+
17+
export const Route = createFileRoute("/_auth/projects/$projectId/services/pca/$pcaId/$certificateId")({
18+
staticData: { section: "services", service: "pca" } satisfies RouteInfo,
19+
component: RouteComponent,
20+
})
21+
22+
export function RouteComponent() {
23+
const { t } = useLingui()
24+
const navigate = useNavigate()
25+
const { setPageTitle } = Route.useRouteContext()
26+
const { projectId, pcaId, certificateId } = Route.useParams()
27+
28+
const {
29+
isLoading,
30+
isError,
31+
error,
32+
data: certificate,
33+
} = trpcReact.services.pca.getByIdCertificate.useQuery({
34+
project_id: projectId,
35+
certificate_authority_id: pcaId,
36+
certificate_id: certificateId,
37+
})
38+
39+
// Loading state
40+
if (isLoading) {
41+
setPageTitle(t`Loading...`)
42+
return (
43+
<Stack className="fixed inset-0" distribution="center" alignment="center" direction="vertical">
44+
<Spinner variant="primary" size="large" className="mb-2" />
45+
<Trans>Loading Certificate Details...</Trans>
46+
</Stack>
47+
)
48+
}
49+
50+
const handleBack = () =>
51+
navigate({
52+
to: "/projects/$projectId/services/pca/$pcaId",
53+
params: { projectId, pcaId },
54+
})
55+
56+
// Error state
57+
if (isError) {
58+
const errorMessage = error?.message || "Unknown error"
59+
return (
60+
<Stack className="fixed inset-0" distribution="center" alignment="center" direction="vertical" gap="5">
61+
<p className="text-theme-error font-semibold">
62+
<Trans>Error loading Certificate</Trans>
63+
</p>
64+
<p className="text-theme-highest">{errorMessage}</p>
65+
<Button onClick={handleBack} variant="primary">
66+
<Trans>Back to Certificate Authorities Details page</Trans>
67+
</Button>
68+
</Stack>
69+
)
70+
}
71+
72+
// No data state
73+
if (!certificate) {
74+
return (
75+
<Stack className="fixed inset-0" distribution="center" alignment="center" direction="vertical" gap="5">
76+
<p className="text-theme-secondary">
77+
<Trans>Certificate not found</Trans>
78+
</p>
79+
<Button onClick={handleBack} variant="primary">
80+
<Trans>Back to Certificate Authorities Details page</Trans>
81+
</Button>
82+
</Stack>
83+
)
84+
}
85+
86+
setPageTitle(`Certificate ${certificate.id}`)
87+
88+
const basicInfo = [
89+
{ label: t`CA ID`, value: certificate.certificate_authority_id },
90+
{ label: t`ID`, value: certificate.id },
91+
{
92+
label: t`Duration/validity`,
93+
value:
94+
certificate.configuration?.validity.not_before !== undefined &&
95+
certificate.configuration?.validity.not_after !== undefined
96+
? `${Math.round(
97+
(certificate.configuration.validity.not_after - certificate.configuration.validity.not_before) /
98+
(60 * 60 * 24)
99+
)} days`
100+
: undefined,
101+
},
102+
] as const
103+
104+
return (
105+
<Stack direction="vertical" gap="3">
106+
<div className="text-theme-default text-2xl font-semibold">{`${certificate.id} Certificate Details`}</div>
107+
108+
<p className="text-theme-highest text-sm">
109+
<Trans>Manage your Certificate</Trans>
110+
</p>
111+
112+
<Stack gap="4" className="grid grid-cols-2 items-start">
113+
<DescriptionList alignTerms="right" className="w-full">
114+
{basicInfo.map(({ label, value }) => (
115+
<Fragment key={label}>
116+
<DescriptionTerm>{label}</DescriptionTerm>
117+
<DescriptionDefinition>{value || "—"}</DescriptionDefinition>
118+
</Fragment>
119+
))}
120+
</DescriptionList>
121+
122+
<div className="bg-dt-background w-full rounded-sm">
123+
<div className="text-theme-default p-4 text-xl font-bold">Certificate {`${certificate.id}`}</div>
124+
<Divider />
125+
126+
<div className="p-4 text-sm break-all whitespace-pre-wrap">{certificate?.csr}</div>
127+
128+
{/* I will implement downloading-copying functionality at issue/import part of the epic as I need to clarify some stuff with design-clavis team */}
129+
<Divider />
130+
<Stack gap="2" distribution="end" className="p-4">
131+
<Button>
132+
<MdDownload />
133+
</Button>
134+
<Button>
135+
<MdContentCopy />
136+
</Button>
137+
</Stack>
138+
</div>
139+
</Stack>
140+
</Stack>
141+
)
142+
}

0 commit comments

Comments
 (0)