diff --git a/apps/console/src/components/SchemaForm.test.tsx b/apps/console/src/components/SchemaForm.test.tsx
index ea416cc..174722d 100644
--- a/apps/console/src/components/SchemaForm.test.tsx
+++ b/apps/console/src/components/SchemaForm.test.tsx
@@ -102,6 +102,40 @@ describe("SchemaForm immutableMode", () => {
expect(screen.getByRole("button", { name: /add/i })).toBeDisabled()
})
+ it("binds the key/value editor to an additionalProperties map nested in array items", () => {
+ // spec.strategies[].parameters shape: an array of objects each carrying a
+ // free-form string map. Native rendering shows no Add control here (the
+ // custom ObjectFieldTemplate drops it), so an empty map would be
+ // uneditable — the walker must reach into items and attach the field.
+ const arrayMapSchema = JSON.stringify({
+ type: "object",
+ properties: {
+ strategies: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ parameters: {
+ type: "object",
+ additionalProperties: { type: "string" },
+ },
+ },
+ },
+ },
+ },
+ })
+ render(
+ ,
+ )
+ // AdditionalPropertiesField exposes an explicit add-key input even when the
+ // map is empty; native additionalProperties rendering would not.
+ expect(screen.getByPlaceholderText("Enter key name...")).toBeInTheDocument()
+ })
+
it("greys out immutable nested fields inside array items", () => {
const arraySchema = JSON.stringify({
type: "object",
diff --git a/apps/console/src/components/SchemaForm.tsx b/apps/console/src/components/SchemaForm.tsx
index bc0048d..ce69caf 100644
--- a/apps/console/src/components/SchemaForm.tsx
+++ b/apps/console/src/components/SchemaForm.tsx
@@ -79,7 +79,11 @@ function addBackupClassWidgets(schema: RJSFSchema, uiSchema: UiSchema = {}): UiS
}
/**
- * Recursively find all fields with additionalProperties schema and add widget
+ * Recursively find all fields with additionalProperties schema and add widget.
+ * Walks nested objects AND array items, so a map nested inside array elements
+ * (e.g. spec.strategies[].parameters) gets the key/value editor too — without
+ * this, such maps fall back to native rendering whose Add control the custom
+ * ObjectFieldTemplate omits, leaving empty maps with no way to add entries.
*/
function addAdditionalPropertiesWidgets(schema: RJSFSchema, uiSchema: UiSchema = {}): UiSchema {
if (!schema || typeof schema !== "object") return uiSchema
@@ -91,31 +95,55 @@ function addAdditionalPropertiesWidgets(schema: RJSFSchema, uiSchema: UiSchema =
for (const [key, value] of Object.entries(properties)) {
if (typeof value === "object" && value !== null) {
- const fieldSchema = value as any
- // Check if this field has additionalProperties with a schema
- const hasAdditionalPropertiesSchema =
- fieldSchema.type === "object" &&
- (!fieldSchema.properties || Object.keys(fieldSchema.properties).length === 0) &&
- typeof fieldSchema.additionalProperties === "object" &&
- fieldSchema.additionalProperties !== null &&
- fieldSchema.additionalProperties !== true
-
- if (hasAdditionalPropertiesSchema) {
- // Found a field with additionalProperties schema - use custom field
- result[key] = {
- ...result[key],
- "ui:field": "AdditionalPropertiesField",
- }
- } else if (fieldSchema.properties) {
- // Recursively process nested objects
- result[key] = addAdditionalPropertiesWidgets(fieldSchema, result[key] as UiSchema)
- }
+ const bound = bindAdditionalProperties(value as RJSFSchema, result[key] as UiSchema | undefined)
+ if (bound !== undefined) result[key] = bound
}
}
return result
}
+/**
+ * Resolve the uiSchema fragment for one schema node: bind the custom field to
+ * an additionalProperties map, recurse into nested objects, or recurse into
+ * array `items`. Returns the (possibly unchanged) ui fragment.
+ */
+function bindAdditionalProperties(
+ fieldSchema: RJSFSchema,
+ uiNode: UiSchema | undefined,
+): UiSchema | undefined {
+ const node = fieldSchema as any
+
+ const isAdditionalPropertiesMap =
+ node.type === "object" &&
+ (!node.properties || Object.keys(node.properties).length === 0) &&
+ typeof node.additionalProperties === "object" &&
+ node.additionalProperties !== null &&
+ node.additionalProperties !== true
+
+ if (isAdditionalPropertiesMap) {
+ return { ...uiNode, "ui:field": "AdditionalPropertiesField" }
+ }
+
+ if (node.properties) {
+ return addAdditionalPropertiesWidgets(fieldSchema, uiNode as UiSchema)
+ }
+
+ if (
+ node.type === "array" &&
+ node.items &&
+ typeof node.items === "object" &&
+ !Array.isArray(node.items)
+ ) {
+ const itemsUi = bindAdditionalProperties(node.items as RJSFSchema, (uiNode as any)?.items)
+ if (itemsUi !== undefined) {
+ return { ...uiNode, items: itemsUi }
+ }
+ }
+
+ return uiNode
+}
+
/**
* Add VMDiskWidget to the "name" field inside "disks" array items
*/
diff --git a/apps/console/src/hooks/useBackupClassAdminAccess.ts b/apps/console/src/hooks/useBackupClassAdminAccess.ts
new file mode 100644
index 0000000..890fb5b
--- /dev/null
+++ b/apps/console/src/hooks/useBackupClassAdminAccess.ts
@@ -0,0 +1,20 @@
+import { useSelfSubjectAccessReview } from "@cozystack/k8s-client"
+
+// BackupClass is cluster-scoped. Tenants already hold get/list/watch on it
+// (that's what powers the BackupClassWidget dropdown), so a read gate would
+// not exclude them — only write does. Gating on `update` is what makes the
+// Backup Classes area admin-only. Fail closed: loading and error states
+// resolve as "not allowed" so the sidebar entry never flickers in then out.
+export function useBackupClassAdminAccess(): { allowed: boolean; isLoading: boolean } {
+ const review = useSelfSubjectAccessReview({
+ resourceAttributes: {
+ group: "backups.cozystack.io",
+ resource: "backupclasses",
+ verb: "update",
+ },
+ })
+ return {
+ isLoading: review.isLoading,
+ allowed: !review.isLoading && !review.error && review.allowed,
+ }
+}
diff --git a/apps/console/src/routes/BackupClassAdminGuard.test.tsx b/apps/console/src/routes/BackupClassAdminGuard.test.tsx
new file mode 100644
index 0000000..d45b465
--- /dev/null
+++ b/apps/console/src/routes/BackupClassAdminGuard.test.tsx
@@ -0,0 +1,70 @@
+import { describe, it, expect, vi } from "vitest"
+import { screen, waitFor } from "@testing-library/react"
+import { Route, Routes } from "react-router"
+import { K8sClient, K8sApiError } from "@cozystack/k8s-client"
+import { renderWithK8sProvider } from "../test-utils/render.tsx"
+import { BackupClassAdminGuard } from "./BackupClassAdminGuard.tsx"
+
+type SsarOutcome = { allowed: boolean } | "pending" | K8sApiError
+
+function makeClient(outcome: SsarOutcome): K8sClient {
+ const client = new K8sClient({ baseUrl: "/mock" })
+ vi.spyOn(client, "create").mockImplementation(async (_g, _v, _p, body) => {
+ if (outcome === "pending") return new Promise(() => {}) as never
+ if (outcome instanceof K8sApiError) throw outcome
+ return { ...(body as object), status: { allowed: outcome.allowed } }
+ })
+ return client
+}
+
+function renderGuard(client: K8sClient) {
+ return renderWithK8sProvider(
+
+ }>
+ BACKUP CLASSES CONTENT} />
+
+ ,
+ { client, initialRoute: "/bc" },
+ )
+}
+
+describe("BackupClassAdminGuard", () => {
+ it("renders the child route when update is allowed", async () => {
+ renderGuard(makeClient({ allowed: true }))
+ await waitFor(() =>
+ expect(screen.getByText("BACKUP CLASSES CONTENT")).toBeInTheDocument(),
+ )
+ })
+
+ it("renders permission-denied instead of the child route when update is denied", async () => {
+ renderGuard(makeClient({ allowed: false }))
+ await waitFor(() =>
+ expect(
+ screen.getByText(/do not have permission to manage backup classes/i),
+ ).toBeInTheDocument(),
+ )
+ expect(screen.queryByText("BACKUP CLASSES CONTENT")).not.toBeInTheDocument()
+ expect(screen.getByRole("link", { name: /back to console/i })).toHaveAttribute(
+ "href",
+ "/console",
+ )
+ })
+
+ it("fails closed (denied) on SSAR error", async () => {
+ renderGuard(makeClient(new K8sApiError(500, "boom")))
+ await waitFor(() =>
+ expect(
+ screen.getByText(/do not have permission to manage backup classes/i),
+ ).toBeInTheDocument(),
+ )
+ expect(screen.queryByText("BACKUP CLASSES CONTENT")).not.toBeInTheDocument()
+ })
+
+ it("shows neither content nor denial while the review is loading", () => {
+ renderGuard(makeClient("pending"))
+ expect(screen.queryByText("BACKUP CLASSES CONTENT")).not.toBeInTheDocument()
+ expect(
+ screen.queryByText(/do not have permission to manage backup classes/i),
+ ).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/console/src/routes/BackupClassAdminGuard.tsx b/apps/console/src/routes/BackupClassAdminGuard.tsx
new file mode 100644
index 0000000..afb711d
--- /dev/null
+++ b/apps/console/src/routes/BackupClassAdminGuard.tsx
@@ -0,0 +1,39 @@
+import { Link, Outlet } from "react-router"
+import { Section, Spinner } from "@cozystack/ui"
+import { useBackupClassAdminAccess } from "../hooks/useBackupClassAdminAccess.ts"
+
+/**
+ * Layout route guard for the Backup Classes pages. Renders the matched child
+ * route only for users who may update backup classes; everyone else gets a
+ * permission-denied message with a link back to the console instead of the
+ * page (and instead of a browser 403 on direct URL navigation).
+ */
+export function BackupClassAdminGuard() {
+ const { allowed, isLoading } = useBackupClassAdminAccess()
+
+ if (isLoading) {
+ return (
+
+ Loading…
+
+ )
+ }
+
+ if (!allowed) {
+ return (
+
+
+
+ You do not have permission to manage backup classes.{" "}
+
+ Back to console
+
+ .
+
+
+
+ )
+ }
+
+ return
+}
diff --git a/apps/console/src/routes/BackupClassCreatePage.tsx b/apps/console/src/routes/BackupClassCreatePage.tsx
new file mode 100644
index 0000000..d58244b
--- /dev/null
+++ b/apps/console/src/routes/BackupClassCreatePage.tsx
@@ -0,0 +1,142 @@
+import { useState } from "react"
+import { Link, useNavigate } from "react-router"
+import { ArrowLeft, Archive, Save } from "lucide-react"
+import { Button, Section, Spinner } from "@cozystack/ui"
+import { useK8sCreate, type K8sResource } from "@cozystack/k8s-client"
+import { useCRDSchema } from "../lib/use-crd-schema.ts"
+import { SchemaForm } from "../components/SchemaForm.tsx"
+
+// BackupClass is cluster-scoped; the guard wrapping this route already
+// enforces that only an SSAR-allowed admin reaches it.
+export function BackupClassCreatePage() {
+ const navigate = useNavigate()
+ const [formData, setFormData] = useState({})
+ const [name, setName] = useState("")
+
+ const { schema, isLoading: schemaLoading } = useCRDSchema(
+ "backupclasses.backups.cozystack.io",
+ )
+
+ const createMutation = useK8sCreate({
+ apiGroup: "backups.cozystack.io",
+ apiVersion: "v1alpha1",
+ plural: "backupclasses",
+ })
+
+ const listPath = "/console/backups/backupclasses"
+
+ const handleSubmit = async () => {
+ if (!name.trim()) {
+ alert("Name is required")
+ return
+ }
+ const resource: K8sResource = {
+ apiVersion: "backups.cozystack.io/v1alpha1",
+ kind: "BackupClass",
+ metadata: { name: name.trim() },
+ spec: formData,
+ }
+ try {
+ await createMutation.mutateAsync(resource)
+ navigate(`${listPath}/${encodeURIComponent(name.trim())}`)
+ } catch (err) {
+ alert(`Failed to create Backup Class: ${(err as Error).message}`)
+ }
+ }
+
+ if (schemaLoading) {
+ return (
+
+ Loading schema…
+
+ )
+ }
+
+ if (!schema) {
+ return (
+
+ Failed to load Backup Class schema. Please refresh the page.
+
+ )
+ }
+
+ return (
+
+
+
Backups
+
+
+
+
+
+
Create Backup Class
+
+ Define a backup strategy for one or more application kinds
+
+
+
+
+
+
+
+
+ Name *
+
+ setName(e.target.value)}
+ placeholder="my-backup-class"
+ className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400"
+ required
+ />
+
+
+
+
+
+
+
+ {createMutation.isPending ? (
+ <>
+ Creating…
+ >
+ ) : (
+ <>
+ Create
+ >
+ )}
+
+ navigate(listPath)}
+ disabled={createMutation.isPending}
+ >
+ Cancel
+
+
+
+
+ )
+}
diff --git a/apps/console/src/routes/BackupClassDetailPage.tsx b/apps/console/src/routes/BackupClassDetailPage.tsx
new file mode 100644
index 0000000..2abd1c1
--- /dev/null
+++ b/apps/console/src/routes/BackupClassDetailPage.tsx
@@ -0,0 +1,141 @@
+import { Link, useNavigate, useParams } from "react-router"
+import yaml from "js-yaml"
+import { ArrowLeft, Archive, Edit, Trash2 } from "lucide-react"
+import { Button, Section, Spinner } from "@cozystack/ui"
+import { useK8sDelete, useK8sGet, type K8sResource } from "@cozystack/k8s-client"
+import { formatAge } from "../lib/status.ts"
+
+export function BackupClassDetailPage() {
+ const { name } = useParams<{ name: string }>()
+ const navigate = useNavigate()
+
+ // BackupClass is cluster-scoped — not tenant-namespaced.
+ const { data: backupClass, isLoading, error } = useK8sGet(
+ {
+ apiGroup: "backups.cozystack.io",
+ apiVersion: "v1alpha1",
+ plural: "backupclasses",
+ name: name ?? "",
+ },
+ { enabled: !!name },
+ )
+
+ const deleteMutation = useK8sDelete({
+ apiGroup: "backups.cozystack.io",
+ apiVersion: "v1alpha1",
+ plural: "backupclasses",
+ })
+
+ const handleDelete = async () => {
+ if (!name) return
+ if (!confirm(`Delete Backup Class "${name}"? This cannot be undone.`)) return
+ try {
+ await deleteMutation.mutateAsync(name)
+ navigate("/console/backups/backupclasses")
+ } catch (err) {
+ alert(`Failed to delete Backup Class: ${(err as Error).message}`)
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+ Loading…
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ Failed to load Backup Class: {(error as Error).message}
+
+ )
+ }
+
+ if (!backupClass) {
+ return Backup Class not found.
+ }
+
+ const labelEntries = Object.entries(backupClass.metadata.labels ?? {})
+
+ return (
+
+
+
Backups
+
+
+
+
+
+
+
+ {backupClass.metadata.name}
+
+
Backup Class
+
+
+
+
+
+ Edit
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
Name
+
+ {backupClass.metadata.name}
+
+
+
+
Age
+
+ {formatAge(backupClass.metadata.creationTimestamp)}
+
+
+
+ {labelEntries.length > 0 && (
+
+
Labels
+
+ {labelEntries.map(([k, v]) => (
+
+ {k}={v}
+
+ ))}
+
+
+ )}
+
+
+
+
+ {yaml.dump(backupClass.spec ?? {})}
+
+
+
+
+ )
+}
diff --git a/apps/console/src/routes/BackupClassEditPage.tsx b/apps/console/src/routes/BackupClassEditPage.tsx
new file mode 100644
index 0000000..f24eacf
--- /dev/null
+++ b/apps/console/src/routes/BackupClassEditPage.tsx
@@ -0,0 +1,162 @@
+import { useEffect, useRef, useState } from "react"
+import { Link, useNavigate, useParams } from "react-router"
+import { ArrowLeft, Archive, Save } from "lucide-react"
+import { Button, Section, Spinner } from "@cozystack/ui"
+import { useK8sGet, useK8sUpdate, type K8sResource } from "@cozystack/k8s-client"
+import { useCRDSchema } from "../lib/use-crd-schema.ts"
+import { prepareUpdateSpec } from "../lib/prepare-update.ts"
+import { SchemaForm } from "../components/SchemaForm.tsx"
+
+// BackupClass is cluster-scoped — editable by cluster admins, not tenant
+// users. The API server's RBAC is the real gate; a tenant-scoped user's PUT
+// here simply 403s and surfaces in the error alert.
+export function BackupClassEditPage() {
+ const { name } = useParams<{ name: string }>()
+ const navigate = useNavigate()
+ const [formData, setFormData] = useState({})
+ const initializedRef = useRef(false)
+ // Snapshot of spec at form-init time, used as the immutable-overlay source
+ // in prepareUpdateSpec so refetches can't change what gets PUT.
+ const initialSpecRef = useRef(null)
+
+ const { schema, isLoading: schemaLoading } = useCRDSchema(
+ "backupclasses.backups.cozystack.io",
+ )
+
+ const {
+ data: resource,
+ isLoading: resourceLoading,
+ error,
+ } = useK8sGet(
+ {
+ apiGroup: "backups.cozystack.io",
+ apiVersion: "v1alpha1",
+ plural: "backupclasses",
+ name: name ?? "",
+ },
+ { enabled: !!name },
+ )
+
+ const updateMutation = useK8sUpdate({
+ apiGroup: "backups.cozystack.io",
+ apiVersion: "v1alpha1",
+ plural: "backupclasses",
+ })
+
+ // Initialise form data from the resource once, so a refetch mid-edit
+ // doesn't clobber in-progress changes.
+ useEffect(() => {
+ if (resource?.spec && !initializedRef.current) {
+ initializedRef.current = true
+ setFormData(resource.spec)
+ initialSpecRef.current = resource.spec
+ }
+ }, [resource])
+
+ const detailPath = `/console/backups/backupclasses/${name}`
+
+ const handleSubmit = async () => {
+ if (!resource || !schema) return
+ const updated: K8sResource = {
+ ...resource,
+ spec: prepareUpdateSpec(formData, initialSpecRef.current, schema),
+ }
+ try {
+ await updateMutation.mutateAsync(updated)
+ navigate(detailPath)
+ } catch (err) {
+ alert(`Failed to update Backup Class: ${(err as Error).message}`)
+ }
+ }
+
+ if (schemaLoading || resourceLoading) {
+ return (
+
+ Loading…
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ Failed to load Backup Class: {(error as Error).message}
+
+ )
+ }
+
+ if (!resource) {
+ return Backup Class not found.
+ }
+
+ if (!schema) {
+ return (
+
+ Failed to load schema. Please refresh the page.
+
+ )
+ }
+
+ return (
+
+
+
{name}
+
+
+
+
+
+
Edit Backup Class
+
{name}
+
+
+
+
+
+
+
+
+ {updateMutation.isPending ? (
+ <>
+ Saving…
+ >
+ ) : (
+ <>
+ Save
+ >
+ )}
+
+ navigate(detailPath)}
+ disabled={updateMutation.isPending}
+ >
+ Cancel
+
+
+
+
+ )
+}
diff --git a/apps/console/src/routes/BackupClassListPage.tsx b/apps/console/src/routes/BackupClassListPage.tsx
new file mode 100644
index 0000000..aa22b51
--- /dev/null
+++ b/apps/console/src/routes/BackupClassListPage.tsx
@@ -0,0 +1,234 @@
+import { Link } from "react-router"
+import { Archive, ChevronRight, Plus } from "lucide-react"
+import { Button, Section, Spinner, StatusBadge } from "@cozystack/ui"
+import { useK8sGet, useK8sList, type K8sResource } from "@cozystack/k8s-client"
+import type { ApplicationInstance } from "@cozystack/types"
+import { useTenantContext } from "../lib/tenant-context.tsx"
+import { useApplicationDefinitions, iconDataUrl } from "../lib/app-definitions.ts"
+import { formatAge, readyCondition } from "../lib/status.ts"
+
+// Hardcoded: cozy-backups in tenant-root is the cluster's system backup
+// bucket. Swap this for a discovery hook once cozystack exposes a canonical
+// reference (e.g. via a status field on BackupClass or a known label).
+function SystemBackupBucketPanel() {
+ const { selectTenant } = useTenantContext()
+ const { data: bucket, isLoading } = useK8sGet({
+ apiGroup: "apps.cozystack.io",
+ apiVersion: "v1alpha1",
+ plural: "buckets",
+ name: "cozy-backups",
+ namespace: "tenant-root",
+ })
+ // Reuse the Bucket ApplicationDefinition's own icon (the same red S3
+ // glyph rendered on deployed-app cards) so the bucket here is visually
+ // recognisable as a Bucket app.
+ const { data: appDefs } = useApplicationDefinitions()
+ const bucketAd = appDefs?.items.find((d) => d.spec?.application.kind === "Bucket")
+ const bucketIcon = bucketAd ? iconDataUrl(bucketAd) : undefined
+
+ if (isLoading) {
+ return (
+
+
+ Loading system backup bucket…
+
+
+ )
+ }
+
+ // Bucket missing (or unreachable) — surface as an attention-grabbing
+ // placeholder rather than hiding, so an admin can't miss the gap.
+ if (!bucket) {
+ return (
+
+
+
+
+
+
cozy-backups
+
+ Not deployed on this cluster
+
+
+
+
Not configured
+
+
+ )
+ }
+
+ const ready = readyCondition(bucket)
+ const tone = ready?.status === "True" ? "ok" : ready ? "warn" : "muted"
+ const label =
+ ready?.status === "True" ? "Ready" : (ready?.reason ?? "Unknown")
+
+ return (
+
+ selectTenant("root")}
+ className="flex h-full items-center justify-between gap-4 p-4 hover:bg-slate-50"
+ >
+
+
+ {bucketIcon ? (
+
+ ) : (
+
+ )}
+
+
+
cozy-backups
+
+ System backup bucket · {formatAge(bucket.metadata.creationTimestamp)}
+
+
+
+
+ {label}
+
+
+
+
+ )
+}
+
+// Cluster-wide count of backup artifacts. List call omits the namespace so it
+// hits the all-namespaces endpoint; the click target is the per-tenant list
+// page, which scopes to whatever tenant the admin has selected.
+function BackupArtifactsCountPanel() {
+ const { data, isLoading } = useK8sList({
+ apiGroup: "backups.cozystack.io",
+ apiVersion: "v1alpha1",
+ plural: "backups",
+ })
+
+ if (isLoading) {
+ return (
+
+
+ Loading backup artifacts…
+
+
+ )
+ }
+
+ const count = data?.items?.length ?? 0
+
+ return (
+
+
+
+
+
+
{count}
+
Backup artifacts count
+
+
+
+
+
+ )
+}
+
+export function BackupClassListPage() {
+ // BackupClass is cluster-scoped — not tenant-namespaced.
+ const { data, isLoading, error } = useK8sList({
+ apiGroup: "backups.cozystack.io",
+ apiVersion: "v1alpha1",
+ plural: "backupclasses",
+ })
+
+ const items = data?.items ?? []
+
+ if (isLoading) {
+ return (
+
+ Loading…
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ Failed to load Backup Classes: {(error as Error).message}
+
+ )
+ }
+
+ return (
+
+
+
Backups
+
System storage and classes configuration
+
+
+
+
+
+
+
+
+
+
+
Backup Classes
+
+ {items.length} {items.length === 1 ? "item" : "items"}
+
+
+
+
+ Create Backup Class
+
+
+
+ {items.length === 0 ? (
+
+ No backup classes found.
+
+ ) : (
+
+
+
+
+ Name
+ Age
+
+
+
+ {items.map((item) => (
+
+
+
+ {item.metadata.name}
+
+
+
+ {item.metadata.creationTimestamp
+ ? formatAge(item.metadata.creationTimestamp)
+ : "-"}
+
+
+ ))}
+
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/console/src/routes/BackupResourceListPage.tsx b/apps/console/src/routes/BackupResourceListPage.tsx
index c00f33d..eacdc51 100644
--- a/apps/console/src/routes/BackupResourceListPage.tsx
+++ b/apps/console/src/routes/BackupResourceListPage.tsx
@@ -13,6 +13,18 @@ interface BackupResource {
namespace?: string
creationTimestamp?: string
}
+ spec?: {
+ applicationRef?: {
+ apiGroup?: string
+ kind?: string
+ name?: string
+ }
+ targetApplicationRef?: {
+ apiGroup?: string
+ kind?: string
+ name?: string
+ }
+ }
status?: {
phase?: string
conditions?: Array<{
@@ -110,6 +122,7 @@ export function BackupResourceListPage({ resourceType, title }: BackupResourceLi
Name
+ Application
Namespace
Status
Age
@@ -126,6 +139,13 @@ export function BackupResourceListPage({ resourceType, title }: BackupResourceLi
statusText === "Failed" || statusText === "False" ? "error" :
"warn"
+ const appRef = resourceType === "restorejobs"
+ ? item.spec?.targetApplicationRef
+ : item.spec?.applicationRef
+ const appRefText = appRef?.kind && appRef?.name
+ ? `${appRef.kind}/${appRef.name}`
+ : appRef?.name || "-"
+
return (
{item.metadata.name}
+
+ {appRefText}
+
{item.metadata.namespace}
diff --git a/apps/console/src/routes/ConsolePage.tsx b/apps/console/src/routes/ConsolePage.tsx
index 5e0a249..3e9a750 100644
--- a/apps/console/src/routes/ConsolePage.tsx
+++ b/apps/console/src/routes/ConsolePage.tsx
@@ -9,6 +9,11 @@ import { ApplicationListPage } from "./ApplicationListPage.tsx"
import { ApplicationDetailPage } from "./detail/ApplicationDetailPage.tsx"
import { ApplicationEditRoute } from "./detail/ApplicationEditRoute.tsx"
import { BackupResourceListPage } from "./BackupResourceListPage.tsx"
+import { BackupClassListPage } from "./BackupClassListPage.tsx"
+import { BackupClassDetailPage } from "./BackupClassDetailPage.tsx"
+import { BackupClassEditPage } from "./BackupClassEditPage.tsx"
+import { BackupClassCreatePage } from "./BackupClassCreatePage.tsx"
+import { BackupClassAdminGuard } from "./BackupClassAdminGuard.tsx"
import { BackupResourceEditPage } from "./BackupResourceEditPage.tsx"
import { BackupPlanCreatePage } from "./BackupPlanCreatePage.tsx"
import { BackupJobCreatePage } from "./BackupJobCreatePage.tsx"
@@ -73,6 +78,12 @@ export function ConsolePage() {
path="backups/restorejobs/:name/edit"
element={ }
/>
+ }>
+ } />
+ } />
+ } />
+ } />
+
} />
} />
} />
diff --git a/apps/console/src/routes/sidebar-sections.test.tsx b/apps/console/src/routes/sidebar-sections.test.tsx
index 783d3e2..a553d70 100644
--- a/apps/console/src/routes/sidebar-sections.test.tsx
+++ b/apps/console/src/routes/sidebar-sections.test.tsx
@@ -115,3 +115,58 @@ describe("useConsoleSidebarSections — Cluster Usage gate", () => {
})
})
})
+
+// The sidebar issues two SSARs (nodes/list for Cluster Usage, and
+// backupclasses/update for Backup Classes); this client answers each by the
+// requested resource so the two gates can be exercised independently.
+function makeResourceClient(allow: Record): K8sClient {
+ const client = new K8sClient()
+ vi.spyOn(client, "list").mockResolvedValue(emptyAppDefList as K8sList)
+ vi.spyOn(client, "create").mockImplementation(async (_g, _v, _p, body) => {
+ const resource =
+ (body as SelfSubjectAccessReview).spec?.resourceAttributes?.resource ?? ""
+ return {
+ ...(body as object),
+ status: { allowed: allow[resource] ?? false },
+ } as unknown
+ })
+ return client
+}
+
+// The admin "Backups" entry collides by label with the per-tenant "Backups"
+// item in the Backups group, so locate the admin one by section + URL.
+function findAdminBackupsItem(
+ sections: ReturnType,
+) {
+ const admin = sections.find((s) => s.title === "Administration")
+ return admin?.items.find((i) => i.to === "/console/backups/backupclasses")
+}
+
+describe("useConsoleSidebarSections — Backup Classes gate", () => {
+ it("shows the admin Backups entry when update on backupclasses is allowed", async () => {
+ const client = makeResourceClient({ backupclasses: true })
+ const { result } = renderHook(() => useConsoleSidebarSections(), {
+ wrapper: makeWrapper(client),
+ })
+ await waitFor(() => {
+ const item = findAdminBackupsItem(result.current)
+ expect(item).toBeDefined()
+ expect(item?.label).toBe("Backups")
+ })
+ })
+
+ it("hides the admin Backups entry when update on backupclasses is denied (read-only tenant)", async () => {
+ // list allowed, update denied — the read a tenant actually has must NOT
+ // be enough to surface the admin entry.
+ const client = makeResourceClient({ backupclasses: false })
+ const { result } = renderHook(() => useConsoleSidebarSections(), {
+ wrapper: makeWrapper(client),
+ })
+ await waitFor(() => {
+ expect(client.create).toHaveBeenCalled()
+ expect(findAdminBackupsItem(result.current)).toBeUndefined()
+ })
+ // The per-tenant "Backups" group item (different URL) remains visible.
+ expect(findItem(result.current, "Plans")).toBeDefined()
+ })
+})
diff --git a/apps/console/src/routes/sidebar-sections.tsx b/apps/console/src/routes/sidebar-sections.tsx
index a37326a..4e7b69a 100644
--- a/apps/console/src/routes/sidebar-sections.tsx
+++ b/apps/console/src/routes/sidebar-sections.tsx
@@ -15,6 +15,7 @@ import {
} from "lucide-react"
import type { SidebarSection } from "@cozystack/ui"
import { useSelfSubjectAccessReview } from "@cozystack/k8s-client"
+import { useBackupClassAdminAccess } from "../hooks/useBackupClassAdminAccess.ts"
import { useApplicationDefinitions, groupByCategory } from "../lib/app-definitions.ts"
import { humanizeKind } from "../lib/humanize.ts"
import {
@@ -82,6 +83,9 @@ export function useConsoleSidebarSections(): SidebarSection[] {
!clusterUsageReview.isLoading &&
!clusterUsageReview.error &&
clusterUsageReview.allowed
+ // Backup Classes is admin-only: tenants have cluster-wide read on
+ // backupclasses, so the entry is gated on write (update), not list.
+ const { allowed: canManageBackupClasses } = useBackupClassAdminAccess()
return useMemo(() => {
const sorted = [...grouped]
@@ -125,6 +129,9 @@ export function useConsoleSidebarSections(): SidebarSection[] {
...(canSeeClusterUsage
? [{ label: "Cluster Usage", to: "/console/cluster-usage", icon: Gauge }]
: []),
+ ...(canManageBackupClasses
+ ? [{ label: "Backups", to: "/console/backups/backupclasses", icon: Archive }]
+ : []),
{ label: "Info", to: "/console/info", icon: Info },
{ label: "Modules", to: "/console/modules", icon: ToyBrick },
{ label: "External IPs", to: "/console/external-ips", icon: Globe },
@@ -133,5 +140,5 @@ export function useConsoleSidebarSections(): SidebarSection[] {
}
return [...categorySections, backupsSection, administrationSection]
- }, [grouped, canSeeClusterUsage])
+ }, [grouped, canSeeClusterUsage, canManageBackupClasses])
}