Skip to content
This repository was archived by the owner on Jun 26, 2026. It is now read-only.

Commit 8722855

Browse files
kvapsclaude
andcommitted
feat(console): move Cluster Usage and Backups into a gated Admin portal
Add an Admin top-nav entry (Marketplace / Console / Admin) with its own sidebar (Cluster Usage, Backups), and relocate both features out of the tenant-facing Console section. The Admin tab and the /admin/* routes are gated by a nodes/list SelfSubjectAccessReview: the tab is hidden for non-operators and direct-URL access renders a 403 notice while the review is still resolving shows a spinner. Internal backup links are repointed from /console/backups to /admin/backups. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
1 parent 0ce6e02 commit 8722855

13 files changed

Lines changed: 328 additions & 132 deletions

apps/console/src/App.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import { TenantProvider } from "./lib/tenant-context.tsx"
44
import { Breadcrumb } from "./components/Breadcrumb.tsx"
55
import { MarketplacePage } from "./routes/MarketplacePage.tsx"
66
import { ConsolePage } from "./routes/ConsolePage.tsx"
7+
import { AdminPage } from "./routes/AdminPage.tsx"
78
import {
9+
useAdminSidebarSections,
10+
useCanSeeAdmin,
811
useConsoleSidebarSections,
912
useMarketplaceSidebarSections,
1013
} from "./routes/sidebar-sections.tsx"
14+
import type { HeaderTab } from "@cozystack/ui"
1115
import { CommandPaletteProvider, useCommandPalette } from "./components/command-palette/command-palette-provider.tsx"
1216
import { CommandPalette } from "./components/command-palette/command-palette.tsx"
1317
import type { AppConfig } from "./lib/config.ts"
@@ -20,13 +24,27 @@ interface ShellProps {
2024
function Shell({ config, username }: ShellProps) {
2125
const { pathname } = useLocation()
2226
const inMarketplace = pathname.startsWith("/marketplace")
27+
const inAdmin = pathname.startsWith("/admin")
2328
const marketplaceSections = useMarketplaceSidebarSections()
2429
const consoleSections = useConsoleSidebarSections()
25-
const sections = inMarketplace ? marketplaceSections : consoleSections
30+
const adminSections = useAdminSidebarSections()
31+
const canSeeAdmin = useCanSeeAdmin()
32+
const sections = inAdmin
33+
? adminSections
34+
: inMarketplace
35+
? marketplaceSections
36+
: consoleSections
2637
const { toggle } = useCommandPalette()
2738

39+
const tabs: HeaderTab[] = [
40+
{ id: "marketplace", label: "Marketplace", to: "/marketplace", highlight: true },
41+
{ id: "console", label: "Console", to: "/console" },
42+
...(canSeeAdmin ? [{ id: "admin", label: "Admin", to: "/admin" }] : []),
43+
]
44+
2845
return (
2946
<AppShell
47+
tabs={tabs}
3048
sections={sections}
3149
subtitle={<Breadcrumb />}
3250
onSearchClick={toggle}
@@ -40,6 +58,7 @@ function Shell({ config, username }: ShellProps) {
4058
<Route path="/" element={<Navigate to="/marketplace" replace />} />
4159
<Route path="/marketplace/*" element={<MarketplacePage />} />
4260
<Route path="/console/*" element={<ConsolePage />} />
61+
<Route path="/admin/*" element={<AdminPage />} />
4362
</Routes>
4463
</AppShell>
4564
)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect, vi } from "vitest"
2+
import { screen } from "@testing-library/react"
3+
import {
4+
K8sClient,
5+
type K8sList,
6+
type APIGroupList,
7+
} from "@cozystack/k8s-client"
8+
import { AdminPage } from "./AdminPage.tsx"
9+
import { renderWithK8sProvider } from "../test-utils/render.tsx"
10+
11+
/**
12+
* @param adminAllowed result of the nodes/list SelfSubjectAccessReview that
13+
* gates the whole Admin portal (useAdminAccess).
14+
*/
15+
function makeClient(adminAllowed: boolean): K8sClient {
16+
const client = new K8sClient()
17+
vi.spyOn(client, "list").mockImplementation(async (_g, _v, plural) => {
18+
return {
19+
apiVersion: "v1",
20+
kind: `${plural}List`,
21+
metadata: {},
22+
items: [],
23+
} as K8sList<unknown>
24+
})
25+
vi.spyOn(client, "getApiGroups").mockResolvedValue({
26+
kind: "APIGroupList",
27+
apiVersion: "v1",
28+
groups: [],
29+
} as APIGroupList)
30+
vi.spyOn(client, "create").mockResolvedValue({
31+
apiVersion: "authorization.k8s.io/v1",
32+
kind: "SelfSubjectAccessReview",
33+
metadata: { name: "" },
34+
spec: {},
35+
status: { allowed: adminAllowed },
36+
} as unknown)
37+
return client
38+
}
39+
40+
describe("AdminPage routing & access gate", () => {
41+
it("renders the Cluster Usage page at /cluster-usage for an operator", async () => {
42+
renderWithK8sProvider(<AdminPage />, {
43+
client: makeClient(true),
44+
initialRoute: "/cluster-usage",
45+
})
46+
expect(await screen.findByText("Cluster Usage")).toBeInTheDocument()
47+
})
48+
49+
it("redirects the index route to Cluster Usage for an operator", async () => {
50+
renderWithK8sProvider(<AdminPage />, {
51+
client: makeClient(true),
52+
initialRoute: "/",
53+
})
54+
expect(await screen.findByText("Cluster Usage")).toBeInTheDocument()
55+
})
56+
57+
it("blocks direct access with a 403 notice when the user is not an operator", async () => {
58+
renderWithK8sProvider(<AdminPage />, {
59+
client: makeClient(false),
60+
initialRoute: "/cluster-usage",
61+
})
62+
expect(
63+
await screen.findByText(/you do not have permission to access the admin portal/i),
64+
).toBeInTheDocument()
65+
})
66+
})
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Link, Navigate, Route, Routes } from "react-router"
2+
import { Section, Spinner } from "@cozystack/ui"
3+
import { useAdminAccess } from "./sidebar-sections.tsx"
4+
import { ClusterUsagePage } from "./ClusterUsagePage.tsx"
5+
import { ClusterUsageResourcePage } from "./ClusterUsageResourcePage.tsx"
6+
import { BackupResourceListPage } from "./BackupResourceListPage.tsx"
7+
import { BackupResourceEditPage } from "./BackupResourceEditPage.tsx"
8+
import { BackupPlanCreatePage } from "./BackupPlanCreatePage.tsx"
9+
import { BackupJobCreatePage } from "./BackupJobCreatePage.tsx"
10+
import { BackupCreatePage } from "./BackupCreatePage.tsx"
11+
import { BackupRestoreJobCreatePage } from "./BackupRestoreJobCreatePage.tsx"
12+
13+
/**
14+
* Admin portal: cluster-wide operator views (Cluster Usage and Backups),
15+
* moved out of the tenant-facing Console section. Mounted at /admin/* and
16+
* gated by useAdminAccess (nodes/list) — the shell hides the nav tab for
17+
* non-operators, and this guard blocks direct-URL access too: while the
18+
* access review is in flight we show a spinner, and a denied review
19+
* renders a 403 notice instead of leaking any admin screen.
20+
*/
21+
export function AdminPage() {
22+
const { allowed, isLoading } = useAdminAccess()
23+
24+
if (isLoading) {
25+
return (
26+
<div className="flex items-center gap-2 p-6 text-sm text-slate-500">
27+
<Spinner /> Loading…
28+
</div>
29+
)
30+
}
31+
32+
if (!allowed) {
33+
return (
34+
<div className="p-6">
35+
<Section>
36+
<div className="px-2 py-4 text-sm text-slate-700">
37+
You do not have permission to access the Admin portal.{" "}
38+
<Link to="/console" className="text-blue-700 underline hover:text-blue-800">
39+
Back to console
40+
</Link>
41+
.
42+
</div>
43+
</Section>
44+
</div>
45+
)
46+
}
47+
48+
return (
49+
<Routes>
50+
<Route index element={<Navigate to="cluster-usage" replace />} />
51+
<Route path="cluster-usage" element={<ClusterUsagePage />} />
52+
<Route path="cluster-usage/r/*" element={<ClusterUsageResourcePage />} />
53+
<Route
54+
path="backups/plans"
55+
element={<BackupResourceListPage resourceType="plans" title="Plans" />}
56+
/>
57+
<Route path="backups/plans/create" element={<BackupPlanCreatePage />} />
58+
<Route
59+
path="backups/plans/:name/edit"
60+
element={<BackupResourceEditPage resourceType="plans" title="Plans" />}
61+
/>
62+
<Route
63+
path="backups/backupjobs"
64+
element={<BackupResourceListPage resourceType="backupjobs" title="Backup Jobs" />}
65+
/>
66+
<Route path="backups/backupjobs/create" element={<BackupJobCreatePage />} />
67+
<Route
68+
path="backups/backupjobs/:name/edit"
69+
element={<BackupResourceEditPage resourceType="backupjobs" title="Backup Jobs" />}
70+
/>
71+
<Route
72+
path="backups/backups"
73+
element={<BackupResourceListPage resourceType="backups" title="Backups" />}
74+
/>
75+
<Route path="backups/backups/create" element={<BackupCreatePage />} />
76+
<Route
77+
path="backups/backups/:name/edit"
78+
element={<BackupResourceEditPage resourceType="backups" title="Backups" />}
79+
/>
80+
<Route
81+
path="backups/restorejobs"
82+
element={<BackupResourceListPage resourceType="restorejobs" title="Restore Jobs" />}
83+
/>
84+
<Route path="backups/restorejobs/create" element={<BackupRestoreJobCreatePage />} />
85+
<Route
86+
path="backups/restorejobs/:name/edit"
87+
element={<BackupResourceEditPage resourceType="restorejobs" title="Restore Jobs" />}
88+
/>
89+
</Routes>
90+
)
91+
}

