Skip to content
Merged
6 changes: 3 additions & 3 deletions apps/aurora-portal/docs/0011_clavis.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ Implemented screens and interactions:
- certificate list view via `PcaCertificatesListContainer` displays certificates issued by a CA
- certificates list shows CA ID and certificate ID columns with loading, error, and empty states
- disabled "Issue End Entity Certificate" button (placeholder for future issue-certificate task)
- individual certificate rows rendered via `PcaCertificatesTableRow` component
- individual certificate rows rendered via `PcaCertificatesTableRow` component, clicking a row navigates to the certificate detail page
- 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

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.
The certificate list view integrates within the CA details view and fetches certificates via the `listCertificates` endpoint.
Expand Down Expand Up @@ -82,9 +83,8 @@ Error states are surfaced directly in the modal or list view when the BFF call f

## Next Areas To Document

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

- certificate detail view
- certificate import flow
- list filtering, sorting, and search controls

Expand Down
23 changes: 23 additions & 0 deletions apps/aurora-portal/src/client/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { Route as AuthProjectsProjectIdStorageProviderContainersIndexRouteImport
import { Route as AuthProjectsProjectIdServicesPcaPcaIdIndexRouteImport } from "./routes/_auth/projects/$projectId/services/pca/$pcaId/index"
import { Route as AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRouteImport } from "./routes/_auth/projects/$projectId/network/securitygroups/$securityGroupId/index"
import { Route as AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRouteImport } from "./routes/_auth/projects/$projectId/network/floatingips/$floatingIpId/index"
import { Route as AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRouteImport } from "./routes/_auth/projects/$projectId/services/pca/$pcaId/$certificateId"
import { Route as AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRouteImport } from "./routes/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/index"
import { Route as AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRouteImport } from "./routes/_auth/projects/$projectId/storage/$provider/containers/$containerName/objects/index"

Expand Down Expand Up @@ -216,6 +217,12 @@ const AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute =
path: "/floatingips/$floatingIpId/",
getParentRoute: () => AuthProjectsProjectIdNetworkRoute,
} as any)
const AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute =
AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRouteImport.update({
id: "/services/pca/$pcaId/$certificateId",
path: "/services/pca/$pcaId/$certificateId",
getParentRoute: () => AuthProjectsProjectIdRoute,
} as any)
const AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRoute =
AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRouteImport.update(
{
Expand Down Expand Up @@ -258,6 +265,7 @@ export interface FileRoutesByFullPath {
"/projects/$projectId/network/securitygroups/": typeof AuthProjectsProjectIdNetworkSecuritygroupsIndexRoute
"/projects/$projectId/services/pca/": typeof AuthProjectsProjectIdServicesPcaIndexRoute
"/projects/$projectId/storage/ceph/": typeof AuthProjectsProjectIdStorageCephIndexRoute
"/projects/$projectId/services/pca/$pcaId/$certificateId": typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute
"/projects/$projectId/network/floatingips/$floatingIpId/": typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute
"/projects/$projectId/network/securitygroups/$securityGroupId/": typeof AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute
"/projects/$projectId/services/pca/$pcaId/": typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute
Expand Down Expand Up @@ -287,6 +295,7 @@ export interface FileRoutesByTo {
"/projects/$projectId/network/securitygroups": typeof AuthProjectsProjectIdNetworkSecuritygroupsIndexRoute
"/projects/$projectId/services/pca": typeof AuthProjectsProjectIdServicesPcaIndexRoute
"/projects/$projectId/storage/ceph": typeof AuthProjectsProjectIdStorageCephIndexRoute
"/projects/$projectId/services/pca/$pcaId/$certificateId": typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute
"/projects/$projectId/network/floatingips/$floatingIpId": typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute
"/projects/$projectId/network/securitygroups/$securityGroupId": typeof AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute
"/projects/$projectId/services/pca/$pcaId": typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute
Expand Down Expand Up @@ -322,6 +331,7 @@ export interface FileRoutesById {
"/_auth/projects/$projectId/network/securitygroups/": typeof AuthProjectsProjectIdNetworkSecuritygroupsIndexRoute
"/_auth/projects/$projectId/services/pca/": typeof AuthProjectsProjectIdServicesPcaIndexRoute
"/_auth/projects/$projectId/storage/ceph/": typeof AuthProjectsProjectIdStorageCephIndexRoute
"/_auth/projects/$projectId/services/pca/$pcaId/$certificateId": typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute
"/_auth/projects/$projectId/network/floatingips/$floatingIpId/": typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute
"/_auth/projects/$projectId/network/securitygroups/$securityGroupId/": typeof AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute
"/_auth/projects/$projectId/services/pca/$pcaId/": typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute
Expand Down Expand Up @@ -357,6 +367,7 @@ export interface FileRouteTypes {
| "/projects/$projectId/network/securitygroups/"
| "/projects/$projectId/services/pca/"
| "/projects/$projectId/storage/ceph/"
| "/projects/$projectId/services/pca/$pcaId/$certificateId"
| "/projects/$projectId/network/floatingips/$floatingIpId/"
| "/projects/$projectId/network/securitygroups/$securityGroupId/"
| "/projects/$projectId/services/pca/$pcaId/"
Expand Down Expand Up @@ -386,6 +397,7 @@ export interface FileRouteTypes {
| "/projects/$projectId/network/securitygroups"
| "/projects/$projectId/services/pca"
| "/projects/$projectId/storage/ceph"
| "/projects/$projectId/services/pca/$pcaId/$certificateId"
| "/projects/$projectId/network/floatingips/$floatingIpId"
| "/projects/$projectId/network/securitygroups/$securityGroupId"
| "/projects/$projectId/services/pca/$pcaId"
Expand Down Expand Up @@ -420,6 +432,7 @@ export interface FileRouteTypes {
| "/_auth/projects/$projectId/network/securitygroups/"
| "/_auth/projects/$projectId/services/pca/"
| "/_auth/projects/$projectId/storage/ceph/"
| "/_auth/projects/$projectId/services/pca/$pcaId/$certificateId"
| "/_auth/projects/$projectId/network/floatingips/$floatingIpId/"
| "/_auth/projects/$projectId/network/securitygroups/$securityGroupId/"
| "/_auth/projects/$projectId/services/pca/$pcaId/"
Expand Down Expand Up @@ -647,6 +660,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRouteImport
parentRoute: typeof AuthProjectsProjectIdNetworkRoute
}
"/_auth/projects/$projectId/services/pca/$pcaId/$certificateId": {
id: "/_auth/projects/$projectId/services/pca/$pcaId/$certificateId"
path: "/services/pca/$pcaId/$certificateId"
fullPath: "/projects/$projectId/services/pca/$pcaId/$certificateId"
preLoaderRoute: typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRouteImport
parentRoute: typeof AuthProjectsProjectIdRoute
}
"/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/": {
id: "/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/"
path: "/storage/ceph/containers/$containerName/objects"
Expand Down Expand Up @@ -741,6 +761,7 @@ interface AuthProjectsProjectIdRouteChildren {
AuthProjectsProjectIdServicesIndexRoute: typeof AuthProjectsProjectIdServicesIndexRoute
AuthProjectsProjectIdServicesPcaIndexRoute: typeof AuthProjectsProjectIdServicesPcaIndexRoute
AuthProjectsProjectIdStorageCephIndexRoute: typeof AuthProjectsProjectIdStorageCephIndexRoute
AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute: typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute
AuthProjectsProjectIdServicesPcaPcaIdIndexRoute: typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute
AuthProjectsProjectIdStorageProviderContainersIndexRoute: typeof AuthProjectsProjectIdStorageProviderContainersIndexRoute
AuthProjectsProjectIdStorageCephContainersIndexRoute: typeof AuthProjectsProjectIdStorageCephContainersIndexRoute
Expand Down Expand Up @@ -768,6 +789,8 @@ const AuthProjectsProjectIdRouteChildren: AuthProjectsProjectIdRouteChildren = {
AuthProjectsProjectIdServicesPcaIndexRoute,
AuthProjectsProjectIdStorageCephIndexRoute:
AuthProjectsProjectIdStorageCephIndexRoute,
AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute:
AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute,
AuthProjectsProjectIdServicesPcaPcaIdIndexRoute:
AuthProjectsProjectIdServicesPcaPcaIdIndexRoute,
AuthProjectsProjectIdStorageProviderContainersIndexRoute:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { I18nProvider } from "@lingui/react"
import { i18n } from "@lingui/core"
import { PortalProvider } from "@cloudoperators/juno-ui-components"
import { trpcReact } from "@/client/trpcClient"
import { RouteComponent } from "./$certificateId"

const mockSetPageTitle = vi.fn()
const mockNavigate = vi.fn()

vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => () => ({
staticData: {},
useParams: () => ({ projectId: "project-1", pcaId: "ca-1", certificateId: "cert-1" }),
useRouteContext: () => ({ setPageTitle: mockSetPageTitle }),
}),
useNavigate: () => mockNavigate,
}))

vi.mock("@/client/trpcClient", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/client/trpcClient")>()
return {
...actual,
trpcReact: {
...actual.trpcReact,
services: {
...actual.trpcReact.services,
pca: {
...actual.trpcReact.services.pca,
getByIdCertificate: {
...actual.trpcReact.services.pca.getByIdCertificate,
useQuery: vi.fn(),
},
},
},
},
}
})

const renderComponent = () =>
render(
<I18nProvider i18n={i18n}>
<PortalProvider>
<RouteComponent />
</PortalProvider>
</I18nProvider>
)

const mockQuery = (overrides: object) =>
vi.mocked(trpcReact.services.pca.getByIdCertificate.useQuery).mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
...overrides,
} as never)

describe("$certificateId page", () => {
beforeEach(() => {
vi.clearAllMocks()
})

it("renders loading state", () => {
mockQuery({ isLoading: true })
renderComponent()

expect(screen.getByText("Loading Certificate Details...")).toBeInTheDocument()
})

it("renders error state with message", () => {
mockQuery({ isError: true, error: { message: "Not found" } })
renderComponent()

expect(screen.getByText("Not found")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Back to Certificate Authorities Details page" })).toBeInTheDocument()
})

it("renders not-found state", () => {
mockQuery({ data: undefined })
renderComponent()

expect(screen.getByText("Certificate not found")).toBeInTheDocument()
})

it("renders certificate details", () => {
mockQuery({
data: {
id: "cert-1",
certificate_authority_id: "ca-1",
project_id: "project-1",
csr: "-----BEGIN CERTIFICATE REQUEST-----\nABC\n-----END CERTIFICATE REQUEST-----",
configuration: { validity: { not_before: 0, not_after: 86400 } },
},
})
renderComponent()

expect(screen.getByText("cert-1 Certificate Details")).toBeInTheDocument()
expect(screen.getByText("ca-1")).toBeInTheDocument()
expect(screen.getByText("1 days")).toBeInTheDocument()
})

it("navigates back on error back button click", async () => {
const user = userEvent.setup()
mockQuery({ isError: true, error: { message: "error" } })
renderComponent()

await user.click(screen.getByRole("button", { name: "Back to Certificate Authorities Details page" }))

expect(mockNavigate).toHaveBeenCalledWith({
to: "/projects/$projectId/services/pca/$pcaId",
params: { projectId: "project-1", pcaId: "ca-1" },
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import type { RouteInfo } from "@/client/routes/routeInfo"
import { trpcReact } from "@/client/trpcClient"
import {
Button,
DescriptionDefinition,
DescriptionList,
DescriptionTerm,
Divider,
Spinner,
Stack,
} from "@cloudoperators/juno-ui-components/index"
import { Trans, useLingui } from "@lingui/react/macro"
import { MdContentCopy, MdDownload } from "react-icons/md"
import { Fragment } from "react/jsx-runtime"

export const Route = createFileRoute("/_auth/projects/$projectId/services/pca/$pcaId/$certificateId")({
staticData: { section: "services", service: "pca" } satisfies RouteInfo,
component: RouteComponent,
})

export function RouteComponent() {
const { t } = useLingui()
const navigate = useNavigate()
const { setPageTitle } = Route.useRouteContext()
const { projectId, pcaId, certificateId } = Route.useParams()

const {
isLoading,
isError,
error,
data: certificate,
} = trpcReact.services.pca.getByIdCertificate.useQuery({
project_id: projectId,
certificate_authority_id: pcaId,
certificate_id: certificateId,
})

// Loading state
if (isLoading) {
setPageTitle(t`Loading...`)
Comment thread
vlad-schur-external-sap marked this conversation as resolved.
return (
<Stack className="fixed inset-0" distribution="center" alignment="center" direction="vertical">
<Spinner variant="primary" size="large" className="mb-2" />
<Trans>Loading Certificate Details...</Trans>
</Stack>
)
}

const handleBack = () =>
navigate({
to: "/projects/$projectId/services/pca/$pcaId",
params: { projectId, pcaId },
})
Comment thread
vlad-schur-external-sap marked this conversation as resolved.

// Error state
if (isError) {
const errorMessage = error?.message || "Unknown error"
return (
<Stack className="fixed inset-0" distribution="center" alignment="center" direction="vertical" gap="5">
<p className="text-theme-error font-semibold">
<Trans>Error loading Certificate</Trans>
</p>
<p className="text-theme-highest">{errorMessage}</p>
<Button onClick={handleBack} variant="primary">
<Trans>Back to Certificate Authorities Details page</Trans>
</Button>
</Stack>
)
}

// No data state
if (!certificate) {
return (
<Stack className="fixed inset-0" distribution="center" alignment="center" direction="vertical" gap="5">
<p className="text-theme-secondary">
<Trans>Certificate not found</Trans>
</p>
<Button onClick={handleBack} variant="primary">
<Trans>Back to Certificate Authorities Details page</Trans>
</Button>
</Stack>
)
}

setPageTitle(`Certificate ${certificate.id}`)

const basicInfo = [
{ label: t`CA ID`, value: certificate.certificate_authority_id },
{ label: t`ID`, value: certificate.id },
{
label: t`Duration/validity`,
value:
certificate.configuration?.validity.not_before !== undefined &&
certificate.configuration?.validity.not_after !== undefined
? `${Math.round(
(certificate.configuration.validity.not_after - certificate.configuration.validity.not_before) /
(60 * 60 * 24)
)} days`
: undefined,
},
] as const

return (
<Stack direction="vertical" gap="3">
<div className="text-theme-default text-2xl font-semibold">{`${certificate.id} Certificate Details`}</div>

<p className="text-theme-highest text-sm">
<Trans>Manage your Certificate</Trans>
</p>

<Stack gap="4" className="grid grid-cols-2 items-start">
<DescriptionList alignTerms="right" className="w-full">
{basicInfo.map(({ label, value }) => (
<Fragment key={label}>
<DescriptionTerm>{label}</DescriptionTerm>
<DescriptionDefinition>{value || "—"}</DescriptionDefinition>
</Fragment>
))}
</DescriptionList>

<div className="bg-dt-background w-full rounded-sm">
<div className="text-theme-default p-4 text-xl font-bold">Certificate {`${certificate.id}`}</div>
<Divider />

<div className="p-4 text-sm break-all whitespace-pre-wrap">{certificate?.csr}</div>

{/* I will implement downloading-copying functionality at issue/import part of the epic as I need to clarify some stuff with design-clavis team */}
<Divider />
<Stack gap="2" distribution="end" className="p-4">
<Button>
<MdDownload />
</Button>
<Button>
<MdContentCopy />
</Button>
</Stack>
</div>
</Stack>
</Stack>
)
}
Loading