Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/console/src/components/SchemaForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<SchemaForm
openAPISchema={arrayMapSchema}
formData={{ strategies: [{ parameters: {} }] }}
onChange={noop}
/>,
)
// 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",
Expand Down
68 changes: 48 additions & 20 deletions apps/console/src/components/SchemaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
*/
Expand Down
20 changes: 20 additions & 0 deletions apps/console/src/hooks/useBackupClassAdminAccess.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
70 changes: 70 additions & 0 deletions apps/console/src/routes/BackupClassAdminGuard.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Routes>
<Route element={<BackupClassAdminGuard />}>
<Route path="bc" element={<div>BACKUP CLASSES CONTENT</div>} />
</Route>
</Routes>,
{ 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()
})
})
39 changes: 39 additions & 0 deletions apps/console/src/routes/BackupClassAdminGuard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2 p-6 text-sm text-slate-500">
<Spinner /> Loading…
</div>
)
}

if (!allowed) {
return (
<div className="p-6">
<Section>
<div className="px-2 py-4 text-sm text-slate-700">
You do not have permission to manage backup classes.{" "}
<Link to="/console" className="text-blue-700 underline hover:text-blue-800">
Back to console
</Link>
.
</div>
</Section>
</div>
)
}

return <Outlet />
}
Loading
Loading