|
| 1 | +import { useState } from 'react' |
| 2 | +import { clsx } from 'clsx' |
| 3 | +import { Shield, ChevronDown, Copy, Check } from 'lucide-react' |
| 4 | + |
| 5 | +// RestrictedState is the shared "you can't see this because of Kubernetes RBAC" |
| 6 | +// surface — distinct from EmptyState (which covers healthy / filtered / no-data). |
| 7 | +// It never claims WHY the SAR failed (Radar can't prove tier vs custom-role vs |
| 8 | +// disabled-value from a denied check); it states the fact and hands the operator |
| 9 | +// something to forward to whoever administers their cluster. |
| 10 | +// |
| 11 | +// Reused across the resource list, topology, search, and the per-cluster access |
| 12 | +// summary so the messaging can't drift. |
| 13 | + |
| 14 | +interface Props { |
| 15 | + /** Display kind, e.g. "Node". */ |
| 16 | + kindLabel: string |
| 17 | + /** API group for the kind ("" for core). Used to build the example RBAC. */ |
| 18 | + group?: string |
| 19 | + /** Plural resource name, e.g. "nodes". When omitted the snippet uses a |
| 20 | + * placeholder the admin fills in. */ |
| 21 | + resource?: string |
| 22 | + /** Why the kind is hidden. "rbac_denied" (default): Radar can read it but the |
| 23 | + * user's RBAC can't — show the grant request. "unavailable": Radar's |
| 24 | + * ServiceAccount can't read it at all (not installed / SA RBAC / feature off) |
| 25 | + * — a user grant won't help, so show a different message and no snippet. */ |
| 26 | + reason?: 'rbac_denied' | 'unavailable' | string |
| 27 | + /** Tightens spacing for inline/embedded use (topology overlay, etc.). */ |
| 28 | + compact?: boolean |
| 29 | + className?: string |
| 30 | +} |
| 31 | + |
| 32 | +function buildRbacRequest(group: string, resource: string): string { |
| 33 | + // resource is the placeholder when we don't have a confident API plural |
| 34 | + // (e.g. a CRD deep-link before discovery resolves the kind) — use a generic |
| 35 | + // role name rather than radar-read-<Kind>. |
| 36 | + const roleName = resource === '<resource>' ? 'radar-read-access' : `radar-read-${resource}` |
| 37 | + return [ |
| 38 | + `apiVersion: rbac.authorization.k8s.io/v1`, |
| 39 | + `kind: ClusterRole`, |
| 40 | + `metadata:`, |
| 41 | + ` name: ${roleName}`, |
| 42 | + `rules:`, |
| 43 | + ` - apiGroups: ["${group}"]`, |
| 44 | + ` resources: ["${resource}"]`, |
| 45 | + ` verbs: ["get", "list", "watch"]`, |
| 46 | + `---`, |
| 47 | + `apiVersion: rbac.authorization.k8s.io/v1`, |
| 48 | + `kind: ClusterRoleBinding`, |
| 49 | + `metadata:`, |
| 50 | + ` name: ${roleName}`, |
| 51 | + `roleRef:`, |
| 52 | + ` apiGroup: rbac.authorization.k8s.io`, |
| 53 | + ` kind: ClusterRole`, |
| 54 | + ` name: ${roleName}`, |
| 55 | + `subjects:`, |
| 56 | + ` - kind: Group # or User / ServiceAccount`, |
| 57 | + ` name: <your-identity>`, |
| 58 | + ` apiGroup: rbac.authorization.k8s.io`, |
| 59 | + ].join('\n') |
| 60 | +} |
| 61 | + |
| 62 | +export function RestrictedState({ kindLabel, group = '', resource, reason, compact, className }: Props) { |
| 63 | + const [expanded, setExpanded] = useState(false) |
| 64 | + const [copied, setCopied] = useState(false) |
| 65 | + const isUnavailable = reason === 'unavailable' |
| 66 | + |
| 67 | + // RBAC `resources` must be the lowercase API plural. selectedKind.name is |
| 68 | + // that in normal use, but a CRD deep-link can transiently carry the Kind |
| 69 | + // (e.g. "HTTPRoute") before discovery resolves it — emitting that would |
| 70 | + // produce a snippet that doesn't grant access. Only inline the resource when |
| 71 | + // it looks like a valid resource name; otherwise leave a clear placeholder. |
| 72 | + const validResource = resource && /^[a-z0-9.-]+$/.test(resource) ? resource : '<resource>' |
| 73 | + const snippet = buildRbacRequest(group, validResource) |
| 74 | + |
| 75 | + const copy = () => { |
| 76 | + navigator.clipboard.writeText(snippet).then( |
| 77 | + () => { |
| 78 | + setCopied(true) |
| 79 | + setTimeout(() => setCopied(false), 2000) |
| 80 | + }, |
| 81 | + () => {}, |
| 82 | + ) |
| 83 | + } |
| 84 | + |
| 85 | + return ( |
| 86 | + <div |
| 87 | + className={clsx( |
| 88 | + 'flex flex-col items-center justify-center text-center text-theme-text-tertiary', |
| 89 | + compact ? 'p-4' : 'p-6', |
| 90 | + className, |
| 91 | + )} |
| 92 | + > |
| 93 | + <Shield className="w-8 h-8 text-amber-400 mb-2" /> |
| 94 | + {isUnavailable ? ( |
| 95 | + // Radar's ServiceAccount can't read this kind — a user grant won't help. |
| 96 | + <> |
| 97 | + <p className="text-theme-text-secondary font-medium">{kindLabel} isn't available here</p> |
| 98 | + <p className="text-sm mt-1 max-w-md"> |
| 99 | + Radar can't read {kindLabel} resources in this cluster — the type may not be installed, |
| 100 | + or read access isn't granted to Radar's ServiceAccount (some kinds, like RBAC objects |
| 101 | + and Secrets, are off unless enabled in the Radar chart). Granting your own identity |
| 102 | + access won't surface it. |
| 103 | + </p> |
| 104 | + </> |
| 105 | + ) : ( |
| 106 | + <> |
| 107 | + <p className="text-theme-text-secondary font-medium">You don't have access to {kindLabel}</p> |
| 108 | + <p className="text-sm mt-1 max-w-md"> |
| 109 | + Your Kubernetes RBAC doesn't allow listing {kindLabel} resources in this cluster. This |
| 110 | + isn't an empty cluster — Radar is hiding what your identity can't read. |
| 111 | + </p> |
| 112 | + |
| 113 | + <div className="mt-3 w-full max-w-md"> |
| 114 | + <button |
| 115 | + onClick={() => setExpanded((v) => !v)} |
| 116 | + className="flex items-center gap-1.5 mx-auto text-sm text-theme-text-secondary hover:text-theme-text-primary transition-colors" |
| 117 | + > |
| 118 | + <ChevronDown className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-180')} /> |
| 119 | + How to get access |
| 120 | + </button> |
| 121 | + |
| 122 | + {expanded && ( |
| 123 | + <div className="mt-2 text-left"> |
| 124 | + <p className="text-xs text-theme-text-tertiary mb-2"> |
| 125 | + Apply this, or send it to whoever administers your cluster, to grant your identity |
| 126 | + read access. |
| 127 | + </p> |
| 128 | + <div className="rounded-md border border-theme-border bg-theme-base overflow-hidden"> |
| 129 | + <div className="flex items-center justify-between px-3 py-1.5 border-b border-theme-border bg-theme-elevated"> |
| 130 | + <span className="text-xs text-theme-text-tertiary"> |
| 131 | + ClusterRole + ClusterRoleBinding |
| 132 | + </span> |
| 133 | + <button |
| 134 | + onClick={copy} |
| 135 | + className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary transition-colors" |
| 136 | + > |
| 137 | + {copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} |
| 138 | + {copied ? 'Copied' : 'Copy'} |
| 139 | + </button> |
| 140 | + </div> |
| 141 | + <pre className="text-xs p-3 max-h-72 overflow-auto text-theme-text-secondary"> |
| 142 | + {snippet} |
| 143 | + </pre> |
| 144 | + </div> |
| 145 | + </div> |
| 146 | + )} |
| 147 | + </div> |
| 148 | + </> |
| 149 | + )} |
| 150 | + </div> |
| 151 | + ) |
| 152 | +} |
0 commit comments