Skip to content

Commit f879e0b

Browse files
committed
fix: merge conflicts
2 parents 62a1a3c + b027042 commit f879e0b

19 files changed

Lines changed: 866 additions & 63 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Clavis / PCA
2+
3+
This document tracks the Clavis integration in Aurora Portal. The initial backend and UI pieces are implemented, and this note is meant to evolve as the feature grows.
4+
5+
## Current Scope
6+
7+
The feature currently covers project-scoped management of private certificate authorities (PCAs):
8+
9+
- list certificate authorities for a project
10+
- create a certificate authority
11+
- delete a certificate authority
12+
- import a certificate chain for a CA
13+
- list certificates issued by a certificate authority
14+
- fetch certificate authority and certificate details by id
15+
- create certificates under a certificate authority
16+
17+
## Implemented UI
18+
19+
The active UI entry point is the project service route at `/projects/$projectId/services/pca/`.
20+
21+
Implemented screens and interactions:
22+
23+
- PCA list page with loading, error, and empty states
24+
- primary action to create a certificate authority
25+
- row action menu with delete certificate authority
26+
- create modal with FQDN/common name validation
27+
- delete modal with explicit confirmation by typing `delete`
28+
- CA details page at `/projects/$projectId/services/pca/$pcaId/` via `PcaDetailsView`
29+
- details page shows CA metadata, certificate validity, CSR content, and delete action
30+
- details-page delete flow reuses the shared delete modal and redirects back to the PCA list after success
31+
32+
The list page currently renders the CA state, id, and common name. It also shows the translated empty state when no PCAs are available for the current project.
33+
34+
## Implemented BFF
35+
36+
The PCA router is project-scoped and talks to the OpenStack PCA / Clavis service.
37+
38+
| Procedure | OpenStack path | Purpose |
39+
| -------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------- |
40+
| `list` | `GET /certificate-authorities` | List all certificate authorities in the project |
41+
| `create` | `POST /certificate-authorities` | Create a new certificate authority |
42+
| `getById` | `GET /certificate-authorities/{certificate_authority_id}` | Fetch certificate authority details |
43+
| `delete` | `DELETE /certificate-authorities/{certificate_authority_id}` | Permanently delete a certificate authority |
44+
| `import` | `POST /certificate-authorities/{certificate_authority_id}:importCertificate` | Import the certificate chain for a CA |
45+
| `listCertificates` | `GET /certificate-authorities/{certificate_authority_id}/certificates` | List certificates issued by a CA |
46+
| `createCertificate` | `POST /certificate-authorities/{certificate_authority_id}/certificates` | Create a new certificate under a CA |
47+
| `getByIdCertificate` | `GET /certificate-authorities/{certificate_authority_id}/certificates/{certificate_id}` | Fetch certificate details |
48+
49+
All endpoints expect `project_id` in the request context or input and use the OpenStack service client exposed by the Aurora BFF.
50+
51+
## Data Model Notes
52+
53+
Relevant PCA states are:
54+
55+
- `CREATING`
56+
- `AWAITING_CERTIFICATE`
57+
- `READY`
58+
- `FAILED`
59+
- `UNEXPECTED`
60+
61+
A newly created CA starts in `CREATING`. Once its CSR is generated, it moves to `AWAITING_CERTIFICATE`. Importing the certificate chain transitions it to `READY`, at which point it can issue end-entity certificates.
62+
63+
The CA schema also includes:
64+
65+
- `configuration.subject.common_name`
66+
- `csr`
67+
- `certificate`
68+
- `certificate_chain`
69+
- `imported_certificate_chain`
70+
- `project_id`
71+
72+
## UX and Validation
73+
74+
The create flow currently validates the common name as an FQDN-style value. The delete flow requires a typed confirmation to reduce accidental removal of a CA and its associated certificates.
75+
76+
Error states are surfaced directly in the modal or list view when the BFF call fails.
77+
78+
## Next Areas To Document
79+
80+
The backend already exposes certificate and import operations, but the UI does not yet have dedicated screens for:
81+
82+
- certificate list view
83+
- certificate detail view
84+
- certificate import flow
85+
- list filtering, sorting, and search controls
86+
87+
Those can be documented once the corresponding UI work lands.

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { Route as AuthProjectsProjectIdComputeFlavorsIndexRouteImport } from "./
3434
import { Route as AuthProjectsProjectIdComputeImagesImageIdRouteImport } from "./routes/_auth/projects/$projectId/compute/images/$imageId"
3535
import { Route as AuthProjectsProjectIdComputeFlavorsFlavorIdRouteImport } from "./routes/_auth/projects/$projectId/compute/flavors/$flavorId"
3636
import { Route as AuthProjectsProjectIdStorageProviderContainersIndexRouteImport } from "./routes/_auth/projects/$projectId/storage/$provider/containers/index"
37+
import { Route as AuthProjectsProjectIdServicesPcaPcaIdIndexRouteImport } from "./routes/_auth/projects/$projectId/services/pca/$pcaId/index"
3738
import { Route as AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRouteImport } from "./routes/_auth/projects/$projectId/network/securitygroups/$securityGroupId/index"
3839
import { Route as AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRouteImport } from "./routes/_auth/projects/$projectId/network/floatingips/$floatingIpId/index"
3940
import { Route as AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRouteImport } from "./routes/_auth/projects/$projectId/storage/$provider/containers/$containerName/objects/index"
@@ -180,6 +181,12 @@ const AuthProjectsProjectIdStorageProviderContainersIndexRoute =
180181
path: "/storage/$provider/containers/",
181182
getParentRoute: () => AuthProjectsProjectIdRoute,
182183
} as any)
184+
const AuthProjectsProjectIdServicesPcaPcaIdIndexRoute =
185+
AuthProjectsProjectIdServicesPcaPcaIdIndexRouteImport.update({
186+
id: "/services/pca/$pcaId/",
187+
path: "/services/pca/$pcaId/",
188+
getParentRoute: () => AuthProjectsProjectIdRoute,
189+
} as any)
183190
const AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute =
184191
AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRouteImport.update(
185192
{
@@ -229,6 +236,7 @@ export interface FileRoutesByFullPath {
229236
"/projects/$projectId/services/pca/": typeof AuthProjectsProjectIdServicesPcaIndexRoute
230237
"/projects/$projectId/network/floatingips/$floatingIpId/": typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute
231238
"/projects/$projectId/network/securitygroups/$securityGroupId/": typeof AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute
239+
"/projects/$projectId/services/pca/$pcaId/": typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute
232240
"/projects/$projectId/storage/$provider/containers/": typeof AuthProjectsProjectIdStorageProviderContainersIndexRoute
233241
"/projects/$projectId/storage/$provider/containers/$containerName/objects/": typeof AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute
234242
}
@@ -254,6 +262,7 @@ export interface FileRoutesByTo {
254262
"/projects/$projectId/services/pca": typeof AuthProjectsProjectIdServicesPcaIndexRoute
255263
"/projects/$projectId/network/floatingips/$floatingIpId": typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute
256264
"/projects/$projectId/network/securitygroups/$securityGroupId": typeof AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute
265+
"/projects/$projectId/services/pca/$pcaId": typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute
257266
"/projects/$projectId/storage/$provider/containers": typeof AuthProjectsProjectIdStorageProviderContainersIndexRoute
258267
"/projects/$projectId/storage/$provider/containers/$containerName/objects": typeof AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute
259268
}
@@ -285,6 +294,7 @@ export interface FileRoutesById {
285294
"/_auth/projects/$projectId/services/pca/": typeof AuthProjectsProjectIdServicesPcaIndexRoute
286295
"/_auth/projects/$projectId/network/floatingips/$floatingIpId/": typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute
287296
"/_auth/projects/$projectId/network/securitygroups/$securityGroupId/": typeof AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute
297+
"/_auth/projects/$projectId/services/pca/$pcaId/": typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute
288298
"/_auth/projects/$projectId/storage/$provider/containers/": typeof AuthProjectsProjectIdStorageProviderContainersIndexRoute
289299
"/_auth/projects/$projectId/storage/$provider/containers/$containerName/objects/": typeof AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute
290300
}
@@ -316,6 +326,7 @@ export interface FileRouteTypes {
316326
| "/projects/$projectId/services/pca/"
317327
| "/projects/$projectId/network/floatingips/$floatingIpId/"
318328
| "/projects/$projectId/network/securitygroups/$securityGroupId/"
329+
| "/projects/$projectId/services/pca/$pcaId/"
319330
| "/projects/$projectId/storage/$provider/containers/"
320331
| "/projects/$projectId/storage/$provider/containers/$containerName/objects/"
321332
fileRoutesByTo: FileRoutesByTo
@@ -341,6 +352,7 @@ export interface FileRouteTypes {
341352
| "/projects/$projectId/services/pca"
342353
| "/projects/$projectId/network/floatingips/$floatingIpId"
343354
| "/projects/$projectId/network/securitygroups/$securityGroupId"
355+
| "/projects/$projectId/services/pca/$pcaId"
344356
| "/projects/$projectId/storage/$provider/containers"
345357
| "/projects/$projectId/storage/$provider/containers/$containerName/objects"
346358
id:
@@ -371,6 +383,7 @@ export interface FileRouteTypes {
371383
| "/_auth/projects/$projectId/services/pca/"
372384
| "/_auth/projects/$projectId/network/floatingips/$floatingIpId/"
373385
| "/_auth/projects/$projectId/network/securitygroups/$securityGroupId/"
386+
| "/_auth/projects/$projectId/services/pca/$pcaId/"
374387
| "/_auth/projects/$projectId/storage/$provider/containers/"
375388
| "/_auth/projects/$projectId/storage/$provider/containers/$containerName/objects/"
376389
fileRoutesById: FileRoutesById
@@ -558,6 +571,13 @@ declare module "@tanstack/react-router" {
558571
preLoaderRoute: typeof AuthProjectsProjectIdStorageProviderContainersIndexRouteImport
559572
parentRoute: typeof AuthProjectsProjectIdRoute
560573
}
574+
"/_auth/projects/$projectId/services/pca/$pcaId/": {
575+
id: "/_auth/projects/$projectId/services/pca/$pcaId/"
576+
path: "/services/pca/$pcaId"
577+
fullPath: "/projects/$projectId/services/pca/$pcaId/"
578+
preLoaderRoute: typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRouteImport
579+
parentRoute: typeof AuthProjectsProjectIdRoute
580+
}
561581
"/_auth/projects/$projectId/network/securitygroups/$securityGroupId/": {
562582
id: "/_auth/projects/$projectId/network/securitygroups/$securityGroupId/"
563583
path: "/securitygroups/$securityGroupId"
@@ -658,6 +678,7 @@ interface AuthProjectsProjectIdRouteChildren {
658678
AuthProjectsProjectIdComputeIndexRoute: typeof AuthProjectsProjectIdComputeIndexRoute
659679
AuthProjectsProjectIdServicesIndexRoute: typeof AuthProjectsProjectIdServicesIndexRoute
660680
AuthProjectsProjectIdServicesPcaIndexRoute: typeof AuthProjectsProjectIdServicesPcaIndexRoute
681+
AuthProjectsProjectIdServicesPcaPcaIdIndexRoute: typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute
661682
AuthProjectsProjectIdStorageProviderContainersIndexRoute: typeof AuthProjectsProjectIdStorageProviderContainersIndexRoute
662683
AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute: typeof AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute
663684
}
@@ -680,6 +701,8 @@ const AuthProjectsProjectIdRouteChildren: AuthProjectsProjectIdRouteChildren = {
680701
AuthProjectsProjectIdServicesIndexRoute,
681702
AuthProjectsProjectIdServicesPcaIndexRoute:
682703
AuthProjectsProjectIdServicesPcaIndexRoute,
704+
AuthProjectsProjectIdServicesPcaPcaIdIndexRoute:
705+
AuthProjectsProjectIdServicesPcaPcaIdIndexRoute,
683706
AuthProjectsProjectIdStorageProviderContainersIndexRoute:
684707
AuthProjectsProjectIdStorageProviderContainersIndexRoute,
685708
AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute:
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import { act, 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 type { CertificateAuthority } from "@/server/Services/types/pca"
7+
import { PcaDetailsView } from "./PcaDetailsView"
8+
9+
const mockNavigate = vi.fn()
10+
11+
vi.mock("@tanstack/react-router", () => ({
12+
useNavigate: () => mockNavigate,
13+
}))
14+
15+
vi.mock("@/client/hooks", () => ({
16+
useProjectId: () => "project-1",
17+
}))
18+
19+
vi.mock("../../-components/-modals/DeletePcaModal", () => ({
20+
DeletePcaModal: ({ open, onSuccess }: { open: boolean; onSuccess?: () => void }) =>
21+
open ? (
22+
<div>
23+
<div>Delete CA Modal</div>
24+
<button onClick={onSuccess}>Trigger Delete Success</button>
25+
</div>
26+
) : null,
27+
}))
28+
29+
describe("PcaDetailsView", () => {
30+
const basePca: CertificateAuthority = {
31+
id: "ca-1",
32+
project_id: "project-1",
33+
state: "READY",
34+
configuration: {
35+
subject: {
36+
common_name: "ca.example.internal",
37+
},
38+
},
39+
csr: "-----BEGIN CSR-----",
40+
certificate: {
41+
pem: "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----",
42+
validity: {
43+
not_before: 1705315200,
44+
not_after: 1705488000,
45+
},
46+
},
47+
}
48+
49+
const renderView = (pca: CertificateAuthority = basePca) =>
50+
render(
51+
<I18nProvider i18n={i18n}>
52+
<PcaDetailsView pca={pca} />
53+
</I18nProvider>
54+
)
55+
56+
beforeEach(async () => {
57+
vi.clearAllMocks()
58+
await act(async () => {
59+
i18n.activate("en")
60+
})
61+
})
62+
63+
it("renders basic details content", () => {
64+
renderView()
65+
66+
expect(screen.getByText("ca.example.internal Certificate Authority Details")).toBeInTheDocument()
67+
expect(screen.getByText("Manage your Private Certificate Authority infrastructure")).toBeInTheDocument()
68+
expect(screen.getByText("CA ID")).toBeInTheDocument()
69+
expect(screen.getByText("ca-1")).toBeInTheDocument()
70+
expect(screen.getByText("2 days")).toBeInTheDocument()
71+
})
72+
73+
it("opens delete modal from details page", async () => {
74+
const user = userEvent.setup()
75+
renderView()
76+
77+
await user.click(screen.getByRole("button", { name: "Delete Certificate Authority" }))
78+
79+
expect(screen.getByText("Delete CA Modal")).toBeInTheDocument()
80+
})
81+
82+
it("navigates to list page after delete success", async () => {
83+
const user = userEvent.setup()
84+
renderView()
85+
86+
await user.click(screen.getByRole("button", { name: "Delete Certificate Authority" }))
87+
await user.click(screen.getByRole("button", { name: "Trigger Delete Success" }))
88+
89+
expect(mockNavigate).toHaveBeenCalledWith({
90+
to: "/projects/$projectId/services/pca",
91+
params: { projectId: "project-1" },
92+
})
93+
})
94+
})
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { Fragment } from "react"
2+
import { MdDownload, MdContentCopy } from "react-icons/md"
3+
import { useNavigate } from "@tanstack/react-router"
4+
import { Trans, useLingui } from "@lingui/react/macro"
5+
import {
6+
Button,
7+
DescriptionDefinition,
8+
DescriptionList,
9+
DescriptionTerm,
10+
Divider,
11+
Stack,
12+
} from "@cloudoperators/juno-ui-components/index"
13+
import { CertificateAuthority } from "@/server/Services/types/pca"
14+
import { useProjectId } from "@/client/hooks"
15+
import { useModal } from "@/client/utils/useModal"
16+
import { DeletePcaModal } from "../../-components/-modals/DeletePcaModal"
17+
import { STATE_CONFIG } from "../../-components/-table/constants"
18+
19+
interface PcaDetailsViewProps {
20+
pca: CertificateAuthority
21+
}
22+
23+
export const PcaDetailsView = ({ pca }: PcaDetailsViewProps) => {
24+
const { t } = useLingui()
25+
const navigate = useNavigate()
26+
const projectId = useProjectId()
27+
const [deletePcaModalOpen, toggleDeletePcaModal] = useModal(false)
28+
29+
const navigateToPcaList = () =>
30+
navigate({
31+
to: "/projects/$projectId/services/pca",
32+
params: { projectId },
33+
})
34+
35+
const basicInfo = [
36+
{ label: t`CA ID`, value: pca.id },
37+
{ label: t`Project ID`, value: pca.project_id },
38+
{ label: t`Subject`, value: pca.configuration?.subject?.common_name },
39+
{
40+
label: t`Duration/validity`,
41+
value:
42+
pca.certificate?.validity.not_before !== undefined && pca.certificate?.validity.not_after !== undefined
43+
? `${Math.round(
44+
(pca.certificate.validity.not_after - pca.certificate.validity.not_before) / (60 * 60 * 24)
45+
)} days`
46+
: undefined,
47+
},
48+
] as const
49+
50+
return (
51+
<>
52+
<Stack direction="vertical" gap="3">
53+
<Stack direction="horizontal" distribution="between">
54+
<Stack gap="2" alignment="center">
55+
<div className="text-theme-default text-2xl font-semibold">
56+
{`${pca.configuration?.subject?.common_name} Certificate Authority Details`}
57+
</div>
58+
{/* temporary bg, I will resolve this as soon as I will have sync with designers */}
59+
<div className="bg-aurora-blue-200 flex items-center gap-1 rounded-sm px-1 py-0.5">
60+
{STATE_CONFIG[pca.state].icon} {STATE_CONFIG[pca.state].text}
61+
</div>
62+
</Stack>
63+
<Button onClick={toggleDeletePcaModal}>
64+
<Trans>Delete Certificate Authority</Trans>
65+
</Button>
66+
</Stack>
67+
68+
<p className="text-theme-highest text-sm">
69+
<Trans>Manage your Private Certificate Authority infrastructure</Trans>
70+
</p>
71+
72+
<Stack gap="4" className="grid grid-cols-2 items-start">
73+
<DescriptionList alignTerms="right" className="w-full">
74+
{basicInfo.map(({ label, value }) => (
75+
<Fragment key={label}>
76+
<DescriptionTerm>{label}</DescriptionTerm>
77+
<DescriptionDefinition>{value || "—"}</DescriptionDefinition>
78+
</Fragment>
79+
))}
80+
</DescriptionList>
81+
82+
<div className="bg-dt-background w-full rounded-sm">
83+
<div className="text-theme-default p-4 text-xl font-bold">
84+
Certificate {`${pca.configuration?.subject?.common_name}`}
85+
</div>
86+
<Divider />
87+
88+
<div className="p-4 text-sm break-all whitespace-pre-wrap">{pca?.csr}</div>
89+
90+
{/* I will implement downloading-copying functionality at issue/import part of the epic as I need to clarify some stuff with design-clavis team */}
91+
<Divider />
92+
<Stack gap="2" distribution="end" className="p-4">
93+
<Button>
94+
<MdDownload />
95+
</Button>
96+
<Button>
97+
<MdContentCopy />
98+
</Button>
99+
</Stack>
100+
</div>
101+
</Stack>
102+
</Stack>
103+
104+
{deletePcaModalOpen && (
105+
<DeletePcaModal
106+
pca={pca}
107+
open={deletePcaModalOpen}
108+
onClose={toggleDeletePcaModal}
109+
onSuccess={navigateToPcaList}
110+
/>
111+
)}
112+
</>
113+
)
114+
}

0 commit comments

Comments
 (0)