@flamingo-stack/openframe-frontend-core 0.0.442 → 0.0.443

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 (107) hide show
  1. package/dist/{chunk-J5NO7PAG.cjs → chunk-2CJ734N2.cjs} +3 -3
  2. package/dist/{chunk-J5NO7PAG.cjs.map → chunk-2CJ734N2.cjs.map} +1 -1
  3. package/dist/{chunk-UFBUQO7Z.cjs → chunk-323TCNF3.cjs} +18 -18
  4. package/dist/{chunk-UFBUQO7Z.cjs.map → chunk-323TCNF3.cjs.map} +1 -1
  5. package/dist/{chunk-63TSTDME.js → chunk-3ZE55IH6.js} +5 -5
  6. package/dist/{chunk-Y3NFB6HR.js → chunk-5U6OZ5FD.js} +4 -4
  7. package/dist/{chunk-IAZ5AN2E.js → chunk-6IPNXMB7.js} +6 -6
  8. package/dist/{chunk-XMCVUYKC.cjs → chunk-6NO4YYU7.cjs} +12 -12
  9. package/dist/{chunk-XMCVUYKC.cjs.map → chunk-6NO4YYU7.cjs.map} +1 -1
  10. package/dist/{chunk-QO6MCQYE.cjs → chunk-7CZ4MJSI.cjs} +13 -10
  11. package/dist/chunk-7CZ4MJSI.cjs.map +1 -0
  12. package/dist/{chunk-FPE3SQ5D.js → chunk-A7MG4SBL.js} +3 -3
  13. package/dist/{chunk-SM26C2L7.js → chunk-AZQOX2RS.js} +2 -2
  14. package/dist/{chunk-JYI4R25Q.js → chunk-DOVBNECK.js} +3 -3
  15. package/dist/{chunk-7KDR5P23.js → chunk-FQASXKBK.js} +2 -2
  16. package/dist/{chunk-LFWPWIAC.cjs → chunk-H4W3NCR2.cjs} +7 -7
  17. package/dist/{chunk-LFWPWIAC.cjs.map → chunk-H4W3NCR2.cjs.map} +1 -1
  18. package/dist/{chunk-DUQQEQ7G.cjs → chunk-IBIGXER5.cjs} +191 -153
  19. package/dist/chunk-IBIGXER5.cjs.map +1 -0
  20. package/dist/{chunk-IZ6A6K6N.cjs → chunk-KNNSS3CZ.cjs} +15 -15
  21. package/dist/{chunk-IZ6A6K6N.cjs.map → chunk-KNNSS3CZ.cjs.map} +1 -1
  22. package/dist/{chunk-AGV26PUR.js → chunk-LGMRNYTX.js} +3 -3
  23. package/dist/{chunk-YZJUHEPZ.js → chunk-LYYVTI34.js} +5 -5
  24. package/dist/{chunk-TAQMYVO6.cjs → chunk-M6XUXQ5Q.cjs} +40 -40
  25. package/dist/{chunk-TAQMYVO6.cjs.map → chunk-M6XUXQ5Q.cjs.map} +1 -1
  26. package/dist/{chunk-2BTDPDME.js → chunk-MG4AMIFX.js} +4 -1
  27. package/dist/chunk-MG4AMIFX.js.map +1 -0
  28. package/dist/{chunk-ZQYFDYWV.js → chunk-NQODKJ2F.js} +121 -83
  29. package/dist/{chunk-ZQYFDYWV.js.map → chunk-NQODKJ2F.js.map} +1 -1
  30. package/dist/{chunk-KUXGQ2FQ.cjs → chunk-OHKKI6EF.cjs} +32 -32
  31. package/dist/{chunk-KUXGQ2FQ.cjs.map → chunk-OHKKI6EF.cjs.map} +1 -1
  32. package/dist/{chunk-JOH5D76X.cjs → chunk-PRJFSF4W.cjs} +73 -73
  33. package/dist/{chunk-JOH5D76X.cjs.map → chunk-PRJFSF4W.cjs.map} +1 -1
  34. package/dist/{chunk-W3JZXLJ2.js → chunk-PVMPBWKX.js} +6 -6
  35. package/dist/{chunk-7KNOHGMI.js → chunk-RQVR7I73.js} +2 -2
  36. package/dist/{chunk-7QP65IE4.cjs → chunk-RYKANQE6.cjs} +5 -5
  37. package/dist/{chunk-7QP65IE4.cjs.map → chunk-RYKANQE6.cjs.map} +1 -1
  38. package/dist/{chunk-QAG357PE.js → chunk-UXJ47OJF.js} +2 -2
  39. package/dist/{chunk-JNC4LVD6.cjs → chunk-VACXEUGF.cjs} +29 -29
  40. package/dist/{chunk-JNC4LVD6.cjs.map → chunk-VACXEUGF.cjs.map} +1 -1
  41. package/dist/{chunk-LUKRPHEW.cjs → chunk-YPFQ2UIH.cjs} +73 -73
  42. package/dist/{chunk-LUKRPHEW.cjs.map → chunk-YPFQ2UIH.cjs.map} +1 -1
  43. package/dist/{chunk-IPEIOR77.cjs → chunk-ZVAAIP5Y.cjs} +40 -40
  44. package/dist/{chunk-IPEIOR77.cjs.map → chunk-ZVAAIP5Y.cjs.map} +1 -1
  45. package/dist/components/case-studies/index.cjs +9 -9
  46. package/dist/components/case-studies/index.js +3 -3
  47. package/dist/components/chat/index.cjs +3 -3
  48. package/dist/components/chat/index.js +2 -2
  49. package/dist/components/contact/index.cjs +4 -4
  50. package/dist/components/contact/index.js +3 -3
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +4 -4
  54. package/dist/components/embeds/index.js +3 -3
  55. package/dist/components/faq/index.cjs +5 -5
  56. package/dist/components/faq/index.js +4 -4
  57. package/dist/components/features/index.cjs +3 -3
  58. package/dist/components/features/index.js +2 -2
  59. package/dist/components/features/policy-configuration-panel.d.ts +11 -0
  60. package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
  61. package/dist/components/help-center-pages/index.cjs +22 -22
  62. package/dist/components/help-center-pages/index.js +13 -13
  63. package/dist/components/index.cjs +135 -135
  64. package/dist/components/index.js +11 -11
  65. package/dist/components/navigation/index.cjs +3 -3
  66. package/dist/components/navigation/index.js +2 -2
  67. package/dist/components/onboarding-guides/index.cjs +7 -7
  68. package/dist/components/onboarding-guides/index.js +6 -6
  69. package/dist/components/related-content/index.cjs +5 -5
  70. package/dist/components/related-content/index.js +4 -4
  71. package/dist/components/tickets/index.cjs +6 -6
  72. package/dist/components/tickets/index.js +5 -5
  73. package/dist/components/ui/index.cjs +3 -3
  74. package/dist/components/ui/index.js +2 -2
  75. package/dist/hooks/index.cjs +2 -2
  76. package/dist/hooks/index.js +1 -1
  77. package/dist/index.cjs +3 -3
  78. package/dist/index.js +2 -2
  79. package/dist/types/permissions.d.ts +9 -6
  80. package/dist/types/permissions.d.ts.map +1 -1
  81. package/dist/utils/embed-authed-fetch.d.ts +19 -3
  82. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  83. package/dist/utils/index.cjs +3 -0
  84. package/dist/utils/index.cjs.map +1 -1
  85. package/dist/utils/index.js +3 -0
  86. package/dist/utils/index.js.map +1 -1
  87. package/package.json +1 -1
  88. package/src/components/features/policy-configuration-panel.tsx +185 -122
  89. package/src/stories/PolicyConfigurationPanel.stories.tsx +254 -0
  90. package/src/types/permissions.ts +10 -7
  91. package/src/utils/__tests__/embed-authed-fetch.test.ts +39 -0
  92. package/src/utils/embed-authed-fetch.ts +27 -3
  93. package/dist/chunk-2BTDPDME.js.map +0 -1
  94. package/dist/chunk-DUQQEQ7G.cjs.map +0 -1
  95. package/dist/chunk-QO6MCQYE.cjs.map +0 -1
  96. /package/dist/{chunk-63TSTDME.js.map → chunk-3ZE55IH6.js.map} +0 -0
  97. /package/dist/{chunk-Y3NFB6HR.js.map → chunk-5U6OZ5FD.js.map} +0 -0
  98. /package/dist/{chunk-IAZ5AN2E.js.map → chunk-6IPNXMB7.js.map} +0 -0
  99. /package/dist/{chunk-FPE3SQ5D.js.map → chunk-A7MG4SBL.js.map} +0 -0
  100. /package/dist/{chunk-SM26C2L7.js.map → chunk-AZQOX2RS.js.map} +0 -0
  101. /package/dist/{chunk-JYI4R25Q.js.map → chunk-DOVBNECK.js.map} +0 -0
  102. /package/dist/{chunk-7KDR5P23.js.map → chunk-FQASXKBK.js.map} +0 -0
  103. /package/dist/{chunk-AGV26PUR.js.map → chunk-LGMRNYTX.js.map} +0 -0
  104. /package/dist/{chunk-YZJUHEPZ.js.map → chunk-LYYVTI34.js.map} +0 -0
  105. /package/dist/{chunk-W3JZXLJ2.js.map → chunk-PVMPBWKX.js.map} +0 -0
  106. /package/dist/{chunk-7KNOHGMI.js.map → chunk-RQVR7I73.js.map} +0 -0
  107. /package/dist/{chunk-QAG357PE.js.map → chunk-UXJ47OJF.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.442",
3
+ "version": "0.0.443",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { useRef, useEffect, useState } from 'react'
5
- import { ChevronDown } from 'lucide-react'
5
+ import { ChevronDown, Shield } from 'lucide-react'
6
6
  import { cn } from '../../utils/cn'
7
7
  import { ToolIcon } from '../tool-icon'
8
8
  import { Button } from '../ui/button'
@@ -34,62 +34,110 @@ const getApprovalLevelLabel = (level: ApprovalLevel | undefined, editMode: boole
34
34
  return option?.label || level
35
35
  }
36
36
 
37
+ const ApprovalLevelDropdown: React.FC<{
38
+ value: ApprovalLevel | undefined;
39
+ onChange: (level: ApprovalLevel | undefined) => void;
40
+ /** Adds the "Clear Global Permission" item (category bulk dropdown). */
41
+ allowClear?: boolean;
42
+ triggerClassName?: string;
43
+ contentClassName?: string;
44
+ }> = ({ value, onChange, allowClear = false, triggerClassName, contentClassName }) => (
45
+ <DropdownMenu>
46
+ <DropdownMenuTrigger asChild>
47
+ <Button
48
+ variant="outline"
49
+ rightIcon={<ChevronDown className="h-6 w-6 text-ods-text-secondary" />}
50
+ className={cn(
51
+ "!text-h6 bg-ods-card border border-ods-border rounded-md flex gap-[var(--spacing-system-xsf)] items-center justify-between !px-[var(--spacing-system-xsf)] py-[var(--spacing-system-xsf)] hover:bg-ods-bg-hover transition-colors h-auto",
52
+ value ? "text-ods-text-primary" : "text-ods-text-secondary",
53
+ triggerClassName
54
+ )}
55
+ >
56
+ {getApprovalLevelLabel(value, true)}
57
+ </Button>
58
+ </DropdownMenuTrigger>
59
+ <DropdownMenuContent align="end" className={contentClassName}>
60
+ {allowClear && (
61
+ <DropdownMenuItem onClick={() => onChange(undefined)} className="text-h6">
62
+ Clear Global Permission
63
+ </DropdownMenuItem>
64
+ )}
65
+ {approvalLevelOptions.map(option => (
66
+ <DropdownMenuItem
67
+ key={option.value}
68
+ onClick={() => onChange(option.value)}
69
+ className="text-h6"
70
+ >
71
+ {option.label}
72
+ </DropdownMenuItem>
73
+ ))}
74
+ </DropdownMenuContent>
75
+ </DropdownMenu>
76
+ )
77
+
37
78
  const PolicyRow: React.FC<{
38
79
  policy: PermissionPolicy;
39
80
  categoryId: string;
40
81
  editMode: boolean;
41
82
  onPermissionChange: (categoryId: string, policyId: string, level: ApprovalLevel) => void;
42
83
  }> = ({ policy, categoryId, editMode, onPermissionChange }) => {
84
+ const handleChange = (level: ApprovalLevel | undefined) => {
85
+ if (level) onPermissionChange(categoryId, policy.id, level)
86
+ }
87
+
43
88
  return (
44
- <div className="bg-ods-bg border-b border-ods-border flex gap-4 items-center px-4 py-3">
89
+ <div className="bg-ods-bg border-b last:border-b-0 border-ods-border flex gap-[var(--spacing-system-m)] items-start md:items-center px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)]">
45
90
  {/* Tool Icon */}
46
- <div className="bg-ods-bg border border-ods-border rounded-md flex items-center justify-center w-8 h-8">
91
+ <div className="bg-ods-bg border border-ods-border rounded-md flex items-center justify-center w-8 h-8 shrink-0">
47
92
  <ToolIcon toolType={policy.toolName} size={16} />
48
93
  </div>
49
94
 
50
- {/* Policy Info */}
95
+ {/* Policy Info — on mobile the approval level stacks below the pattern */}
51
96
  <div className="flex-1 flex flex-col min-w-0">
52
- <p className="text-h6 text-ods-text-primary truncate" title={policy.name}>
97
+ <p className="text-h4 text-ods-text-primary truncate" title={policy.name}>
53
98
  {policy.name}
54
99
  </p>
55
- <p className="text-code text-ods-text-secondary break-all">
100
+ <p
101
+ className="text-h6 text-ods-text-secondary truncate md:whitespace-normal md:break-all"
102
+ title={policy.commandPattern}
103
+ >
56
104
  {policy.commandPattern}
57
105
  </p>
106
+ {editMode ? (
107
+ <div className="md:hidden mt-[var(--spacing-system-xsf)]">
108
+ <ApprovalLevelDropdown
109
+ value={policy.approvalLevel}
110
+ onChange={handleChange}
111
+ triggerClassName="w-full"
112
+ contentClassName="w-[180px]"
113
+ />
114
+ </div>
115
+ ) : policy.approvalLevel ? (
116
+ <span className="md:hidden text-h4 text-ods-text-primary">
117
+ {getApprovalLevelLabel(policy.approvalLevel)}
118
+ </span>
119
+ ) : null}
58
120
  </div>
59
121
 
60
- {/* Approval Level Dropdown */}
61
- {editMode ? (
62
- <DropdownMenu>
63
- <DropdownMenuTrigger asChild>
64
- <Button
65
- variant="outline"
66
- rightIcon={<ChevronDown className="h-6 w-6 text-ods-text-secondary" />}
67
- className="md:!text-sm bg-ods-card border border-ods-border rounded-md flex gap-2 items-center justify-between !px-2 py-2 w-[180px] hover:bg-ods-bg-hover transition-colors text-ods-text-primary h-auto"
68
- >
69
- {getApprovalLevelLabel(policy.approvalLevel, editMode)}
70
- </Button>
71
- </DropdownMenuTrigger>
72
- <DropdownMenuContent align="end" className="w-[180px]">
73
- {approvalLevelOptions.map(option => (
74
- <DropdownMenuItem
75
- key={option.value}
76
- onClick={() => onPermissionChange(categoryId, policy.id, option.value)}
77
- className="text-h6"
78
- >
79
- {option.label}
80
- </DropdownMenuItem>
81
- ))}
82
- </DropdownMenuContent>
83
- </DropdownMenu>
84
- ) : policy.approvalLevel ? (
85
- <div className="px-3 py-2 w-[180px]">
86
- <span className="text-h6 text-ods-text-primary">
87
- {getApprovalLevelLabel(policy.approvalLevel, editMode)}
88
- </span>
89
- </div>
90
- ) : (
91
- <div className="px-3 py-2 w-[180px]" />
92
- )}
122
+ {/* Approval Level column (desktop only) */}
123
+ <div className="hidden md:block shrink-0">
124
+ {editMode ? (
125
+ <ApprovalLevelDropdown
126
+ value={policy.approvalLevel}
127
+ onChange={handleChange}
128
+ triggerClassName="w-[180px]"
129
+ contentClassName="w-[180px]"
130
+ />
131
+ ) : (
132
+ <div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)] w-[180px]">
133
+ {policy.approvalLevel && (
134
+ <span className="text-h4 text-ods-text-primary">
135
+ {getApprovalLevelLabel(policy.approvalLevel)}
136
+ </span>
137
+ )}
138
+ </div>
139
+ )}
140
+ </div>
93
141
  </div>
94
142
  )