apps/console/src/routes/BackupCreatePage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,14 @@ export function BackupCreatePage() {
111111

112112
try {
113113
await createMutation.mutateAsync(resource)
114-
navigate("/console/backups/backups")
114+
navigate("/admin/backups/backups")
115115
} catch (err) {
116116
alert(`Failed to create Backup: ${(err as Error).message}`)
117117
}
118118
}
119119

120120
const handleCancel = () => {
121-
navigate("/console/backups/backups")
121+
navigate("/admin/backups/backups")
122122
}
123123

124124
if (schemaLoading) {

apps/console/src/routes/BackupJobCreatePage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,14 @@ export function BackupJobCreatePage() {
155155

156156
try {
157157
await createMutation.mutateAsync(resource)
158-
navigate("/console/backups/backupjobs")
158+
navigate("/admin/backups/backupjobs")
159159
} catch (err) {
160160
alert(`Failed to create BackupJob: ${(err as Error).message}`)
161161
}
162162
}
163163

164164
const handleCancel = () => {
165-
navigate("/console/backups/backupjobs")
165+
navigate("/admin/backups/backupjobs")
166166
}
167167

168168
if (schemaLoading) {

apps/console/src/routes/BackupPlanCreatePage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,14 +140,14 @@ export function BackupPlanCreatePage() {
140140

141141
try {
142142
await createMutation.mutateAsync(resource)
143-
navigate("/console/backups/plans")
143+
navigate("/admin/backups/plans")
144144
} catch (err) {
145145
alert(`Failed to create Plan: ${(err as Error).message}`)
146146
}
147147
}
148148

149149
const handleCancel = () => {
150-
navigate("/console/backups/plans")
150+
navigate("/admin/backups/plans")
151151
}
152152

153153
if (schemaLoading) {

apps/console/src/routes/BackupResourceEditPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,14 @@ export function BackupResourceEditPage({
8787

8888
try {
8989
await updateMutation.mutateAsync(updated)
90-
navigate(`/console/backups/${resourceType}`)
90+
navigate(`/admin/backups/${resourceType}`)
9191
} catch (err) {
9292
alert(`Failed to update ${title.slice(0, -1)}: ${(err as Error).message}`)
9393
}
9494
}
9595

9696
const handleCancel = () => {
97-
navigate(`/console/backups/${resourceType}`)
97+
navigate(`/admin/backups/${resourceType}`)
9898
}
9999

100100
if (schemaLoading || resourceLoading) {

apps/console/src/routes/BackupResourceListPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export function BackupResourceListPage({ resourceType, title }: BackupResourceLi
9191
</div>
9292
</div>
9393
{resourceType !== "backups" && (
94-
<Link to={`/console/backups/${resourceType}/create`}>
94+
<Link to={`/admin/backups/${resourceType}/create`}>
9595
<Button variant="primary" size="sm">
9696
<Plus className="size-3.5" /> Create {title.slice(0, -1)}
9797
</Button>
@@ -158,7 +158,7 @@ export function BackupResourceListPage({ resourceType, title }: BackupResourceLi
158158
<td className="px-5 py-3 text-right">
159159
<div className="flex items-center justify-end gap-2">
160160
<Link
161-
to={`/console/backups/${resourceType}/${item.metadata.name}/edit`}
161+
to={`/admin/backups/${resourceType}/${item.metadata.name}/edit`}
162162
>
163163
<Button variant="outline" size="sm">
164164
<Edit className="size-3.5" /> Edit

apps/console/src/routes/BackupRestoreJobCreatePage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,14 @@ export function BackupRestoreJobCreatePage() {
172172

173173
try {
174174
await createMutation.mutateAsync(resource)
175-
navigate("/console/backups/restorejobs")
175+
navigate("/admin/backups/restorejobs")
176176
} catch (err) {
177177
alert(`Failed to create RestoreJob: ${(err as Error).message}`)
178178
}
179179
}
180180

181181
const handleCancel = () => {
182-
navigate("/console/backups/restorejobs")
182+
navigate("/admin/backups/restorejobs")
183183
}
184184

185185
if (schemaLoading) {

apps/console/src/routes/ConsolePage.routing.test.tsx

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { describe, it, expect, vi } from "vitest"
2-
import { screen } from "@testing-library/react"
1+
import { describe, it, expect, vi, beforeAll } from "vitest"
2+
import { screen, waitFor } from "@testing-library/react"
33
import {
44
K8sClient,
55
type K8sList,
66
type APIGroupList,
77
} from "@cozystack/k8s-client"
88
import { ConsolePage } from "./ConsolePage.tsx"
9+
import { TenantProvider } from "../lib/tenant-context.tsx"
910
import { renderWithK8sProvider } from "../test-utils/render.tsx"
1011

1112
function makeClient(): K8sClient {
@@ -41,13 +42,35 @@ function makeClient(): K8sClient {
4142
return client
4243
}
4344

45+
// TenantProvider reads window.localStorage on mount; provide a minimal
46+
// in-memory shim for the test environment when one is not present.
47+
beforeAll(() => {
48+
if (typeof globalThis.localStorage?.getItem !== "function") {
49+
const store = new Map<string, string>()
50+
vi.stubGlobal("localStorage", {
51+
getItem: (k: string) => store.get(k) ?? null,
52+
setItem: (k: string, v: string) => void store.set(k, v),
53+
removeItem: (k: string) => void store.delete(k),
54+
clear: () => store.clear(),
55+
})
56+
}
57+
})
58+
4459
describe("ConsolePage routing", () => {
45-
it("renders ClusterUsagePage at /cluster-usage", async () => {
60+
it("no longer serves the Cluster Usage page under console (moved to /admin)", async () => {
4661
const client = makeClient()
47-
renderWithK8sProvider(<ConsolePage />, {
48-
client,
49-
initialRoute: "/cluster-usage",
50-
})
51-
expect(await screen.findByText("Cluster Usage")).toBeInTheDocument()
62+
renderWithK8sProvider(
63+
<TenantProvider>
64+
<ConsolePage />
65+
</TenantProvider>,
66+
{
67+
client,
68+
initialRoute: "/cluster-usage",
69+
},
70+
)
71+
// "cluster-usage" now falls through to the generic :plural list route, so
72+
// the Cluster Usage page's unique subtitle must not appear.
73+
await waitFor(() => expect(client.list).toHaveBeenCalled())
74+
expect(screen.queryByText(/Cluster-scoped capacity/i)).toBeNull()
5275
})
5376
})

0 commit comments

Comments
 (0)