From 7ffc61f7ff2f1c0a43d49cd74eb4c3cbee7763d2 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Tue, 23 Jun 2026 15:00:35 +0300 Subject: [PATCH 1/2] feat(ui): actionable RestrictedState for RBAC-denied resource lists The forbidden empty state was a bare 'Access Restricted / Insufficient permissions'. Replace with a reusable RestrictedState component: plain-language framing for a non-k8s operator (states it's RBAC, not an empty cluster), and a 'How to get access' disclosure with a generic ClusterRole/ClusterRoleBinding snippet + 'Copy request' button to forward to whoever administers the cluster. Honest about cause (never claims tier vs custom-role) and chart-version-agnostic (generic YAML). --- .../components/resources/ResourcesView.tsx | 11 +- .../src/components/ui/RestrictedState.tsx | 120 ++++++++++++++++++ packages/k8s-ui/src/components/ui/index.ts | 1 + 3 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 packages/k8s-ui/src/components/ui/RestrictedState.tsx diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx index b4e61d588..fe2a00b96 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx @@ -2,6 +2,7 @@ import React, { useState, useMemo, useEffect, useCallback, useRef, useContext, u import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso' import { useRefreshAnimation } from '../../hooks/useRefreshAnimation' import { PaneLoader } from '../ui/PaneLoader' +import { RestrictedState } from '../ui/RestrictedState' import type { TopPodMetrics, TopNodeMetrics } from '../../types' import { Search, @@ -4505,10 +4506,12 @@ export function ResourcesView({ {isLoading ? ( ) : isSelectedForbidden ? ( -
- -

Access Restricted

-

Insufficient permissions to list {selectedKind.kind} resources

+
+
) : largeListGuard ? (
diff --git a/packages/k8s-ui/src/components/ui/RestrictedState.tsx b/packages/k8s-ui/src/components/ui/RestrictedState.tsx new file mode 100644 index 000000000..2bbd562a3 --- /dev/null +++ b/packages/k8s-ui/src/components/ui/RestrictedState.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react' +import { clsx } from 'clsx' +import { Shield, ChevronDown, Copy, Check } from 'lucide-react' + +// RestrictedState is the shared "you can't see this because of Kubernetes RBAC" +// surface — distinct from EmptyState (which covers healthy / filtered / no-data). +// It never claims WHY the SAR failed (Radar can't prove tier vs custom-role vs +// disabled-value from a denied check); it states the fact and hands the operator +// something to forward to whoever administers their cluster. +// +// Reused across the resource list, topology, search, and the per-cluster access +// summary so the messaging can't drift. + +interface Props { + /** Display kind, e.g. "Node". */ + kindLabel: string + /** API group for the kind ("" for core). Used to build the example RBAC. */ + group?: string + /** Plural resource name, e.g. "nodes". When omitted the snippet uses a + * placeholder the admin fills in. */ + resource?: string + /** Tightens spacing for inline/embedded use (topology overlay, etc.). */ + compact?: boolean + className?: string +} + +function buildRbacRequest(kindLabel: string, group: string, resource: string): string { + const roleName = `radar-read-${resource}` + return [ + `I need read access to ${kindLabel} in this Kubernetes cluster via Radar.`, + `Please grant my identity (user, group, or ServiceAccount) get/list/watch on "${resource}".`, + ``, + `apiVersion: rbac.authorization.k8s.io/v1`, + `kind: ClusterRole`, + `metadata:`, + ` name: ${roleName}`, + `rules:`, + ` - apiGroups: ["${group}"]`, + ` resources: ["${resource}"]`, + ` verbs: ["get", "list", "watch"]`, + `---`, + `apiVersion: rbac.authorization.k8s.io/v1`, + `kind: ClusterRoleBinding`, + `metadata:`, + ` name: ${roleName}`, + `roleRef:`, + ` apiGroup: rbac.authorization.k8s.io`, + ` kind: ClusterRole`, + ` name: ${roleName}`, + `subjects:`, + ` - kind: Group # or User / ServiceAccount`, + ` name: `, + ` apiGroup: rbac.authorization.k8s.io`, + ].join('\n') +} + +export function RestrictedState({ kindLabel, group = '', resource, compact, className }: Props) { + const [expanded, setExpanded] = useState(false) + const [copied, setCopied] = useState(false) + + const snippet = buildRbacRequest(kindLabel, group, resource || '') + + const copy = () => { + navigator.clipboard.writeText(snippet).then( + () => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, + () => {}, + ) + } + + return ( +
+ +

You don't have access to {kindLabel}

+

+ Your Kubernetes RBAC doesn't allow listing {kindLabel} in this cluster. This isn't an empty + cluster — Radar is hiding what your identity can't read. +

+ +
+ + + {expanded && ( +
+

+ Send this to whoever administers your cluster (the person who manages your Radar + install or your cluster RBAC) — it asks them to grant your identity read access. +

+
+
+                {snippet}
+              
+ +
+
+ )} +
+
+ ) +} diff --git a/packages/k8s-ui/src/components/ui/index.ts b/packages/k8s-ui/src/components/ui/index.ts index 9e31cb861..6b954c944 100644 --- a/packages/k8s-ui/src/components/ui/index.ts +++ b/packages/k8s-ui/src/components/ui/index.ts @@ -5,6 +5,7 @@ export { MiddleEllipsis } from './MiddleEllipsis' export type { MiddleEllipsisProps } from './MiddleEllipsis' export { EmptyState } from './EmptyState' export type { EmptyStateTone, EmptyStateVariant } from './EmptyState' +export { RestrictedState } from './RestrictedState' export { FetchResult } from './FetchResult' export { SearchBox } from './SearchBox' export { FilterPill } from './FilterPill' From 75abdf4b5183492087afb0ceb69c0b3486bdc50e Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 24 Jun 2026 00:55:32 +0300 Subject: [PATCH 2/2] fix(ui): RestrictedState scroll + accurate RBAC resource Bugbot triage on the forbidden-state component: - Expanded RBAC snippet could clip on short panes (absolute inset-0 centered, no scroll). Wrap in overflow-y-auto + min-h-full so it centers when collapsed and scrolls (no top clip) when expanded; cap the snippet at max-h-72. - selectedKind.name is the API plural in normal use but a CRD deep-link can transiently carry the Kind (HTTPRoute) before discovery resolves it, yielding a snippet that wouldn't grant access. Only inline the resource when it looks like a valid lowercase resource name; otherwise use a placeholder and a generic role name. --- .../src/components/resources/ResourcesView.tsx | 17 +++++++++++------ .../src/components/ui/RestrictedState.tsx | 15 ++++++++++++--- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/ResourcesView.tsx b/packages/k8s-ui/src/components/resources/ResourcesView.tsx index fe2a00b96..bbc67fedf 100644 --- a/packages/k8s-ui/src/components/resources/ResourcesView.tsx +++ b/packages/k8s-ui/src/components/resources/ResourcesView.tsx @@ -4506,12 +4506,17 @@ export function ResourcesView({ {isLoading ? ( ) : isSelectedForbidden ? ( -
- + // overflow-y-auto + min-h-full: centered when the panel is short + // (collapsed), scrollable (no top clip) when the RBAC snippet is + // expanded on a shorter pane. +
+
+ +
) : largeListGuard ? (
diff --git a/packages/k8s-ui/src/components/ui/RestrictedState.tsx b/packages/k8s-ui/src/components/ui/RestrictedState.tsx index 2bbd562a3..5793f30c5 100644 --- a/packages/k8s-ui/src/components/ui/RestrictedState.tsx +++ b/packages/k8s-ui/src/components/ui/RestrictedState.tsx @@ -25,7 +25,10 @@ interface Props { } function buildRbacRequest(kindLabel: string, group: string, resource: string): string { - const roleName = `radar-read-${resource}` + // resource is the placeholder when we don't have a confident API plural + // (e.g. a CRD deep-link before discovery resolves the kind) — use a generic + // role name rather than radar-read-. + const roleName = resource === '' ? 'radar-read-access' : `radar-read-${resource}` return [ `I need read access to ${kindLabel} in this Kubernetes cluster via Radar.`, `Please grant my identity (user, group, or ServiceAccount) get/list/watch on "${resource}".`, @@ -58,7 +61,13 @@ export function RestrictedState({ kindLabel, group = '', resource, compact, clas const [expanded, setExpanded] = useState(false) const [copied, setCopied] = useState(false) - const snippet = buildRbacRequest(kindLabel, group, resource || '') + // RBAC `resources` must be the lowercase API plural. selectedKind.name is + // that in normal use, but a CRD deep-link can transiently carry the Kind + // (e.g. "HTTPRoute") before discovery resolves it — emitting that would + // produce a snippet that doesn't grant access. Only inline the resource when + // it looks like a valid resource name; otherwise leave a clear placeholder. + const validResource = resource && /^[a-z0-9.-]+$/.test(resource) ? resource : '' + const snippet = buildRbacRequest(kindLabel, group, validResource) const copy = () => { navigator.clipboard.writeText(snippet).then( @@ -101,7 +110,7 @@ export function RestrictedState({ kindLabel, group = '', resource, compact, clas install or your cluster RBAC) — it asks them to grant your identity read access.

-
+              
                 {snippet}