@skyhook-io/k8s-ui 1.6.1 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/AreaChart.tsx +371 -0
  3. package/src/components/charts/MetricsSummary.tsx +49 -0
  4. package/src/components/charts/SeriesLegend.tsx +27 -0
  5. package/src/components/charts/colors.ts +49 -0
  6. package/src/components/charts/format.ts +41 -0
  7. package/src/components/charts/index.ts +14 -0
  8. package/src/components/charts/saturation.test.ts +60 -0
  9. package/src/components/charts/saturation.ts +32 -0
  10. package/src/components/charts/types.ts +36 -0
  11. package/src/components/compare/CompareResourcePicker.tsx +192 -0
  12. package/src/components/compare/CompareTray.tsx +130 -0
  13. package/src/components/compare/ResourceCompareView.tsx +272 -0
  14. package/src/components/compare/index.ts +14 -0
  15. package/src/components/compare/normalize.test.ts +193 -0
  16. package/src/components/compare/normalize.ts +69 -0
  17. package/src/components/compare/picks.test.ts +97 -0
  18. package/src/components/compare/picks.ts +35 -0
  19. package/src/components/compare/sort.test.ts +78 -0
  20. package/src/components/compare/sort.ts +26 -0
  21. package/src/components/compare/types.ts +43 -0
  22. package/src/components/compare/url.test.ts +61 -0
  23. package/src/components/compare/url.ts +26 -0
  24. package/src/components/resources/ResourcesView.tsx +338 -57
  25. package/src/components/resources/index.ts +1 -0
  26. package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
  27. package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
  28. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
  29. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
  30. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  31. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
  32. package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
  33. package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
  34. package/src/components/resources/renderers/PodRenderer.tsx +207 -2
  35. package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
  36. package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
  37. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
  38. package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
  39. package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
  40. package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
  41. package/src/components/resources/renderers/flux-cells.tsx +12 -0
  42. package/src/components/resources/renderers/index.ts +8 -0
  43. package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
  44. package/src/components/resources/resource-utils-crossplane.ts +325 -0
  45. package/src/components/resources/resource-utils-flux.ts +14 -0
  46. package/src/components/resources/resource-utils.ts +1 -0
  47. package/src/components/resources/resources-column-filter.test.ts +74 -0
  48. package/src/components/shared/ResourceActionsBar.tsx +77 -0
  49. package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
  50. package/src/components/ui/YamlEditor.tsx +28 -15
  51. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  52. package/src/index.ts +3 -0
  53. package/src/types/index.ts +1 -0
  54. package/src/types/rbac.ts +99 -0
  55. package/src/utils/api-resources.ts +9 -1
  56. package/src/utils/index.ts +1 -0
  57. package/src/utils/rbac-badges.ts +61 -0
  58. package/src/utils/rbac-blast-radius.test.ts +137 -0
  59. package/src/utils/rbac-blast-radius.ts +98 -0
@@ -1,11 +1,64 @@
1
- import { UserCog, Key, Cloud } from 'lucide-react'
2
- import { Section, PropertyList, Property } from '../../ui/drawer-components'
1
+ import { useState } from 'react'
2
+ import { UserCog, Key, Cloud, Shield, Users, AlertTriangle, Box } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import { Section, PropertyList, Property, ResourceLink, AlertBanner } from '../../ui/drawer-components'
5
+ import type { ResourceRef } from '../../../types'
6
+ import type {
7
+ RBACSubjectResponse,
8
+ RBACBindingRules,
9
+ RBACPolicyRule,
10
+ RBACInheritedGroup,
11
+ } from '../../../types'
12
+ import {
13
+ rbacVerbBadgeClass,
14
+ rbacResourceBadgeClass,
15
+ rbacApiGroupBadgeClass,
16
+ rbacKindBadgeClass,
17
+ } from '../../../utils/rbac-badges'
18
+ import { RBAC_BLAST_ESCALATION_VERBS } from '../../../utils/rbac-blast-radius'
19
+ import { BADGE_SEVERITY_COLORS } from '../../ui/Badge'
3
20
 
