@igstack/app-catalog-frontend-core 6.5.2 → 6.6.0

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.
@@ -1,4 +1,4 @@
1
- import { jsx, jsxs, Fragment } from "react/jsx-runtime";
1
+ import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
2
  import { ExternalLink, Settings, Users, Bot, Check, Copy, MoreHorizontal } from "lucide-react";
3
3
  import { useMemo, useCallback, useState, useRef, useEffect } from "react";
4
4
  import { Button } from "../../../../ui/button.js";
@@ -36,7 +36,13 @@ function RolesTable({
36
36
  /* @__PURE__ */ jsxs(TableBody, { children: [
37
37
  shown.map((role, idx) => /* @__PURE__ */ jsxs(TableRow, { children: [
38
38
  /* @__PURE__ */ jsx(TableCell, { className: "font-medium whitespace-nowrap", children: role.displayName }),
39
- /* @__PURE__ */ jsx(TableCell, { className: "text-sm text-muted-foreground", children: role.description || "—" })
39
+ /* @__PURE__ */ jsxs(TableCell, { className: "text-sm text-muted-foreground", children: [
40
+ role.description || "—",
41
+ role.adminNotes && /* @__PURE__ */ jsxs("div", { className: "mt-1 text-xs italic text-muted-foreground/80", children: [
42
+ "Note: ",
43
+ role.adminNotes
44
+ ] })
45
+ ] })
40
46
  ] }, `${role.displayName}-${idx}`)),
41
47
  collapsible && /* @__PURE__ */ jsx(TableRow, { children: /* @__PURE__ */ jsx(TableCell, { colSpan: 2, className: "p-0", children: /* @__PURE__ */ jsxs(
42
48
  "button",
@@ -112,6 +118,12 @@ function CopyButton({
112
118
  }
113
119
  const UNDOCUMENTED_WITH_CONTACT = "Access process not documented yet — contact the owner below to find out.";
114
120
  const UNDOCUMENTED_WITHOUT_CONTACT = "Access process not documented yet, and no owner is listed for this app.";
121
+ function AdminNotes({ notes }) {
122
+ return /* @__PURE__ */ jsxs("div", { children: [
123
+ /* @__PURE__ */ jsx("h4", { className: "mb-2 text-sm font-medium", children: "Admin notes" }),
124
+ /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground prose prose-sm max-w-none", children: /* @__PURE__ */ jsx(MarkdownText, { children: notes }) })
125
+ ] });
126
+ }
115
127
  function AccessBox({ children }) {
116
128
  return (
117
129
  // No frame and no heading. Both existed to mark this out as THE thing on a long
@@ -161,7 +173,10 @@ function AccessRequestSection({
161
173
  }
162
174
  }, [requestPrompt, copyToClipboard]);
163
175
  if (!accessRequest) {
164
- return /* @__PURE__ */ jsx(AccessBox, { children: /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: UNDOCUMENTED_WITHOUT_CONTACT }) });
176
+ return /* @__PURE__ */ jsxs(AccessBox, { children: [
177
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: UNDOCUMENTED_WITHOUT_CONTACT }),
178
+ app.notes && /* @__PURE__ */ jsx(AdminNotes, { notes: app.notes })
179
+ ] });
165
180
  }
166
181
  const isOpen = (approvalMethod == null ? void 0 : approvalMethod.type) === "noAccessRequired";
167
182
  const hasWrittenSteps = Boolean(
@@ -224,6 +239,7 @@ function AccessRequestSection({
224
239
  ] }),
225
240
  accessRequest.comments && /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground prose prose-sm max-w-none", children: /* @__PURE__ */ jsx(MarkdownText, { children: accessRequest.comments }) }),
226
241
  accessRequest.roles && accessRequest.roles.length > 0 && /* @__PURE__ */ jsx(RolesTable, { roles: accessRequest.roles }),
242
+ app.notes && /* @__PURE__ */ jsx(AdminNotes, { notes: app.notes }),
227
243
  accessRequest.approverSlugs && accessRequest.approverSlugs.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
228
244
  /* @__PURE__ */ jsx("h4", { className: "mb-2 text-sm font-medium", children: "Approvers" }),
229
245
  /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: accessRequest.approverSlugs.map((slug) => /* @__PURE__ */ jsx(PersonOrGroupBadge, { slug }, slug)) })
@@ -1 +1 @@
1
- {"version":3,"file":"AccessRequestSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/AccessRequestSection.tsx"],"sourcesContent":["import type {\n AppAccessRequest,\n AppApprovalMethod,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport {\n Bot,\n Check,\n Copy,\n ExternalLink,\n MoreHorizontal,\n Settings,\n Users,\n} from 'lucide-react'\nimport type { ReactNode } from 'react'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { MarkdownText } from './MarkdownText'\nimport { PersonOrGroupBadge } from './PersonBadge'\n\n// Constants\nconst COPY_FEEDBACK_DURATION = 2000\n\ninterface AccessRequestSectionProps {\n app: Resource\n approvalMethods: AppApprovalMethod[]\n}\n\n// Helper function for approval method icons\nfunction getApprovalMethodIcon(\n type: 'service' | 'personTeam' | 'custom' | 'noAccessRequired' | 'unknown',\n) {\n switch (type) {\n case 'service':\n return <Bot className=\"size-5 text-primary\" />\n case 'personTeam':\n return <Users className=\"size-5 text-primary\" />\n case 'custom':\n case 'noAccessRequired':\n case 'unknown':\n return <Settings className=\"size-5 text-primary\" />\n }\n}\n\n/** Roles shown before the rest are folded behind the table's last row. */\nconst VISIBLE_ROLES = 5\n\n/**\n * The roles table. A long list (dozens of roles) pushed the approval steps off\n * screen, so beyond {@link VISIBLE_ROLES} the rest sit behind a toggle.\n */\nfunction RolesTable({\n roles,\n}: {\n roles: NonNullable<AppAccessRequest['roles']>\n}) {\n const [expanded, setExpanded] = useState(false)\n const collapsible = roles.length > VISIBLE_ROLES\n const shown = collapsible && !expanded ? roles.slice(0, VISIBLE_ROLES) : roles\n\n return (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Available Roles</h4>\n <div className=\"rounded-lg border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"whitespace-nowrap\">Role</TableHead>\n <TableHead>Description</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {shown.map((role, idx) => (\n <TableRow key={`${role.displayName}-${idx}`}>\n <TableCell className=\"font-medium whitespace-nowrap\">\n {role.displayName}\n </TableCell>\n {/* `adminNotes` is provisioning-only and never shown to\n the requester — only `description` belongs here. */}\n <TableCell className=\"text-sm text-muted-foreground\">\n {role.description || '—'}\n </TableCell>\n </TableRow>\n ))}\n {/* The control is the table's last row, not a link under the\n table: a table whose border closes under its last data row\n reads as the complete list, and a standalone link below it\n went unnoticed. Same shape the search results use for their\n hidden sub-resources. */}\n {collapsible && (\n <TableRow>\n <TableCell colSpan={2} className=\"p-0\">\n {/* A real <button> filling the cell: the whole row is\n clickable, and focus plus Enter/Space come from the\n platform rather than from a key handler on a <tr>. */}\n <button\n type=\"button\"\n aria-expanded={expanded}\n onClick={() => setExpanded((v) => !v)}\n // The focus ring is drawn INSIDE the border box: the\n // table's container is `overflow-x-auto`, which clips an\n // outline drawn outside it, and this button's edges are\n // flush with that clip box on three sides.\n className=\"flex w-full cursor-pointer items-center gap-2 p-2 text-left text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\"\n >\n <MoreHorizontal className=\"size-4 shrink-0\" />\n {expanded\n ? 'Show fewer roles'\n : `Show all (${roles.length}) roles`}\n </button>\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n\n/**\n * Custom hook for handling copy-to-clipboard functionality with feedback\n * Includes proper cleanup to prevent memory leaks\n */\nfunction useCopyToClipboard() {\n const [copiedId, setCopiedId] = useState<string | null>(null)\n const [error, setError] = useState<string | null>(null)\n const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n // Cleanup timeout on unmount\n useEffect(() => {\n return () => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n }\n }, [])\n\n const copyToClipboard = useCallback(async (text: string, id: string) => {\n // Clear existing timeout\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n\n try {\n await navigator.clipboard.writeText(text)\n setCopiedId(id)\n setError(null)\n\n // Set new timeout with cleanup\n timeoutRef.current = setTimeout(() => {\n setCopiedId(null)\n }, COPY_FEEDBACK_DURATION)\n } catch (err) {\n console.error('Failed to copy to clipboard:', err)\n setError('Failed to copy')\n\n // Clear error after duration\n timeoutRef.current = setTimeout(() => {\n setError(null)\n }, COPY_FEEDBACK_DURATION)\n }\n }, [])\n\n return { copiedId, error, copyToClipboard }\n}\n\n/**\n * Reusable copy button component with accessibility\n */\ninterface CopyButtonProps {\n onCopy: () => void\n isCopied: boolean\n ariaLabel: string\n className?: string\n}\n\nfunction CopyButton({\n onCopy,\n isCopied,\n ariaLabel,\n className,\n}: CopyButtonProps) {\n return (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={onCopy}\n className={className}\n aria-label={ariaLabel}\n title={isCopied ? 'Copied!' : 'Copy to clipboard'}\n >\n {isCopied ? (\n <>\n <Check className=\"h-3.5 w-3.5 text-green-600\" />\n <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n Copied to clipboard\n </span>\n </>\n ) : (\n <Copy className=\"h-3.5 w-3.5\" />\n )}\n </Button>\n )\n}\n\n/**\n * Wording for an entry whose access route is not written down. Which one is\n * honest depends on whether a contact is actually rendered for the reader.\n */\nconst UNDOCUMENTED_WITH_CONTACT =\n 'Access process not documented yet — contact the owner below to find out.'\nconst UNDOCUMENTED_WITHOUT_CONTACT =\n 'Access process not documented yet, and no owner is listed for this app.'\n\n/**\n * The frame every access state renders into, including the empty one — an\n * entry with no access data still gets the heading, because a missing box\n * reads as \"nothing to do here\" rather than \"we have not documented this\".\n */\nfunction AccessBox({ children }: { children: ReactNode }) {\n return (\n // No frame and no heading. Both existed to mark this out as THE thing on a long\n // scrolling card; now it is its own tab, so the tab's label already says what\n // this is and the panel edge already bounds it. A box inside a box, under a\n // heading that repeats the tab you just clicked, is two statements of the same\n // fact and ink the reader has to look past.\n // A stable anchor for tests, because neither of the old handles survives: the\n // heading is gone, and a sub-resource page has no tab panel to read either.\n // Text and structure both move; this does not.\n <div data-access-section=\"\" className=\"space-y-4\">\n {children}\n </div>\n )\n}\n\nexport function AccessRequestSection({\n app,\n approvalMethods,\n}: AccessRequestSectionProps) {\n const { copiedId, copyToClipboard } = useCopyToClipboard()\n\n // Sub-resources carry their access data as top-level fields (`approverSlugs`,\n // `accessComments`, `ownerPersonSlug`) rather than in an `accessRequest`.\n // Without this fallback the whole section rendered nothing for them: the\n // person was told a request is needed and then shown no way to make one.\n const accessRequest = useMemo((): AppAccessRequest | undefined => {\n if (app.accessRequest) return app.accessRequest\n const approverSlugs = [\n ...new Set([\n ...(app.approverSlugs ?? []),\n ...(app.ownerPersonSlug ? [app.ownerPersonSlug] : []),\n ]),\n ]\n if (approverSlugs.length === 0 && !app.accessComments) return undefined\n // No approval method to name — the approvers below ARE the route.\n return {\n approvalMethodSlug: '',\n approverSlugs,\n comments: app.accessComments,\n }\n }, [\n app.accessRequest,\n app.approverSlugs,\n app.ownerPersonSlug,\n app.accessComments,\n ])\n\n const approvalMethod = approvalMethods.find(\n (m) => m.slug === accessRequest?.approvalMethodSlug,\n )\n\n // Read through a plain binding rather than depending on\n // `accessRequest?.requestPrompt` directly: the React Compiler cannot preserve a\n // manual memo whose dependency is an optional-chained member expression, so it\n // drops this useCallback and the button gets a new handler every render.\n const requestPrompt = accessRequest?.requestPrompt\n const handleCopyPrompt = useCallback(() => {\n if (requestPrompt) {\n copyToClipboard(requestPrompt, 'prompt')\n }\n }, [requestPrompt, copyToClipboard])\n\n // Nothing is written down anywhere: no `accessRequest`, no approvers, no\n // owner, no comments. This used to `return null`, so the card showed no\n // \"How to get access\" box at all and the reader could not tell an\n // undocumented entry from one that needs no request (#181). Keep the box and\n // say plainly that we do not know.\n if (!accessRequest) {\n return (\n <AccessBox>\n <p className=\"text-sm text-muted-foreground\">\n {UNDOCUMENTED_WITHOUT_CONTACT}\n </p>\n </AccessBox>\n )\n }\n\n // Access UX (#31): some methods carry no clickable target — `noAccessRequired`\n // (open), `unknown` (undocumented), and bare `custom` with no comments. The\n // old UI rendered nothing for `custom`, leaving a blank/dead section. Instead\n // surface an explicit, actionable line so the user is never stranded:\n // - open → \"No request needed — just open it.\"\n // - unknown / undocumented custom → route them to the owner (shown below).\n const isOpen = approvalMethod?.type === 'noAccessRequired'\n const hasWrittenSteps = Boolean(\n accessRequest.requestPrompt || accessRequest.comments,\n )\n const isUndocumented =\n approvalMethod?.type === 'unknown' ||\n (approvalMethod?.type === 'custom' && !hasWrittenSteps)\n // \"the owner below\" is either the Approvers block in this box or the owner\n // block further down the detail page, and BOTH are conditional — so an entry\n // with neither was telling the reader to go and contact nobody.\n const hasContact =\n (accessRequest.approverSlugs?.length ?? 0) > 0 ||\n Boolean(app.ownerPersonSlug)\n // Two-step: has a primary request action AND follow-up post-approval steps\n const isTwoStep = Boolean(\n hasWrittenSteps && accessRequest.postApprovalInstructions,\n )\n\n return (\n <AccessBox>\n {isOpen && (\n <p className=\"text-sm font-medium text-primary\">\n ✓ Open to everyone — no request needed. Just open it.\n </p>\n )}\n\n {isUndocumented && (\n <p className=\"text-sm text-muted-foreground\">\n {hasContact\n ? UNDOCUMENTED_WITH_CONTACT\n : UNDOCUMENTED_WITHOUT_CONTACT}\n </p>\n )}\n\n {isTwoStep && (\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5\">\n Step 1\n </span>\n <span className=\"text-xs text-muted-foreground\">Request access</span>\n </div>\n )}\n\n {/* Approval Method */}\n {approvalMethod && approvalMethod.type !== 'custom' && (\n <div className=\"flex items-center gap-2\">\n {approvalMethod.type === 'service' && approvalMethod.config.url ? (\n <>\n <a\n href={approvalMethod.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-2 hover:text-primary transition-colors\"\n title={approvalMethod.config.url}\n >\n <div>{getApprovalMethodIcon(approvalMethod.type)}</div>\n <div className=\"font-medium\">{approvalMethod.displayName}</div>\n </a>\n <a\n href={approvalMethod.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-muted-foreground hover:text-primary\"\n title={approvalMethod.config.url}\n >\n <ExternalLink className=\"size-4\" />\n </a>\n </>\n ) : (\n <>\n <div>{getApprovalMethodIcon(approvalMethod.type)}</div>\n <div className=\"font-medium\">{approvalMethod.displayName}</div>\n </>\n )}\n </div>\n )}\n\n {/* Request Prompt - Inline */}\n {accessRequest.requestPrompt && (\n <div className=\"text-sm inline-flex items-center gap-2\">\n <span className=\"whitespace-nowrap shrink-0\">Request Prompt:</span>\n <span className=\"prose prose-sm inline [&>*]:inline [&>*]:m-0\">\n <MarkdownText>{accessRequest.requestPrompt}</MarkdownText>\n </span>\n <CopyButton\n onCopy={handleCopyPrompt}\n isCopied={copiedId === 'prompt'}\n ariaLabel=\"Copy request prompt\"\n className=\"h-6 w-6 p-0 shrink-0\"\n />\n </div>\n )}\n\n {/* Comments */}\n {accessRequest.comments && (\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none\">\n <MarkdownText>{accessRequest.comments}</MarkdownText>\n </div>\n )}\n\n {accessRequest.roles && accessRequest.roles.length > 0 && (\n <RolesTable roles={accessRequest.roles} />\n )}\n\n {/* Approvers */}\n {accessRequest.approverSlugs &&\n accessRequest.approverSlugs.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Approvers</h4>\n <div className=\"flex flex-wrap gap-2\">\n {accessRequest.approverSlugs.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n </div>\n )}\n\n {/* Post-Approval Instructions */}\n {accessRequest.postApprovalInstructions &&\n (isTwoStep ? (\n // Two-step apps: show Step 2 prominently, always expanded\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5\">\n Step 2\n </span>\n <span className=\"text-xs text-muted-foreground\">\n After approval\n </span>\n </div>\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\">\n <MarkdownText>\n {accessRequest.postApprovalInstructions}\n </MarkdownText>\n </div>\n </div>\n ) : (\n // Single-step apps: keep collapsible accordion (secondary info)\n <Accordion type=\"single\" collapsible>\n <AccordionItem\n value=\"post-approval\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Post-Approval Instructions\n </AccordionTrigger>\n <AccordionContent className=\"pb-3\">\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\">\n <MarkdownText>\n {accessRequest.postApprovalInstructions}\n </MarkdownText>\n </div>\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n ))}\n\n {/* Documentation URLs — box level: they cover the whole process,\n not one step, so they come after Step 2 */}\n {accessRequest.urls && accessRequest.urls.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Documentation</h4>\n <div className=\"flex flex-col gap-1.5\">\n {accessRequest.urls.map((urlObj, idx) => (\n <a\n key={`${urlObj.url}-${idx}`}\n href={urlObj.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-muted-foreground hover:text-primary inline-flex items-center gap-1.5\"\n >\n {urlObj.label || urlObj.url}\n <ExternalLink className=\"size-3\" />\n </a>\n ))}\n </div>\n </div>\n )}\n </AccessBox>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AccessRequestSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/AccessRequestSection.tsx"],"sourcesContent":["import type {\n AppAccessRequest,\n AppApprovalMethod,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport {\n Bot,\n Check,\n Copy,\n ExternalLink,\n MoreHorizontal,\n Settings,\n Users,\n} from 'lucide-react'\nimport type { ReactNode } from 'react'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { MarkdownText } from './MarkdownText'\nimport { PersonOrGroupBadge } from './PersonBadge'\n\n// Constants\nconst COPY_FEEDBACK_DURATION = 2000\n\ninterface AccessRequestSectionProps {\n app: Resource\n approvalMethods: AppApprovalMethod[]\n}\n\n// Helper function for approval method icons\nfunction getApprovalMethodIcon(\n type: 'service' | 'personTeam' | 'custom' | 'noAccessRequired' | 'unknown',\n) {\n switch (type) {\n case 'service':\n return <Bot className=\"size-5 text-primary\" />\n case 'personTeam':\n return <Users className=\"size-5 text-primary\" />\n case 'custom':\n case 'noAccessRequired':\n case 'unknown':\n return <Settings className=\"size-5 text-primary\" />\n }\n}\n\n/** Roles shown before the rest are folded behind the table's last row. */\nconst VISIBLE_ROLES = 5\n\n/**\n * The roles table. A long list (dozens of roles) pushed the approval steps off\n * screen, so beyond {@link VISIBLE_ROLES} the rest sit behind a toggle.\n */\nfunction RolesTable({\n roles,\n}: {\n roles: NonNullable<AppAccessRequest['roles']>\n}) {\n const [expanded, setExpanded] = useState(false)\n const collapsible = roles.length > VISIBLE_ROLES\n const shown = collapsible && !expanded ? roles.slice(0, VISIBLE_ROLES) : roles\n\n return (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Available Roles</h4>\n <div className=\"rounded-lg border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"whitespace-nowrap\">Role</TableHead>\n <TableHead>Description</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {shown.map((role, idx) => (\n <TableRow key={`${role.displayName}-${idx}`}>\n <TableCell className=\"font-medium whitespace-nowrap\">\n {role.displayName}\n </TableCell>\n {/* `adminNotes` is the provisioning detail — the directory\n group, the SSO app, the manual step. It reads as a note\n under the description because the person granting the role\n and the person asking for it read the same page. */}\n <TableCell className=\"text-sm text-muted-foreground\">\n {role.description || '—'}\n {role.adminNotes && (\n <div className=\"mt-1 text-xs italic text-muted-foreground/80\">\n Note: {role.adminNotes}\n </div>\n )}\n </TableCell>\n </TableRow>\n ))}\n {/* The control is the table's last row, not a link under the\n table: a table whose border closes under its last data row\n reads as the complete list, and a standalone link below it\n went unnoticed. Same shape the search results use for their\n hidden sub-resources. */}\n {collapsible && (\n <TableRow>\n <TableCell colSpan={2} className=\"p-0\">\n {/* A real <button> filling the cell: the whole row is\n clickable, and focus plus Enter/Space come from the\n platform rather than from a key handler on a <tr>. */}\n <button\n type=\"button\"\n aria-expanded={expanded}\n onClick={() => setExpanded((v) => !v)}\n // The focus ring is drawn INSIDE the border box: the\n // table's container is `overflow-x-auto`, which clips an\n // outline drawn outside it, and this button's edges are\n // flush with that clip box on three sides.\n className=\"flex w-full cursor-pointer items-center gap-2 p-2 text-left text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\"\n >\n <MoreHorizontal className=\"size-4 shrink-0\" />\n {expanded\n ? 'Show fewer roles'\n : `Show all (${roles.length}) roles`}\n </button>\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n\n/**\n * Custom hook for handling copy-to-clipboard functionality with feedback\n * Includes proper cleanup to prevent memory leaks\n */\nfunction useCopyToClipboard() {\n const [copiedId, setCopiedId] = useState<string | null>(null)\n const [error, setError] = useState<string | null>(null)\n const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n // Cleanup timeout on unmount\n useEffect(() => {\n return () => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n }\n }, [])\n\n const copyToClipboard = useCallback(async (text: string, id: string) => {\n // Clear existing timeout\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n\n try {\n await navigator.clipboard.writeText(text)\n setCopiedId(id)\n setError(null)\n\n // Set new timeout with cleanup\n timeoutRef.current = setTimeout(() => {\n setCopiedId(null)\n }, COPY_FEEDBACK_DURATION)\n } catch (err) {\n console.error('Failed to copy to clipboard:', err)\n setError('Failed to copy')\n\n // Clear error after duration\n timeoutRef.current = setTimeout(() => {\n setError(null)\n }, COPY_FEEDBACK_DURATION)\n }\n }, [])\n\n return { copiedId, error, copyToClipboard }\n}\n\n/**\n * Reusable copy button component with accessibility\n */\ninterface CopyButtonProps {\n onCopy: () => void\n isCopied: boolean\n ariaLabel: string\n className?: string\n}\n\nfunction CopyButton({\n onCopy,\n isCopied,\n ariaLabel,\n className,\n}: CopyButtonProps) {\n return (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={onCopy}\n className={className}\n aria-label={ariaLabel}\n title={isCopied ? 'Copied!' : 'Copy to clipboard'}\n >\n {isCopied ? (\n <>\n <Check className=\"h-3.5 w-3.5 text-green-600\" />\n <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n Copied to clipboard\n </span>\n </>\n ) : (\n <Copy className=\"h-3.5 w-3.5\" />\n )}\n </Button>\n )\n}\n\n/**\n * Wording for an entry whose access route is not written down. Which one is\n * honest depends on whether a contact is actually rendered for the reader.\n */\nconst UNDOCUMENTED_WITH_CONTACT =\n 'Access process not documented yet — contact the owner below to find out.'\nconst UNDOCUMENTED_WITHOUT_CONTACT =\n 'Access process not documented yet, and no owner is listed for this app.'\n\n/**\n * The entry's own `adminNotes`, served as `Resource.notes`: the provisioning\n * route an approver follows — the pipeline to run, the group to grant. It is\n * about the whole request rather than one role, so it gets its own block.\n */\nfunction AdminNotes({ notes }: { notes: string }) {\n return (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Admin notes</h4>\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none\">\n <MarkdownText>{notes}</MarkdownText>\n </div>\n </div>\n )\n}\n\n/**\n * The frame every access state renders into, including the empty one — an\n * entry with no access data still gets the heading, because a missing box\n * reads as \"nothing to do here\" rather than \"we have not documented this\".\n */\nfunction AccessBox({ children }: { children: ReactNode }) {\n return (\n // No frame and no heading. Both existed to mark this out as THE thing on a long\n // scrolling card; now it is its own tab, so the tab's label already says what\n // this is and the panel edge already bounds it. A box inside a box, under a\n // heading that repeats the tab you just clicked, is two statements of the same\n // fact and ink the reader has to look past.\n // A stable anchor for tests, because neither of the old handles survives: the\n // heading is gone, and a sub-resource page has no tab panel to read either.\n // Text and structure both move; this does not.\n <div data-access-section=\"\" className=\"space-y-4\">\n {children}\n </div>\n )\n}\n\nexport function AccessRequestSection({\n app,\n approvalMethods,\n}: AccessRequestSectionProps) {\n const { copiedId, copyToClipboard } = useCopyToClipboard()\n\n // Sub-resources carry their access data as top-level fields (`approverSlugs`,\n // `accessComments`, `ownerPersonSlug`) rather than in an `accessRequest`.\n // Without this fallback the whole section rendered nothing for them: the\n // person was told a request is needed and then shown no way to make one.\n const accessRequest = useMemo((): AppAccessRequest | undefined => {\n if (app.accessRequest) return app.accessRequest\n const approverSlugs = [\n ...new Set([\n ...(app.approverSlugs ?? []),\n ...(app.ownerPersonSlug ? [app.ownerPersonSlug] : []),\n ]),\n ]\n if (approverSlugs.length === 0 && !app.accessComments) return undefined\n // No approval method to name — the approvers below ARE the route.\n return {\n approvalMethodSlug: '',\n approverSlugs,\n comments: app.accessComments,\n }\n }, [\n app.accessRequest,\n app.approverSlugs,\n app.ownerPersonSlug,\n app.accessComments,\n ])\n\n const approvalMethod = approvalMethods.find(\n (m) => m.slug === accessRequest?.approvalMethodSlug,\n )\n\n // Read through a plain binding rather than depending on\n // `accessRequest?.requestPrompt` directly: the React Compiler cannot preserve a\n // manual memo whose dependency is an optional-chained member expression, so it\n // drops this useCallback and the button gets a new handler every render.\n const requestPrompt = accessRequest?.requestPrompt\n const handleCopyPrompt = useCallback(() => {\n if (requestPrompt) {\n copyToClipboard(requestPrompt, 'prompt')\n }\n }, [requestPrompt, copyToClipboard])\n\n // Nothing is written down anywhere: no `accessRequest`, no approvers, no\n // owner, no comments. This used to `return null`, so the card showed no\n // \"How to get access\" box at all and the reader could not tell an\n // undocumented entry from one that needs no request (#181). Keep the box and\n // say plainly that we do not know.\n if (!accessRequest) {\n return (\n <AccessBox>\n <p className=\"text-sm text-muted-foreground\">\n {UNDOCUMENTED_WITHOUT_CONTACT}\n </p>\n {/* No request route written down, but a provisioning note still is:\n that note is then the only thing on the page that says how the\n access gets granted, so it cannot hang off `accessRequest`. */}\n {app.notes && <AdminNotes notes={app.notes} />}\n </AccessBox>\n )\n }\n\n // Access UX (#31): some methods carry no clickable target — `noAccessRequired`\n // (open), `unknown` (undocumented), and bare `custom` with no comments. The\n // old UI rendered nothing for `custom`, leaving a blank/dead section. Instead\n // surface an explicit, actionable line so the user is never stranded:\n // - open → \"No request needed — just open it.\"\n // - unknown / undocumented custom → route them to the owner (shown below).\n const isOpen = approvalMethod?.type === 'noAccessRequired'\n const hasWrittenSteps = Boolean(\n accessRequest.requestPrompt || accessRequest.comments,\n )\n const isUndocumented =\n approvalMethod?.type === 'unknown' ||\n (approvalMethod?.type === 'custom' && !hasWrittenSteps)\n // \"the owner below\" is either the Approvers block in this box or the owner\n // block further down the detail page, and BOTH are conditional — so an entry\n // with neither was telling the reader to go and contact nobody.\n const hasContact =\n (accessRequest.approverSlugs?.length ?? 0) > 0 ||\n Boolean(app.ownerPersonSlug)\n // Two-step: has a primary request action AND follow-up post-approval steps\n const isTwoStep = Boolean(\n hasWrittenSteps && accessRequest.postApprovalInstructions,\n )\n\n return (\n <AccessBox>\n {isOpen && (\n <p className=\"text-sm font-medium text-primary\">\n ✓ Open to everyone — no request needed. Just open it.\n </p>\n )}\n\n {isUndocumented && (\n <p className=\"text-sm text-muted-foreground\">\n {hasContact\n ? UNDOCUMENTED_WITH_CONTACT\n : UNDOCUMENTED_WITHOUT_CONTACT}\n </p>\n )}\n\n {isTwoStep && (\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5\">\n Step 1\n </span>\n <span className=\"text-xs text-muted-foreground\">Request access</span>\n </div>\n )}\n\n {/* Approval Method */}\n {approvalMethod && approvalMethod.type !== 'custom' && (\n <div className=\"flex items-center gap-2\">\n {approvalMethod.type === 'service' && approvalMethod.config.url ? (\n <>\n <a\n href={approvalMethod.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-2 hover:text-primary transition-colors\"\n title={approvalMethod.config.url}\n >\n <div>{getApprovalMethodIcon(approvalMethod.type)}</div>\n <div className=\"font-medium\">{approvalMethod.displayName}</div>\n </a>\n <a\n href={approvalMethod.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-muted-foreground hover:text-primary\"\n title={approvalMethod.config.url}\n >\n <ExternalLink className=\"size-4\" />\n </a>\n </>\n ) : (\n <>\n <div>{getApprovalMethodIcon(approvalMethod.type)}</div>\n <div className=\"font-medium\">{approvalMethod.displayName}</div>\n </>\n )}\n </div>\n )}\n\n {/* Request Prompt - Inline */}\n {accessRequest.requestPrompt && (\n <div className=\"text-sm inline-flex items-center gap-2\">\n <span className=\"whitespace-nowrap shrink-0\">Request Prompt:</span>\n <span className=\"prose prose-sm inline [&>*]:inline [&>*]:m-0\">\n <MarkdownText>{accessRequest.requestPrompt}</MarkdownText>\n </span>\n <CopyButton\n onCopy={handleCopyPrompt}\n isCopied={copiedId === 'prompt'}\n ariaLabel=\"Copy request prompt\"\n className=\"h-6 w-6 p-0 shrink-0\"\n />\n </div>\n )}\n\n {/* Comments */}\n {accessRequest.comments && (\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none\">\n <MarkdownText>{accessRequest.comments}</MarkdownText>\n </div>\n )}\n\n {accessRequest.roles && accessRequest.roles.length > 0 && (\n <RolesTable roles={accessRequest.roles} />\n )}\n\n {/* After the roles, because the groups and pipelines it names are what\n the roles above are granted through. */}\n {app.notes && <AdminNotes notes={app.notes} />}\n\n {/* Approvers */}\n {accessRequest.approverSlugs &&\n accessRequest.approverSlugs.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Approvers</h4>\n <div className=\"flex flex-wrap gap-2\">\n {accessRequest.approverSlugs.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n </div>\n )}\n\n {/* Post-Approval Instructions */}\n {accessRequest.postApprovalInstructions &&\n (isTwoStep ? (\n // Two-step apps: show Step 2 prominently, always expanded\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5\">\n Step 2\n </span>\n <span className=\"text-xs text-muted-foreground\">\n After approval\n </span>\n </div>\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\">\n <MarkdownText>\n {accessRequest.postApprovalInstructions}\n </MarkdownText>\n </div>\n </div>\n ) : (\n // Single-step apps: keep collapsible accordion (secondary info)\n <Accordion type=\"single\" collapsible>\n <AccordionItem\n value=\"post-approval\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Post-Approval Instructions\n </AccordionTrigger>\n <AccordionContent className=\"pb-3\">\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\">\n <MarkdownText>\n {accessRequest.postApprovalInstructions}\n </MarkdownText>\n </div>\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n ))}\n\n {/* Documentation URLs — box level: they cover the whole process,\n not one step, so they come after Step 2 */}\n {accessRequest.urls && accessRequest.urls.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Documentation</h4>\n <div className=\"flex flex-col gap-1.5\">\n {accessRequest.urls.map((urlObj, idx) => (\n <a\n key={`${urlObj.url}-${idx}`}\n href={urlObj.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-muted-foreground hover:text-primary inline-flex items-center gap-1.5\"\n >\n {urlObj.label || urlObj.url}\n <ExternalLink className=\"size-3\" />\n </a>\n ))}\n </div>\n </div>\n )}\n </AccessBox>\n )\n}\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "6.5.2",
3
+ "version": "6.6.0",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -112,7 +112,7 @@
112
112
  "tailwindcss": "^4.1.7",
113
113
  "tiptap-markdown": "^0.8.0",
114
114
  "tslib": "^2.3.0",
115
- "@igstack/app-catalog-shared-core": "6.5.2"
115
+ "@igstack/app-catalog-shared-core": "6.6.0"
116
116
  },
117
117
  "devDependencies": {
118
118
  "@tailwindcss/vite": "^4.0.5",
@@ -139,7 +139,7 @@
139
139
  "vite-plugin-static-copy": "^3.1.4",
140
140
  "vite-plugin-svgr": "^4.2.0",
141
141
  "vitest": "^4.1.2",
142
- "@igstack/app-catalog-backend-core": "6.5.2"
142
+ "@igstack/app-catalog-backend-core": "6.6.0"
143
143
  },
144
144
  "peerDependencies": {
145
145
  "react": "19.1.2",
@@ -148,7 +148,7 @@
148
148
  "vite": "^6.3.5",
149
149
  "vite-plugin-svgr": "^4.2.0"
150
150
  },
151
- "gitHead": "2e87f4aaf3ae71d52feac41c681f22234637e73d",
151
+ "gitHead": "ceee8a6aba702c8f4e8cc7ab59b9b8c5eb257ab5",
152
152
  "scripts": {
153
153
  "build": "vite build",
154
154
  "build:lenient": "vite build --mode lenient",
@@ -31,11 +31,13 @@ function appWithRoles(roles: unknown): Resource {
31
31
  } as Resource
32
32
  }
33
33
 
34
- // `Role.adminNotes` is provisioning-only (directory group names, SSO app, manual
35
- // steps) and is documented as never shown to the requester — but the roles table
36
- // printed it inline to anyone who could reach the page, signed out included.
34
+ // `Role.adminNotes` carries the provisioning detail — the directory group, the
35
+ // SSO app, the manual step. The people who grant a role read the same page as
36
+ // the people who ask for it, so the note belongs under the description rather
37
+ // than nowhere: for many entries it is the only written record of HOW the role
38
+ // is actually granted.
37
39
  describe('AccessRequestSection — roles table', () => {
38
- it('renders the role description and not its adminNotes', () => {
40
+ it('renders the role description and its adminNotes', () => {
39
41
  render(
40
42
  <AccessRequestSection
41
43
  app={appWithRoles([
@@ -50,8 +52,7 @@ describe('AccessRequestSection — roles table', () => {
50
52
  )
51
53
 
52
54
  expect(screen.getByText('Full administrative access.')).toBeInTheDocument()
53
- expect(screen.queryByText(/AD Group: SomeApp_Admin/)).toBeNull()
54
- expect(screen.queryByText(/^Note:/)).toBeNull()
55
+ expect(screen.getByText(/AD Group: SomeApp_Admin/)).toBeInTheDocument()
55
56
  })
56
57
 
57
58
  // A long role list (one real entry has 54) pushed the approval steps below
@@ -143,7 +144,9 @@ describe('AccessRequestSection — roles table', () => {
143
144
  expect(toggle).toHaveFocus()
144
145
  })
145
146
 
146
- it('falls back to an em-dash when a role has no description', () => {
147
+ // A role whose only text is its adminNotes still gets the em-dash, so the
148
+ // Description column never reads as if the note were the description.
149
+ it('falls back to an em-dash when a role has no description, and still shows the note', () => {
147
150
  render(
148
151
  <AccessRequestSection
149
152
  app={appWithRoles([
@@ -157,7 +160,77 @@ describe('AccessRequestSection — roles table', () => {
157
160
  )
158
161
 
159
162
  expect(screen.getByText('—')).toBeInTheDocument()
160
- expect(screen.queryByText(/LV 1.0 job type/)).toBeNull()
163
+ expect(screen.getByText(/LV 1.0 job type/)).toBeInTheDocument()
164
+ })
165
+ })
166
+
167
+ // The entry's own `adminNotes` arrives as `Resource.notes` and was stored,
168
+ // synced and served while being rendered nowhere — so the one place that says
169
+ // how access is granted (the pipeline to run, the group to grant) was invisible
170
+ // on the very tab that is about getting access.
171
+ describe('AccessRequestSection — the entry’s admin notes', () => {
172
+ const PIPELINE_NOTE =
173
+ 'To grant access, run the account-provisioning pipeline.'
174
+
175
+ it('renders them under their own heading on a documented entry', () => {
176
+ render(
177
+ <AccessRequestSection
178
+ app={{
179
+ id: 'cloud',
180
+ slug: 'cloud',
181
+ displayName: 'Cloud Console',
182
+ notes: PIPELINE_NOTE,
183
+ accessRequest: {
184
+ approvalMethodSlug: 'service',
185
+ comments: 'Request it from the help desk.',
186
+ },
187
+ }}
188
+ approvalMethods={[]}
189
+ />,
190
+ )
191
+
192
+ expect(
193
+ screen.getByRole('heading', { name: 'Admin notes' }),
194
+ ).toBeInTheDocument()
195
+ expect(screen.getByText(PIPELINE_NOTE)).toBeInTheDocument()
196
+ })
197
+
198
+ // An entry can have no request route written down and still have the note.
199
+ // Hanging the block off `accessRequest` would drop it exactly there.
200
+ it('renders them on an entry with no access request at all', () => {
201
+ render(
202
+ <AccessRequestSection
203
+ app={{
204
+ id: 'ghost',
205
+ slug: 'ghost',
206
+ displayName: 'Ghost',
207
+ notes: PIPELINE_NOTE,
208
+ }}
209
+ approvalMethods={[]}
210
+ />,
211
+ )
212
+
213
+ expect(screen.getByText(/not documented yet/)).toBeInTheDocument()
214
+ expect(screen.getByText(PIPELINE_NOTE)).toBeInTheDocument()
215
+ })
216
+
217
+ it('renders no heading when the entry has no notes', () => {
218
+ render(
219
+ <AccessRequestSection
220
+ app={{
221
+ id: 'plain',
222
+ slug: 'plain',
223
+ displayName: 'Plain',
224
+ accessRequest: {
225
+ approvalMethodSlug: 'service',
226
+ comments: 'Request it from the help desk.',
227
+ },
228
+ }}
229
+ approvalMethods={[]}
230
+ />,
231
+ )
232
+
233
+ expect(screen.queryByRole('heading', { name: 'Admin notes' })).toBeNull()
161
234
  })
162
235
  })
163
236
 
@@ -89,10 +89,17 @@ function RolesTable({
89
89
  <TableCell className="font-medium whitespace-nowrap">
90
90
  {role.displayName}
91
91
  </TableCell>
92
- {/* `adminNotes` is provisioning-only and never shown to
93
- the requester — only `description` belongs here. */}
92
+ {/* `adminNotes` is the provisioning detail — the directory
93
+ group, the SSO app, the manual step. It reads as a note
94
+ under the description because the person granting the role
95
+ and the person asking for it read the same page. */}
94
96
  <TableCell className="text-sm text-muted-foreground">
95
97
  {role.description || '—'}
98
+ {role.adminNotes && (
99
+ <div className="mt-1 text-xs italic text-muted-foreground/80">
100
+ Note: {role.adminNotes}
101
+ </div>
102
+ )}
96
103
  </TableCell>
97
104
  </TableRow>
98
105
  ))}
@@ -228,6 +235,22 @@ const UNDOCUMENTED_WITH_CONTACT =
228
235
  const UNDOCUMENTED_WITHOUT_CONTACT =
229
236
  'Access process not documented yet, and no owner is listed for this app.'
230
237
 
238
+ /**
239
+ * The entry's own `adminNotes`, served as `Resource.notes`: the provisioning
240
+ * route an approver follows — the pipeline to run, the group to grant. It is
241
+ * about the whole request rather than one role, so it gets its own block.
242
+ */
243
+ function AdminNotes({ notes }: { notes: string }) {
244
+ return (
245
+ <div>
246
+ <h4 className="mb-2 text-sm font-medium">Admin notes</h4>
247
+ <div className="text-sm text-muted-foreground prose prose-sm max-w-none">
248
+ <MarkdownText>{notes}</MarkdownText>
249
+ </div>
250
+ </div>
251
+ )
252
+ }
253
+
231
254
  /**
232
255
  * The frame every access state renders into, including the empty one — an
233
256
  * entry with no access data still gets the heading, because a missing box
@@ -307,6 +330,10 @@ export function AccessRequestSection({
307
330
  <p className="text-sm text-muted-foreground">
308
331
  {UNDOCUMENTED_WITHOUT_CONTACT}
309
332
  </p>
333
+ {/* No request route written down, but a provisioning note still is:
334
+ that note is then the only thing on the page that says how the
335
+ access gets granted, so it cannot hang off `accessRequest`. */}
336
+ {app.notes && <AdminNotes notes={app.notes} />}
310
337
  </AccessBox>
311
338
  )
312
339
  }
@@ -421,6 +448,10 @@ export function AccessRequestSection({
421
448
  <RolesTable roles={accessRequest.roles} />
422
449
  )}
423
450
 
451
+ {/* After the roles, because the groups and pipelines it names are what
452
+ the roles above are granted through. */}
453
+ {app.notes && <AdminNotes notes={app.notes} />}
454
+
424
455
  {/* Approvers */}
425
456
  {accessRequest.approverSlugs &&
426
457
  accessRequest.approverSlugs.length > 0 && (