@flamingo-stack/openframe-frontend-core 0.0.442 → 0.0.443-snapshot.20260717175152
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.
- package/dist/{chunk-J5NO7PAG.cjs → chunk-2CJ734N2.cjs} +3 -3
- package/dist/{chunk-J5NO7PAG.cjs.map → chunk-2CJ734N2.cjs.map} +1 -1
- package/dist/{chunk-KUXGQ2FQ.cjs → chunk-2PLNDTRG.cjs} +32 -32
- package/dist/{chunk-KUXGQ2FQ.cjs.map → chunk-2PLNDTRG.cjs.map} +1 -1
- package/dist/{chunk-Y3NFB6HR.js → chunk-37ZZQXHK.js} +4 -4
- package/dist/{chunk-YZJUHEPZ.js → chunk-3SCNEGL2.js} +5 -5
- package/dist/{chunk-DUQQEQ7G.cjs → chunk-4R3SVOKH.cjs} +190 -156
- package/dist/chunk-4R3SVOKH.cjs.map +1 -0
- package/dist/{chunk-QO6MCQYE.cjs → chunk-7CZ4MJSI.cjs} +13 -10
- package/dist/chunk-7CZ4MJSI.cjs.map +1 -0
- package/dist/{chunk-W3JZXLJ2.js → chunk-ABIRMYG5.js} +6 -6
- package/dist/{chunk-TAQMYVO6.cjs → chunk-BYTJNRJW.cjs} +40 -40
- package/dist/{chunk-TAQMYVO6.cjs.map → chunk-BYTJNRJW.cjs.map} +1 -1
- package/dist/{chunk-IAZ5AN2E.js → chunk-CE3AITV2.js} +6 -6
- package/dist/{chunk-FPE3SQ5D.js → chunk-DCDB3SM4.js} +3 -3
- package/dist/{chunk-LFWPWIAC.cjs → chunk-DSW6FIEF.cjs} +7 -7
- package/dist/{chunk-LFWPWIAC.cjs.map → chunk-DSW6FIEF.cjs.map} +1 -1
- package/dist/{chunk-JYI4R25Q.js → chunk-DTMJWBDQ.js} +3 -3
- package/dist/{chunk-SM26C2L7.js → chunk-F2XHBAHT.js} +2 -2
- package/dist/{chunk-JNC4LVD6.cjs → chunk-F6NMMPDL.cjs} +29 -29
- package/dist/{chunk-JNC4LVD6.cjs.map → chunk-F6NMMPDL.cjs.map} +1 -1
- package/dist/{chunk-7QP65IE4.cjs → chunk-FO4CI4R5.cjs} +5 -5
- package/dist/{chunk-7QP65IE4.cjs.map → chunk-FO4CI4R5.cjs.map} +1 -1
- package/dist/{chunk-7KDR5P23.js → chunk-FQASXKBK.js} +2 -2
- package/dist/{chunk-7KNOHGMI.js → chunk-H7H3AFLF.js} +2 -2
- package/dist/{chunk-QAG357PE.js → chunk-LCJGOV6P.js} +2 -2
- package/dist/{chunk-2BTDPDME.js → chunk-MG4AMIFX.js} +4 -1
- package/dist/chunk-MG4AMIFX.js.map +1 -0
- package/dist/{chunk-ZQYFDYWV.js → chunk-NDWIFCLS.js} +120 -86
- package/dist/chunk-NDWIFCLS.js.map +1 -0
- package/dist/{chunk-UFBUQO7Z.cjs → chunk-OXZLBIR7.cjs} +18 -18
- package/dist/{chunk-UFBUQO7Z.cjs.map → chunk-OXZLBIR7.cjs.map} +1 -1
- package/dist/{chunk-63TSTDME.js → chunk-PMTR5W5G.js} +5 -5
- package/dist/{chunk-XMCVUYKC.cjs → chunk-Q6XOEVPH.cjs} +12 -12
- package/dist/{chunk-XMCVUYKC.cjs.map → chunk-Q6XOEVPH.cjs.map} +1 -1
- package/dist/{chunk-IZ6A6K6N.cjs → chunk-RALJEQWM.cjs} +15 -15
- package/dist/{chunk-IZ6A6K6N.cjs.map → chunk-RALJEQWM.cjs.map} +1 -1
- package/dist/{chunk-AGV26PUR.js → chunk-U7U2CZBZ.js} +3 -3
- package/dist/{chunk-LUKRPHEW.cjs → chunk-VI2QQIGR.cjs} +73 -73
- package/dist/{chunk-LUKRPHEW.cjs.map → chunk-VI2QQIGR.cjs.map} +1 -1
- package/dist/{chunk-JOH5D76X.cjs → chunk-WXU6A6CA.cjs} +73 -73
- package/dist/{chunk-JOH5D76X.cjs.map → chunk-WXU6A6CA.cjs.map} +1 -1
- package/dist/{chunk-IPEIOR77.cjs → chunk-YP7VJPCJ.cjs} +40 -40
- package/dist/{chunk-IPEIOR77.cjs.map → chunk-YP7VJPCJ.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/features/policy-configuration-panel.d.ts +11 -0
- package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +135 -135
- package/dist/components/index.js +11 -11
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/checkbox-block.d.ts +6 -0
- package/dist/components/ui/checkbox-block.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/hooks/index.cjs +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/index.cjs +3 -3
- package/dist/index.js +2 -2
- package/dist/types/permissions.d.ts +9 -6
- package/dist/types/permissions.d.ts.map +1 -1
- package/dist/utils/embed-authed-fetch.d.ts +19 -3
- package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
- package/dist/utils/index.cjs +3 -0
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js +3 -0
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/features/policy-configuration-panel.tsx +182 -122
- package/src/components/ui/checkbox-block.tsx +12 -1
- package/src/stories/CheckboxBlock.stories.tsx +18 -0
- package/src/stories/PolicyConfigurationPanel.stories.tsx +254 -0
- package/src/types/permissions.ts +10 -7
- package/src/utils/__tests__/embed-authed-fetch.test.ts +39 -0
- package/src/utils/embed-authed-fetch.ts +27 -3
- package/dist/chunk-2BTDPDME.js.map +0 -1
- package/dist/chunk-DUQQEQ7G.cjs.map +0 -1
- package/dist/chunk-QO6MCQYE.cjs.map +0 -1
- package/dist/chunk-ZQYFDYWV.js.map +0 -1
- /package/dist/{chunk-Y3NFB6HR.js.map → chunk-37ZZQXHK.js.map} +0 -0
- /package/dist/{chunk-YZJUHEPZ.js.map → chunk-3SCNEGL2.js.map} +0 -0
- /package/dist/{chunk-W3JZXLJ2.js.map → chunk-ABIRMYG5.js.map} +0 -0
- /package/dist/{chunk-IAZ5AN2E.js.map → chunk-CE3AITV2.js.map} +0 -0
- /package/dist/{chunk-FPE3SQ5D.js.map → chunk-DCDB3SM4.js.map} +0 -0
- /package/dist/{chunk-JYI4R25Q.js.map → chunk-DTMJWBDQ.js.map} +0 -0
- /package/dist/{chunk-SM26C2L7.js.map → chunk-F2XHBAHT.js.map} +0 -0
- /package/dist/{chunk-7KDR5P23.js.map → chunk-FQASXKBK.js.map} +0 -0
- /package/dist/{chunk-7KNOHGMI.js.map → chunk-H7H3AFLF.js.map} +0 -0
- /package/dist/{chunk-QAG357PE.js.map → chunk-LCJGOV6P.js.map} +0 -0
- /package/dist/{chunk-63TSTDME.js.map → chunk-PMTR5W5G.js.map} +0 -0
- /package/dist/{chunk-AGV26PUR.js.map → chunk-U7U2CZBZ.js.map} +0 -0
|
@@ -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
|
+
}
|
package/src/types/permissions.ts
CHANGED
|
@@ -11,21 +11,24 @@ export interface PermissionPolicy {
|
|
|
11
11
|
approvalLevel: ApprovalLevel;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Pure data describing one permission category. Presentation state (expansion,
|
|
16
|
+
* bulk-selection display) is owned by PolicyConfigurationPanel itself.
|
|
17
|
+
*/
|
|
14
18
|
export interface PermissionCategory {
|
|
15
19
|
id: string;
|
|
16
20
|
name: string;
|
|
17
|
-
icon
|
|
18
|
-
|
|
19
|
-
globalPermission?: ApprovalLevel;
|
|
20
|
-
isExpanded: boolean;
|
|
21
|
+
/** Optional leading icon; the panel falls back to a shield. */
|
|
22
|
+
icon?: React.ReactNode;
|
|
21
23
|
policies: PermissionPolicy[];
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface PolicyConfigurationPanelProps {
|
|
25
27
|
categories: PermissionCategory[];
|
|
26
28
|
editMode: boolean;
|
|
27
|
-
|
|
28
|
-
onGlobalPermissionChange: (categoryId: string, level: ApprovalLevel | undefined) => void;
|
|
29
|
+
/** Change a single policy's approval level. `policyId` is the policy's `id`. */
|
|
29
30
|
onPolicyPermissionChange: (categoryId: string, policyId: string, level: ApprovalLevel) => void;
|
|
31
|
+
/** Bulk-apply an approval level to every policy in a category. */
|
|
32
|
+
onCategoryPermissionChange: (categoryId: string, level: ApprovalLevel) => void;
|
|
30
33
|
className?: string;
|
|
31
|
-
}
|
|
34
|
+
}
|
|
@@ -166,3 +166,42 @@ describe('embedAuthedFetch 401 self-heal (adapter.refresh)', () => {
|
|
|
166
166
|
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
167
167
|
})
|
|
168
168
|
})
|
|
169
|
+
|
|
170
|
+
describe('embedAuthedFetch adapter.allowedOrigins (native-shell cross-origin sanction)', () => {
|
|
171
|
+
const originalFetch = globalThis.fetch
|
|
172
|
+
|
|
173
|
+
beforeEach(() => {
|
|
174
|
+
globalThis.fetch = vi.fn(async () => new Response('ok')) as typeof fetch
|
|
175
|
+
// Production mode so these tests exercise the real guard, not the
|
|
176
|
+
// dev-mode warn-and-allow hatch.
|
|
177
|
+
vi.stubEnv('NODE_ENV', 'production')
|
|
178
|
+
})
|
|
179
|
+
afterEach(() => {
|
|
180
|
+
globalThis.fetch = originalFetch
|
|
181
|
+
setEmbedAuthAdapter(null)
|
|
182
|
+
vi.unstubAllEnvs()
|
|
183
|
+
vi.restoreAllMocks()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('allows a cross-origin URL whose exact origin the adapter sanctions', async () => {
|
|
187
|
+
setEmbedAuthAdapter({ allowedOrigins: ['https://gateway.example'] })
|
|
188
|
+
await expect(embedAuthedFetch('https://gateway.example/content/api/faqs')).resolves.toBeDefined()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('still rejects origins outside the allowlist', () => {
|
|
192
|
+
setEmbedAuthAdapter({ allowedOrigins: ['https://gateway.example'] })
|
|
193
|
+
expect(() => embedAuthedFetch('https://evil.com/x')).toThrow(/cross-origin/i)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('does not treat an allowlisted origin as a prefix (suffix-domain spoof rejected)', () => {
|
|
197
|
+
setEmbedAuthAdapter({ allowedOrigins: ['https://gateway.example'] })
|
|
198
|
+
expect(() => embedAuthedFetch('https://gateway.example.evil.com/x')).toThrow(/cross-origin/i)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('cannot allowlist non-http(s) schemes (protocol check runs first)', () => {
|
|
202
|
+
// `javascript:` URLs have origin 'null'; even a (misguided) 'null' entry
|
|
203
|
+
// must not get past the protocol gate.
|
|
204
|
+
setEmbedAuthAdapter({ allowedOrigins: ['null'] })
|
|
205
|
+
expect(() => embedAuthedFetch('javascript:alert(1)')).toThrow(/non-http\(s\)/i)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -86,6 +86,19 @@ export interface EmbedAuthAdapter {
|
|
|
86
86
|
* host's own auth layer, not to this fetch wrapper.
|
|
87
87
|
*/
|
|
88
88
|
refresh?: () => Promise<boolean>
|
|
89
|
+
/**
|
|
90
|
+
* Exact extra origins (`scheme://host[:port]`, as produced by `URL.origin`)
|
|
91
|
+
* the same-origin guard additionally accepts. The guard normally rejects
|
|
92
|
+
* every cross-origin URL in production — but native-shell hosts serve the
|
|
93
|
+
* page from a local pseudo-origin (`capacitor://localhost`,
|
|
94
|
+
* `tauri://localhost`) with NO server behind it, so the gateway their
|
|
95
|
+
* bearer already belongs to is unavoidably cross-origin. Listing it here
|
|
96
|
+
* is the host's explicit sanction to send the adapter's credentials there.
|
|
97
|
+
* Compared AFTER the http(s)-protocol check, so non-http(s) schemes can
|
|
98
|
+
* never be allowlisted. Omit (or leave empty) everywhere else — same-origin
|
|
99
|
+
* remains the rule, this is the narrow exception.
|
|
100
|
+
*/
|
|
101
|
+
allowedOrigins?: string[]
|
|
89
102
|
}
|
|
90
103
|
|
|
91
104
|
/**
|
|
@@ -158,9 +171,12 @@ export function hasEmbedAuthAdapter(): boolean {
|
|
|
158
171
|
*
|
|
159
172
|
* **Cross-origin defense:** the wrapper assumes a same-origin `/api/…`
|
|
160
173
|
* relative URL. Absolute URLs are accepted only when their origin matches
|
|
161
|
-
* the current window's origin
|
|
162
|
-
*
|
|
163
|
-
*
|
|
174
|
+
* the current window's origin — or appears in the registered adapter's
|
|
175
|
+
* `allowedOrigins` (the native-shell hatch; see that field's doc) — and
|
|
176
|
+
* cross-origin URLs otherwise throw before the bearer leaves the page.
|
|
177
|
+
* This is a defense-in-depth guard for future call sites — outside the
|
|
178
|
+
* allowlisted-shell case there is no legitimate cross-origin use of this
|
|
179
|
+
* fetch wrapper.
|
|
164
180
|
*
|
|
165
181
|
* **401 self-heal:** when a registered adapter supplies `refresh`, a `401`
|
|
166
182
|
* response triggers a single token refresh + retry of the same request
|
|
@@ -352,6 +368,14 @@ function assertSameOrigin(url: string): void {
|
|
|
352
368
|
)
|
|
353
369
|
}
|
|
354
370
|
if (target.origin !== pageOrigin) {
|
|
371
|
+
// Host-sanctioned cross-origin target (`EmbedAuthAdapter.allowedOrigins`).
|
|
372
|
+
// Native shells serve the page from a local pseudo-origin with no server
|
|
373
|
+
// behind it, so their gateway — the same backend the host already trusts
|
|
374
|
+
// with this bearer — is unavoidably cross-origin. Exact `URL.origin`
|
|
375
|
+
// match, and only reachable for http(s) targets (checked above).
|
|
376
|
+
if (getRegisteredAuthAdapter()?.allowedOrigins?.includes(target.origin)) {
|
|
377
|
+
return
|
|
378
|
+
}
|
|
355
379
|
// Dev-mode escape hatch — embedded apps (e.g. openframe-frontend)
|
|
356
380
|
// run on a different origin from their gateway during local dev,
|
|
357
381
|
// and forcing a Next.js `rewrites()` workaround is more error-prone
|