95
143
  }
@@ -97,7 +145,7 @@ const PolicyRow: React.FC<{
97
145
  const useAnimatedHeight = (isExpanded: boolean) => {
98
146
  const [height, setHeight] = useState<number | 'auto'>(0);
99
147
  const contentRef = useRef<HTMLDivElement>(null);
100
-
148
+
101
149
  useEffect(() => {
102
150
  if (contentRef.current) {
103
151
  const contentHeight = contentRef.current.scrollHeight;
@@ -111,93 +159,73 @@ const useAnimatedHeight = (isExpanded: boolean) => {
111
159
  const CategorySection: React.FC<{
112
160
  category: PermissionCategory;
113
161
  editMode: boolean;
114
- onCategoryToggle: (categoryId: string) => void;
115
- onGlobalPermissionChange: (categoryId: string, level: ApprovalLevel | undefined) => void;
162
+ isExpanded: boolean;
163
+ /** Last bulk level applied to this category (display-only, edit mode). */
164
+ bulkLevel: ApprovalLevel | undefined;
165
+ onToggle: (categoryId: string) => void;
166
+ onBulkLevelChange: (categoryId: string, level: ApprovalLevel | undefined) => void;
116
167
  onPolicyPermissionChange: (categoryId: string, policyId: string, level: ApprovalLevel) => void;
117
- }> = ({
118
- category,
119
- editMode,
120
- onCategoryToggle,
121
- onGlobalPermissionChange,
122
- onPolicyPermissionChange
168
+ }> = ({
169
+ category,
170
+ editMode,
171
+ isExpanded,
172
+ bulkLevel,
173
+ onToggle,
174
+ onBulkLevelChange,
175
+ onPolicyPermissionChange
123
176
  }) => {
124
- const { contentRef, height } = useAnimatedHeight(category.isExpanded);
125
-
177
+ const { contentRef, height } = useAnimatedHeight(isExpanded);
178
+ const handleBulkChange = (level: ApprovalLevel | undefined) => onBulkLevelChange(category.id, level)
179
+
126
180
  return (
127
181
  <>
128
182
  {/* Category Header */}
129
- <div
130
- className="bg-ods-card border-t border-ods-border flex gap-4 items-center pl-4 pr-2 py-3 cursor-pointer hover:bg-ods-bg-hover transition-colors"
131
- onClick={() => onCategoryToggle(category.id)}
183
+ <div
184
+ className="bg-ods-card border-t first:border-t-0 border-ods-border flex gap-[var(--spacing-system-m)] items-center pl-[var(--spacing-system-m)] pr-[var(--spacing-system-xsf)] py-[var(--spacing-system-s)] cursor-pointer hover:bg-ods-bg-hover transition-colors"
185
+ onClick={() => onToggle(category.id)}
132
186
  >
133
187
  {/* Category Icon */}
134
- <div className="bg-ods-bg border border-ods-border rounded-md flex items-center justify-center w-8 h-8">
188
+ <div className="bg-ods-bg border border-ods-border rounded-md flex items-center justify-center w-8 h-8 shrink-0">
135
189
  <div className="text-ods-text-secondary">
136
- {category.icon}
190
+ {category.icon ?? <Shield className="w-4 h-4" />}
137
191
  </div>
138
192
  </div>
139
193
 
140
- {/* Category Info */}
141
- <div className="flex-1 flex flex-col">
142
- <p className="md:!text-sm font-medium text-ods-text-primary">
194
+ {/* Category Info — on mobile the bulk dropdown stacks below the count */}
195
+ <div className="flex-1 flex flex-col min-w-0">
196
+ <p className="text-h4 text-ods-text-primary truncate" title={category.name}>
143
197
  {category.name}
144
198
  </p>
145
199
  <p className="text-h6 text-ods-text-secondary">
146
- {category.configurationsCount} Configurations
200
+ {category.policies.length} Configurations
147
201
  </p>
202
+ {editMode && (
203
+ <div className="md:hidden mt-[var(--spacing-system-xsf)]" onClick={(e) => e.stopPropagation()}>
204
+ <ApprovalLevelDropdown
205
+ value={bulkLevel}
206
+ onChange={handleBulkChange}
207
+ allowClear
208
+ triggerClassName="w-full"
209
+ contentClassName="w-[256px]"
210
+ />
211
+ </div>
212
+ )}
148
213
  </div>
149
214
 
150
- {/* Global Permission Dropdown */}
215
+ {/* Global Permission column (desktop only) */}
151
216
  {editMode ? (
152
- <div onClick={(e) => e.stopPropagation()}>
153
- <DropdownMenu>
154
- <DropdownMenuTrigger asChild>
155
- <Button
156
- variant="outline"
157
- rightIcon={<ChevronDown className="h-6 w-6 text-ods-text-secondary" />}
158
- className="md:!text-sm bg-ods-card border border-ods-border rounded-md flex gap-2 items-center justify-between !px-2 py-2 w-[256px] hover:bg-ods-bg-hover transition-colors h-auto"
159
- >
160
- <span className={cn(
161
- "md:!text-sm font-medium",
162
- category.globalPermission
163
- ? "text-ods-text-primary"
164
- : "text-ods-text-secondary"
165
- )}>
166
- {getApprovalLevelLabel(category.globalPermission, editMode)}
167
- </span>
168
- </Button>
169
- </DropdownMenuTrigger>
170
- <DropdownMenuContent align="end" className="w-[256px]">
171
- <DropdownMenuItem
172
- onClick={() => onGlobalPermissionChange(category.id, undefined)}
173
- className="md:!text-sm"
174
- >
175
- Clear Global Permission
176
- </DropdownMenuItem>
177
- {approvalLevelOptions.map(option => (
178
- <DropdownMenuItem
179
- key={option.value}
180
- onClick={() => onGlobalPermissionChange(category.id, option.value)}
181
- className="md:!text-sm"
182
- >
183
- {option.label}
184
- </DropdownMenuItem>
185
- ))}
186
- </DropdownMenuContent>
187
- </DropdownMenu>
188
- </div>
189
- ) : category.globalPermission ? (
190
- <div
191
- className="px-3 py-2 w-[256px]"
192
- onClick={(e) => e.stopPropagation()}
193
- >
194
- <span className="md:!text-sm font-medium text-ods-text-primary">
195
- {getApprovalLevelLabel(category.globalPermission, editMode)}
196
- </span>
217
+ <div className="hidden md:block shrink-0" onClick={(e) => e.stopPropagation()}>
218
+ <ApprovalLevelDropdown
219
+ value={bulkLevel}
220
+ onChange={handleBulkChange}
221
+ allowClear
222
+ triggerClassName="w-[256px]"
223
+ contentClassName="w-[256px]"
224
+ />
197
225
  </div>
198
226
  ) : (
199
- <div
200
- className="px-3 py-2 w-[256px]"
227
+ <div
228
+ className="hidden md:block px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)] w-[256px] shrink-0"
201
229
  onClick={(e) => e.stopPropagation()}
202
230
  />
203
231
  )}
@@ -208,32 +236,32 @@ const CategorySection: React.FC<{
208
236
  variant="transparent"
209
237
  onClick={(e) => {
210
238
  e.stopPropagation();
211
- onCategoryToggle(category.id);
239
+ onToggle(category.id);
212
240
  }}
213
- leftIcon={<ChevronDown
241
+ leftIcon={<ChevronDown
214
242
  className={cn(
215
243
  "h-6 w-6 text-ods-text-primary transition-transform duration-300",
216
- category.isExpanded && "rotate-180"
217
- )}
244
+ isExpanded && "rotate-180"
245
+ )}
218
246
  />}
219
- className="rounded-md h-auto w-auto"
220
- aria-label={category.isExpanded ? "Collapse" : "Expand"}
247
+ className="rounded-md h-auto w-auto shrink-0"
248
+ aria-label={isExpanded ? "Collapse" : "Expand"}
221
249
  />
222
250
  </div>
223
251
 
224
252
  {/* Policies List */}
225
- <div
253
+ <div
226
254
  ref={contentRef}
227
- style={{
255
+ style={{
228
256
  height: height === 'auto' ? 'auto' : `${height}px`,
229
257
  transition: 'height 300ms ease-in-out, opacity 300ms ease-in-out'
230
258
  }}
231
259
  className={cn(
232
260
  "overflow-hidden",
233
- category.isExpanded ? "opacity-100" : "opacity-0"
261
+ isExpanded ? "opacity-100" : "opacity-0"
234
262
  )}
235
263
  >
236
- <div className="px-4 pb-4 pt-0">
264
+ <div className="px-[var(--spacing-system-mf)] pb-[var(--spacing-system-mf)] pt-0">
237
265
  <div className="border border-ods-border rounded-md overflow-hidden">
238
266
  {category.policies.map((policy) => (
239
267
  <PolicyRow
@@ -251,14 +279,47 @@ const CategorySection: React.FC<{
251
279
  )
252
280
  }
253
281
 
282
+ /**
283
+ * Grouped list of permission categories with per-policy approval dropdowns.
284
+ * Presentation state — which categories are expanded and the last bulk level
285
+ * picked per category — lives inside the panel, so callers pass pure data and
286
+ * background data refreshes never collapse the user's view. Bulk selections
287
+ * reset when `editMode` changes.
288
+ *
289
+ * Responsive: below `md` the approval level / dropdowns stack inside the text
290
+ * column (per the ODS mobile design); at `md+` they render as fixed-width
291
+ * right-hand columns.
292
+ */
254
293
  export const PolicyConfigurationPanel: React.FC<PolicyConfigurationPanelProps> = ({
255
294
  categories,
256
295
  editMode,
257
- onCategoryToggle,
258
- onGlobalPermissionChange,
259
296
  onPolicyPermissionChange,
297
+ onCategoryPermissionChange,
260
298
  className
261
299
  }) => {
300
+ const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(new Set())
301
+ const [bulkLevels, setBulkLevels] = useState<Record<string, ApprovalLevel | undefined>>({})
302
+
303
+ useEffect(() => {
304
+ setBulkLevels({})
305
+ }, [editMode])
306
+
307
+ const handleToggle = (categoryId: string) => {
308
+ setExpandedIds(prev => {
309
+ const next = new Set(prev)
310
+ if (next.has(categoryId)) next.delete(categoryId)
311
+ else next.add(categoryId)
312
+ return next
313
+ })
314
+ }
315
+
316
+ const handleBulkLevelChange = (categoryId: string, level: ApprovalLevel | undefined) => {
317
+ setBulkLevels(prev => ({ ...prev, [categoryId]: level }))
318
+ if (level) {
319
+ onCategoryPermissionChange(categoryId, level)
320
+ }
321
+ }
322
+
262
323
  return (
263
324
  <div className={cn(
264
325
  "bg-ods-card border border-ods-border rounded-md overflow-hidden",
@@ -269,8 +330,10 @@ export const PolicyConfigurationPanel: React.FC<PolicyConfigurationPanelProps> =
269
330
  key={category.id}
270
331
  category={category}
271
332
  editMode={editMode}
272
- onCategoryToggle={onCategoryToggle}
273
- onGlobalPermissionChange={onGlobalPermissionChange}
333
+ isExpanded={expandedIds.has(category.id)}
334
+ bulkLevel={bulkLevels[category.id]}
335
+ onToggle={handleToggle}
336
+ onBulkLevelChange={handleBulkLevelChange}
274
337
  onPolicyPermissionChange={onPolicyPermissionChange}
275
338
  />
276
339
  ))}
@@ -278,4 +341,4 @@ export const PolicyConfigurationPanel: React.FC<PolicyConfigurationPanelProps> =
278
341
  )
279
342
  }
280
343
 
281
- PolicyConfigurationPanel.displayName = 'PolicyConfigurationPanel'
344
+ PolicyConfigurationPanel.displayName = 'PolicyConfigurationPanel'
@@ -0,0 +1,254 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
+ import { useState } from 'react'
3
+ import { fn } from 'storybook/test'
4
+ import { PolicyConfigurationPanel } from '../components/features/policy-configuration-panel'
5
+ import type { ApprovalLevel, PermissionCategory } from '../types/permissions'
6
+
7
+ const meta = {
8
+ title: 'Features/PolicyConfigurationPanel',
9
+ component: PolicyConfigurationPanel,
10
+ parameters: {
11
+ layout: 'padded',
12
+ docs: {
13
+ description: {
14
+ component:
15
+ 'Grouped list of AI guardrails permission categories with per-policy approval dropdowns. ' +
16
+ 'Categories are pure data — the panel owns its presentation state internally: which categories ' +
17
+ 'are expanded and the last bulk level picked per category (bulk selections reset when `editMode` ' +
18
+ 'changes). In edit mode each category header offers a bulk "global permission" dropdown that ' +
19
+ 'fires `onCategoryPermissionChange`; each policy row fires `onPolicyPermissionChange`. ' +
20
+ 'Used by the OpenFrame AI Settings → Guardrails tab.',
21
+ },
22
+ },
23
+ },
24
+ tags: ['autodocs'],
25
+ argTypes: {
26
+ editMode: { control: 'boolean' },
27
+ },
28
+ decorators: [
29
+ (Story) => (
30
+ <div style={{ maxWidth: '960px' }}>
31
+ <Story />
32
+ </div>
33
+ ),
34
+ ],
35
+ } satisfies Meta<typeof PolicyConfigurationPanel>
36
+
37
+ export default meta
38
+ type Story = StoryObj<typeof meta>
39
+
40
+ const deviceCategories: PermissionCategory[] = [
41
+ {
42
+ id: 'device-actions',
43
+ name: 'Device Actions',
44
+ policies: [
45
+ {
46
+ id: 'OPENFRAME_RMM:DEVICE_REBOOT',
47
+ naturalKey: 'OPENFRAME_RMM:DEVICE_REBOOT',
48
+ name: 'Reboot Device',
49
+ commandPattern: 'device.reboot --id {deviceId}',
50
+ toolName: 'OPENFRAME_RMM',
51
+ approvalLevel: 'ASK_USER',
52
+ },
53
+ {
54
+ id: 'FLEET_MDM:DEVICE_LOCK',
55
+ naturalKey: 'FLEET_MDM:DEVICE_LOCK',
56
+ name: 'Lock Device',
57
+ commandPattern: 'fleet mdm lock --host {hostId}',
58
+ toolName: 'FLEET_MDM',
59
+ approvalLevel: 'ASK_TECHNICIAN',
60
+ },
61
+ {
62
+ id: 'FLEET_MDM:DEVICE_WIPE',
63
+ naturalKey: 'FLEET_MDM:DEVICE_WIPE',
64
+ name: 'Wipe Device',
65
+ commandPattern: 'fleet mdm wipe --host {hostId}',
66
+ toolName: 'FLEET_MDM',
67
+ approvalLevel: 'DENY',
68
+ },
69
+ ],
70
+ },
71
+ {
72
+ id: 'remote-access',
73
+ name: 'Remote Access',
74
+ policies: [
75
+ {
76
+ id: 'MESHCENTRAL:REMOTE_SHELL',
77
+ naturalKey: 'MESHCENTRAL:REMOTE_SHELL',
78
+ name: 'Open Remote Shell',
79
+ commandPattern: 'mesh.shell --node {nodeId}',
80
+ toolName: 'MESHCENTRAL',
81
+ approvalLevel: 'ASK_TECHNICIAN',
82
+ },
83
+ {
84
+ id: 'MESHCENTRAL:FILE_TRANSFER',
85
+ naturalKey: 'MESHCENTRAL:FILE_TRANSFER',
86
+ name: 'Transfer Files',
87
+ commandPattern: 'mesh.files --node {nodeId} --upload|--download',
88
+ toolName: 'MESHCENTRAL',
89
+ approvalLevel: 'DENY',
90
+ },
91
+ ],
92
+ },
93
+ {
94
+ id: 'scripts-automation',
95
+ name: 'Scripts & Automation',
96
+ policies: [
97
+ {
98
+ id: 'OPENFRAME_RMM:RUN_POWERSHELL',
99
+ naturalKey: 'OPENFRAME_RMM:RUN_POWERSHELL',
100
+ name: 'Run PowerShell Script',
101
+ commandPattern: 'powershell -ExecutionPolicy Bypass -File {script}',
102
+ toolName: 'OPENFRAME_RMM',
103
+ approvalLevel: 'ASK_USER',
104
+ },
105
+ {
106
+ id: 'OPENFRAME_RMM:RUN_BASH',
107
+ naturalKey: 'OPENFRAME_RMM:RUN_BASH',
108
+ name: 'Run Bash Script',
109
+ commandPattern: 'bash -c {script}',
110
+ toolName: 'OPENFRAME_RMM',
111
+ approvalLevel: 'ASK_USER',
112
+ },
113
+ ],
114
+ },
115
+ ]
116
+
117
+ const dataCategories: PermissionCategory[] = [
118
+ {
119
+ id: 'data-read',
120
+ name: 'Read Access',
121
+ policies: [
122
+ {
123
+ id: 'OSQUERY:QUERY_INVENTORY',
124
+ naturalKey: 'OSQUERY:QUERY_INVENTORY',
125
+ name: 'Query Device Inventory',
126
+ commandPattern: 'SELECT * FROM system_info;',
127
+ toolName: 'OSQUERY',
128
+ approvalLevel: 'ALLOW',
129
+ },
130
+ {
131
+ id: 'SYSTEM:READ_LOGS',
132
+ naturalKey: 'SYSTEM:READ_LOGS',
133
+ name: 'Read System Logs',
134
+ commandPattern: 'logs.read --stream {stream} --since {ts}',
135
+ toolName: 'SYSTEM',
136
+ approvalLevel: 'ALLOW',
137
+ },
138
+ ],
139
+ },
140
+ ]
141
+
142
+ /** Applies a level to one policy; mirrors what the guardrails editor hook does. */
143
+ const applyPolicyLevel = (
144
+ categories: PermissionCategory[],
145
+ categoryId: string,
146
+ policyId: string,
147
+ level: ApprovalLevel,
148
+ ): PermissionCategory[] =>
149
+ categories.map(category =>
150
+ category.id !== categoryId
151
+ ? category
152
+ : {
153
+ ...category,
154
+ policies: category.policies.map(policy =>
155
+ policy.id === policyId ? { ...policy, approvalLevel: level } : policy,
156
+ ),
157
+ },
158
+ )
159
+
160
+ /** Bulk-applies a level to every policy in a category. */
161
+ const applyCategoryLevel = (
162
+ categories: PermissionCategory[],
163
+ categoryId: string,
164
+ level: ApprovalLevel,
165
+ ): PermissionCategory[] =>
166
+ categories.map(category =>
167
+ category.id !== categoryId
168
+ ? category
169
+ : { ...category, policies: category.policies.map(policy => ({ ...policy, approvalLevel: level })) },
170
+ )
171
+
172
+ /** Read-only view: approval levels render as text, no bulk dropdowns. */
173
+ export const ReadOnly: Story = {
174
+ args: {
175
+ categories: deviceCategories,
176
+ editMode: false,
177
+ onPolicyPermissionChange: fn(),
178
+ onCategoryPermissionChange: fn(),
179
+ },
180
+ }
181
+
182
+ /**
183
+ * Interactive edit mode: per-policy dropdowns plus a bulk "global permission"
184
+ * dropdown per category header. The wrapper applies changes back into the
185
+ * category data the same way the OpenFrame guardrails editor does.
186
+ */
187
+ export const EditMode: Story = {
188
+ args: {
189
+ categories: deviceCategories,
190
+ editMode: true,
191
+ onPolicyPermissionChange: fn(),
192
+ onCategoryPermissionChange: fn(),
193
+ },
194
+ render: args => {
195
+ const [categories, setCategories] = useState(args.categories)
196
+
197
+ return (
198
+ <PolicyConfigurationPanel
199
+ {...args}
200
+ categories={categories}
201
+ onPolicyPermissionChange={(categoryId, policyId, level) => {
202
+ args.onPolicyPermissionChange(categoryId, policyId, level)
203
+ setCategories(prev => applyPolicyLevel(prev, categoryId, policyId, level))
204
+ }}
205
+ onCategoryPermissionChange={(categoryId, level) => {
206
+ args.onCategoryPermissionChange(categoryId, level)
207
+ setCategories(prev => applyCategoryLevel(prev, categoryId, level))
208
+ }}
209
+ />
210
+ )
211
+ },
212
+ }
213
+
214
+ /**
215
+ * How the guardrails tab composes the panel: one panel per policy group, each
216
+ * under a small secondary heading.
217
+ */
218
+ export const GroupedPolicySections: Story = {
219
+ args: {
220
+ categories: deviceCategories,
221
+ editMode: true,
222
+ onPolicyPermissionChange: fn(),
223
+ onCategoryPermissionChange: fn(),
224
+ },
225
+ render: args => {
226
+ const [groups, setGroups] = useState<Record<string, PermissionCategory[]>>({
227
+ 'Device Management': deviceCategories,
228
+ 'Data Access': dataCategories,
229
+ })
230
+
231
+ const updateGroup = (groupName: string, next: (categories: PermissionCategory[]) => PermissionCategory[]) =>
232
+ setGroups(prev => ({ ...prev, [groupName]: next(prev[groupName]) }))
233
+
234
+ return (
235
+ <div className="flex flex-col gap-[var(--spacing-system-l)]">
236
+ {Object.entries(groups).map(([groupName, categories]) => (
237
+ <div key={groupName} className="flex flex-col gap-[var(--spacing-system-xxs)]">
238
+ <p className="text-h5 text-ods-text-secondary truncate">{groupName}</p>
239
+ <PolicyConfigurationPanel
240
+ categories={categories}
241
+ editMode={args.editMode}
242
+ onPolicyPermissionChange={(categoryId, policyId, level) =>
243
+ updateGroup(groupName, categories => applyPolicyLevel(categories, categoryId, policyId, level))
244
+ }
245
+ onCategoryPermissionChange={(categoryId, level) =>
246
+ updateGroup(groupName, categories => applyCategoryLevel(categories, categoryId, level))
247
+ }
248
+ />
249
+ </div>
250
+ ))}
251
+ </div>
252
+ )
253
+ },
254
+ }