4
21
  interface ServiceAccountRendererProps {
5
22
  data: any
23
+ /** RBAC reverse-lookup data fetched from /api/rbac/subject/ServiceAccount/{ns}/{name}.
24
+ * Undefined while loading; null when the fetch failed (renderer shows a tactful
25
+ * fallback). When omitted entirely (consumer didn't wire the fetch) the
26
+ * Bindings/Permissions sections are simply not rendered. */
27
+ rbacData?: RBACSubjectResponse | null
28
+ rbacLoading?: boolean
29
+ rbacError?: Error | null
30
+ /** Navigation callback for ResourceLinks pointing at bindings / roles. */
31
+ onNavigate?: (ref: ResourceRef) => void
6
32
  }
7
33
 
8
- export function ServiceAccountRenderer({ data }: ServiceAccountRendererProps) {
34
+ // "Dangerous" specifically not the same as "permissive". Resource-wildcards
35
+ // with read-only verbs (e.g. `view` granting list/watch on `*` within a CRD
36
+ // group) are not alarming and were creating banner-fatigue on every SA. We
37
+ // only flag what a compromised SA could *act on*: verb wildcards, escalation
38
+ // verbs (escalate/bind/impersonate), and the cluster-admin role.
39
+ //
40
+ // Distinct from detectBlastRadius (utils/rbac-blast-radius.ts) — the SA page
41
+ // scopes to *direct* bindings only and returns boolean (does any direct
42
+ // binding qualify?), whereas the Pod/Workload pages walk direct + inherited
43
+ // and produce per-binding reasons. Sharing the implementation would force
44
+ // one of those to wear the wrong shape.
45
+ function bindingHasDangerousGrant(br: RBACBindingRules): boolean {
46
+ if (br.role.name === 'cluster-admin' && br.role.kind === 'ClusterRole') return true
47
+ return (br.rules ?? []).some((r) => {
48
+ const verbs = r.verbs ?? []
49
+ if (verbs.includes('*')) return true
50
+ if (verbs.some((v) => RBAC_BLAST_ESCALATION_VERBS.has(v))) return true
51
+ return false
52
+ })
53
+ }
54
+
55
+ export function ServiceAccountRenderer({
56
+ data,
57
+ rbacData,
58
+ rbacLoading,
59
+ rbacError,
60
+ onNavigate,
61
+ }: ServiceAccountRendererProps) {
9
62
  const metadata = data.metadata || {}
10
63
  const annotations = metadata.annotations || {}
11
64
  const secrets = data.secrets || []
@@ -20,8 +73,28 @@ export function ServiceAccountRenderer({ data }: ServiceAccountRendererProps) {
20
73
  const azureClientId = annotations['azure.workload.identity/client-id']
21
74
  const hasWorkloadIdentity = gcpServiceAccount || awsRoleArn || azureClientId
22
75
 
76
+ // Risk signal: scoped to *direct* bindings only. Inherited grants come from
77
+ // system groups every SA is in (system:authenticated, system:serviceaccounts),
78
+ // so flagging them here fires the banner on every SA — operators banner-blind
79
+ // and stop trusting the signal. Inherited risk surfaces in the per-binding
80
+ // rows below (the "permissive" chip) instead.
81
+ const dangerousBinding = (rbacData?.direct ?? []).find(bindingHasDangerousGrant)
82
+
23
83
  return (
24
84
  <>
85
+ {dangerousBinding && (
86
+ <AlertBanner
87
+ variant="warning"
88
+ title="Permissive grant detected"
89
+ message={
90
+ `This ServiceAccount is bound to ${dangerousBinding.role.kind} ` +
91
+ `"${dangerousBinding.role.name}", which grants verb wildcards or ` +
92
+ `privilege-escalation verbs. A compromise of this SA — leaked token, ` +
93
+ `compromised Pod — would give an attacker broad cluster access.`
94
+ }
95
+ />
96
+ )}
97
+
25
98
  {/* Configuration */}
26
99
  <Section title="Configuration" icon={UserCog}>
27
100
  <PropertyList>
@@ -39,6 +112,27 @@ export function ServiceAccountRenderer({ data }: ServiceAccountRendererProps) {
39
112
  </PropertyList>
40
113
  </Section>
41
114
 
115
+ {/* RBAC: Direct Bindings + Effective Permissions. Only rendered when
116
+ * the host has wired the fetch (rbacData is not undefined). */}
117
+ {rbacData !== undefined && (
118
+ <RBACSections
119
+ rbacData={rbacData}
120
+ loading={!!rbacLoading}
121
+ error={rbacError ?? null}
122
+ onNavigate={onNavigate}
123
+ />
124
+ )}
125
+
126
+ {/* Pods using this SA — closes the loop. The bindings sections answer
127
+ * "what permissions"; this answers "what's actually running as this".
128
+ * Always rendered for SA subjects, even when the list is empty (the
129
+ * backend's omitempty drops zero-length slices, so we don't gate on
130
+ * the field's presence — `lonely-sa` should still show "No pods
131
+ * are running as this SA" rather than vanish from the page). */}
132
+ {rbacData && rbacData.subject.kind === 'ServiceAccount' && (
133
+ <UsedByPodsSection pods={rbacData.usedByPods ?? []} onNavigate={onNavigate} />
134
+ )}
135
+
42
136
  {/* Workload Identity */}
43
137
  {hasWorkloadIdentity && (
44
138
  <Section title="Workload Identity" icon={Cloud}>
@@ -79,3 +173,362 @@ export function ServiceAccountRenderer({ data }: ServiceAccountRendererProps) {
79
173
  </>
80
174
  )
81
175
  }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // RBAC sections — pulled out so the main renderer body stays readable.
179
+ // ---------------------------------------------------------------------------
180
+
181
+ interface RBACSectionsProps {
182
+ rbacData: RBACSubjectResponse | null
183
+ loading: boolean
184
+ error: Error | null
185
+ onNavigate?: (ref: ResourceRef) => void
186
+ }
187
+
188
+ function RBACSections({ rbacData, loading, error, onNavigate }: RBACSectionsProps) {
189
+ if (loading) {
190
+ return (
191
+ <Section title="Bindings" icon={Shield}>
192
+ <div className="text-sm text-theme-text-tertiary">Loading RBAC graph…</div>
193
+ </Section>
194
+ )
195
+ }
196
+ if (error) {
197
+ return (
198
+ <Section title="Bindings" icon={Shield}>
199
+ <div className="text-sm text-red-400">
200
+ Could not load RBAC data: {error.message}
201
+ </div>
202
+ </Section>
203
+ )
204
+ }
205
+ if (!rbacData) return null
206
+
207
+ const direct = rbacData.direct ?? []
208
+ const inherited = rbacData.inheritedFromGroups ?? []
209
+ const hasAnyBinding =
210
+ direct.length > 0 || inherited.some((g) => g.bindings.length > 0)
211
+
212
+ return (
213
+ <>
214
+ <Section
215
+ title={`Direct Bindings (${direct.length})`}
216
+ icon={Shield}
217
+ defaultExpanded
218
+ >
219
+ {direct.length === 0 ? (
220
+ <div className="text-sm text-theme-text-tertiary">
221
+ No bindings reference this ServiceAccount directly.
222
+ {!hasAnyBinding &&
223
+ ' It has no permissions beyond what implicit group memberships grant (see below).'}
224
+ </div>
225
+ ) : (
226
+ <div className="space-y-2">
227
+ {direct.map((br) => (
228
+ <BindingRow key={br.binding.kind + '/' + br.binding.namespace + '/' + br.binding.name} br={br} onNavigate={onNavigate} />
229
+ ))}
230
+ </div>
231
+ )}
232
+ </Section>
233
+
234
+ {/* Inherited from groups — collapsed by default (the load-bearing
235
+ * choice) so direct bindings stay the focal point. system:authenticated
236
+ * alone can drag in dozens of bindings on a real cluster; expanding
237
+ * them here would crowd out the actually-SA-specific direct bindings
238
+ * above and inflate the perceived blast radius. */}
239
+ {inherited.map((group) => (
240
+ <InheritedGroupSection
241
+ key={group.groupName}
242
+ group={group}
243
+ onNavigate={onNavigate}
244
+ />
245
+ ))}
246
+
247
+ <EffectivePermissionsSection rbacData={rbacData} />
248
+ </>
249
+ )
250
+ }
251
+
252
+ function InheritedGroupSection({
253
+ group,
254
+ onNavigate,
255
+ }: {
256
+ group: RBACInheritedGroup
257
+ onNavigate?: (ref: ResourceRef) => void
258
+ }) {
259
+ if (group.bindings.length === 0) return null
260
+ return (
261
+ <Section
262
+ title={`Inherited via ${group.groupName} (${group.bindings.length})`}
263
+ icon={Users}
264
+ defaultExpanded={false}
265
+ >
266
+ <div className="space-y-2">
267
+ {group.bindings.map((br) => (
268
+ <BindingRow
269
+ key={br.binding.kind + '/' + br.binding.namespace + '/' + br.binding.name}
270
+ br={br}
271
+ onNavigate={onNavigate}
272
+ inherited
273
+ />
274
+ ))}
275
+ </div>
276
+ </Section>
277
+ )
278
+ }
279
+
280
+ function BindingRow({
281
+ br,
282
+ onNavigate,
283
+ inherited,
284
+ }: {
285
+ br: RBACBindingRules
286
+ onNavigate?: (ref: ResourceRef) => void
287
+ inherited?: boolean
288
+ }) {
289
+ // Map binding kind to the lowercase plural the resource-routing layer
290
+ // expects. The detail page route normalises these.
291
+ const bindingKindPlural =
292
+ br.binding.kind === 'RoleBinding' ? 'rolebindings' : 'clusterrolebindings'
293
+ const roleKindPlural =
294
+ br.role.kind === 'Role' ? 'roles' : 'clusterroles'
295
+
296
+ const risky = bindingHasDangerousGrant(br)
297
+
298
+ return (
299
+ <div className={clsx('card-inner', risky && 'border-orange-500/40')}>
300
+ {/* items-center + uniform text-xs so badges, arrow, and ResourceLink
301
+ * text share a baseline. items-start + a parent without a text size
302
+ * let ResourceLink inherit default sm/base while siblings were xs,
303
+ * producing visible baseline drift. */}
304
+ <div className="flex items-center gap-2 flex-wrap text-xs">
305
+ <span className={clsx('badge', rbacKindBadgeClass(br.binding.kind))}>
306
+ {br.binding.kind}
307
+ </span>
308
+ <ResourceLink
309
+ kind={bindingKindPlural}
310
+ namespace={br.binding.namespace}
311
+ name={br.binding.name}
312
+ onNavigate={onNavigate}
313
+ />
314
+ <span className="text-theme-text-secondary">→</span>
315
+ <span className={clsx('badge', rbacKindBadgeClass(br.role.kind))}>
316
+ {br.role.kind}
317
+ </span>
318
+ <ResourceLink
319
+ kind={roleKindPlural}
320
+ namespace={br.role.namespace}
321
+ name={br.role.name}
322
+ onNavigate={onNavigate}
323
+ />
324
+ {br.scopeNamespace && (
325
+ <span
326
+ className={clsx('badge', rbacApiGroupBadgeClass)}
327
+ title="RoleBinding grants a ClusterRole's rules only within this namespace"
328
+ >
329
+ scoped to {br.scopeNamespace}
330
+ </span>
331
+ )}
332
+ {risky && (
333
+ <span
334
+ className={clsx('badge inline-flex items-center gap-1', BADGE_SEVERITY_COLORS.alert)}
335
+ title="This binding grants wildcard or escalation permissions"
336
+ >
337
+ <AlertTriangle className="w-3 h-3" />
338
+ permissive
339
+ </span>
340
+ )}
341
+ {inherited && (
342
+ <span className="text-theme-text-secondary italic">
343
+ inherited
344
+ </span>
345
+ )}
346
+ </div>
347
+ {/* Rule preview — capped to keep the row compact. */}
348
+ {br.rules && br.rules.length > 0 && (
349
+ <RulesPreview rules={br.rules} max={3} />
350
+ )}
351
+ {br.rules && br.rules.length === 0 && (
352
+ <div className="mt-2 text-xs text-orange-400">
353
+ Referenced role has no rules (or could not be resolved — may be an orphan binding).
354
+ </div>
355
+ )}
356
+ </div>
357
+ )
358
+ }
359
+
360
+ function RulesPreview({ rules, max }: { rules: RBACPolicyRule[]; max: number }) {
361
+ const shown = rules.slice(0, max)
362
+ const hidden = rules.length - shown.length
363
+ return (
364
+ <div className="mt-2 space-y-1">
365
+ {shown.map((r, i) => (
366
+ <RuleLine key={i} rule={r} />
367
+ ))}
368
+ {hidden > 0 && (
369
+ <div className="text-xs text-theme-text-tertiary">+{hidden} more rule{hidden === 1 ? '' : 's'}</div>
370
+ )}
371
+ </div>
372
+ )
373
+ }
374
+
375
+ function RuleLine({ rule }: { rule: RBACPolicyRule }) {
376
+ const verbs = rule.verbs ?? []
377
+ const resources = rule.resources ?? []
378
+ const nonResourceURLs = rule.nonResourceURLs ?? []
379
+ const groups = rule.apiGroups ?? []
380
+ // Non-resource rules (e.g. system:discovery grants `get` on /api, /healthz)
381
+ // have no resources/apiGroups — only URLs. Render those in place; suppresses
382
+ // the misleading bare "get on" with nothing after.
383
+ const isNonResource = resources.length === 0 && nonResourceURLs.length > 0
384
+ return (
385
+ <div className="flex items-center gap-1 flex-wrap text-xs">
386
+ {verbs.map((v) => (
387
+ <span key={v} className={clsx('badge text-xs', rbacVerbBadgeClass(v))}>{v}</span>
388
+ ))}
389
+ <span className="text-theme-text-secondary">on</span>
390
+ {isNonResource ? (
391
+ nonResourceURLs.map((u) => (
392
+ <span key={u} className="badge text-xs font-mono bg-theme-elevated text-theme-text-secondary">{u}</span>
393
+ ))
394
+ ) : (
395
+ resources.map((r) => (
396
+ <span key={r} className={clsx('badge text-xs', rbacResourceBadgeClass)}>
397
+ {r}
398
+ </span>
399
+ ))
400
+ )}
401
+ {!isNonResource && groups.length > 0 && groups.some((g) => g !== '') && (
402
+ <>
403
+ <span className="text-theme-text-secondary">in</span>
404
+ {groups.map((g) => (
405
+ <span key={g} className={clsx('badge text-xs', rbacApiGroupBadgeClass)}>
406
+ {g === '' ? 'core' : g}
407
+ </span>
408
+ ))}
409
+ </>
410
+ )}
411
+ </div>
412
+ )
413
+ }
414
+
415
+ // Effective permissions: tab between per-binding (provenance preserved) and
416
+ // the flat deduplicated rule set ("does this SA have X anywhere?"). Collapsed
417
+ // by default — the Direct Bindings section above is the primary view.
418
+ function EffectivePermissionsSection({ rbacData }: { rbacData: RBACSubjectResponse }) {
419
+ const [tab, setTab] = useState<'by-binding' | 'flat'>('by-binding')
420
+ const allBindings: RBACBindingRules[] = [
421
+ ...rbacData.direct,
422
+ ...rbacData.inheritedFromGroups.flatMap((g) => g.bindings),
423
+ ]
424
+ const ruleCount = rbacData.flat.length
425
+
426
+ return (
427
+ <Section
428
+ title={`Effective Permissions (${ruleCount}${rbacData.truncated ? '+' : ''} rule${ruleCount === 1 ? '' : 's'})`}
429
+ icon={Shield}
430
+ >
431
+ <div className="flex gap-1 mb-3">
432
+ <button
433
+ className={clsx(
434
+ 'btn-brand-toggle text-xs px-2 py-1',
435
+ tab === 'by-binding' && 'bg-theme-elevated',
436
+ )}
437
+ onClick={() => setTab('by-binding')}
438
+ >
439
+ By binding
440
+ </button>
441
+ <button
442
+ className={clsx(
443
+ 'btn-brand-toggle text-xs px-2 py-1',
444
+ tab === 'flat' && 'bg-theme-elevated',
445
+ )}
446
+ onClick={() => setTab('flat')}
447
+ >
448
+ All rules (flat)
449
+ </button>
450
+ </div>
451
+
452
+ {rbacData.truncated && (
453
+ <div className="mb-2 text-xs text-orange-400">
454
+ Rule list capped at {ruleCount} entries — this SA has more
455
+ inherited bindings than fit in the summary. View the underlying
456
+ Role/ClusterRole YAML for the full picture.
457
+ </div>
458
+ )}
459
+
460
+ {tab === 'by-binding' && (
461
+ <div className="space-y-3">
462
+ {allBindings.length === 0 ? (
463
+ <div className="text-sm text-theme-text-tertiary">No effective permissions.</div>
464
+ ) : (
465
+ allBindings.map((br, i) => (
466
+ <div key={i} className="card-inner-lg">
467
+ <div className="text-xs text-theme-text-tertiary mb-1">
468
+ Via {br.binding.kind} <span className="text-theme-text-secondary">{br.binding.name}</span>
469
+ {' → '}
470
+ {br.role.kind} <span className="text-theme-text-secondary">{br.role.name}</span>
471
+ </div>
472
+ <div className="space-y-1">
473
+ {(br.rules ?? []).map((r, j) => (
474
+ <RuleLine key={j} rule={r} />
475
+ ))}
476
+ {(!br.rules || br.rules.length === 0) && (
477
+ <div className="text-xs text-theme-text-tertiary">No rules (orphan binding or empty role)</div>
478
+ )}
479
+ </div>
480
+ </div>
481
+ ))
482
+ )}
483
+ </div>
484
+ )}
485
+
486
+ {tab === 'flat' && (
487
+ <div className="space-y-1">
488
+ {rbacData.flat.length === 0 ? (
489
+ <div className="text-sm text-theme-text-tertiary">No effective permissions.</div>
490
+ ) : (
491
+ rbacData.flat.map((r, i) => <RuleLine key={i} rule={r} />)
492
+ )}
493
+ </div>
494
+ )}
495
+ </Section>
496
+ )
497
+ }
498
+
499
+ // ============================================================================
500
+ // USED BY PODS SECTION
501
+ // ============================================================================
502
+ // Closes the loop on the SA detail page: the bindings sections answer "what
503
+ // permissions" — this answers "what workloads inherit them". Operators
504
+ // reading top-down should be able to ask both questions without leaving.
505
+
506
+ function UsedByPodsSection({
507
+ pods,
508
+ onNavigate,
509
+ }: {
510
+ pods: { namespace: string; name: string }[]
511
+ onNavigate?: (ref: ResourceRef) => void
512
+ }) {
513
+ return (
514
+ <Section title={`Used by Pods (${pods.length})`} icon={Box}>
515
+ {pods.length === 0 ? (
516
+ <div className="text-sm text-theme-text-secondary">
517
+ No Pods are currently running as this ServiceAccount.
518
+ </div>
519
+ ) : (
520
+ <div className="flex flex-wrap gap-1.5 text-xs">
521
+ {pods.map((p) => (
522
+ <ResourceLink
523
+ key={p.namespace + '/' + p.name}
524
+ kind="pods"
525
+ namespace={p.namespace}
526
+ name={p.name}
527
+ onNavigate={onNavigate}
528
+ />
529
+ ))}
530
+ </div>
531
+ )}
532
+ </Section>
533
+ )
534
+ }