@forge/react 11.2.9-next.0 → 11.3.0-experimental-6de6b19

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/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # @forge/react
2
2
 
3
+ ## 11.3.0-experimental-6de6b19
4
+
5
+ ### Minor Changes
6
+
7
+ - 01521ac: add usePermissions hook
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [01521ac]
12
+ - @forge/bridge@5.5.0-experimental-6de6b19
13
+
14
+ ## 11.3.0
15
+
16
+ ### Minor Changes
17
+
18
+ - 1fe70bc: Add package.json exports configuration for improved TypeScript module resolution in forge-react package.
19
+
20
+ ### Patch Changes
21
+
22
+ - 0371b03: Update type import from @forge/bridge
23
+ - Updated dependencies [8e77d28]
24
+ - Updated dependencies [185f844]
25
+ - @forge/bridge@5.5.0
26
+
27
+ ## 11.3.0-next.1
28
+
29
+ ### Patch Changes
30
+
31
+ - 0371b03: Update type import from @forge/bridge
32
+ - Updated dependencies [8e77d28]
33
+ - @forge/bridge@5.5.0-next.0
34
+
35
+ ## 11.3.0-next.0
36
+
37
+ ### Minor Changes
38
+
39
+ - 1fe70bc: Add package.json exports configuration for improved TypeScript module resolution in forge-react package.
40
+
41
+ ## 11.2.9
42
+
43
+ ### Patch Changes
44
+
45
+ - Updated dependencies [c51a29d]
46
+ - @forge/bridge@5.4.1
47
+
3
48
  ## 11.2.9-next.0
4
49
 
5
50
  ### Patch Changes
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=usePermissions.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usePermissions.test.d.ts","sourceRoot":"","sources":["../../../src/hooks/__test__/usePermissions.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,384 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const react_hooks_1 = require("@testing-library/react-hooks");
4
+ const usePermissions_1 = require("../usePermissions");
5
+ const testUtils_1 = require("../../__test__/testUtils");
6
+ // Mock @forge/bridge
7
+ jest.mock('@forge/bridge', () => ({
8
+ view: {
9
+ getContext: jest.fn()
10
+ }
11
+ }));
12
+ const mockGetContext = jest.fn();
13
+ describe('usePermissions', () => {
14
+ beforeEach(() => {
15
+ jest.clearAllMocks();
16
+ (0, testUtils_1.setupBridge)();
17
+ // Set up the mock to use our mockGetContext function
18
+ const { view } = require('@forge/bridge');
19
+ view.getContext.mockImplementation(mockGetContext);
20
+ });
21
+ describe('Loading state', () => {
22
+ it('should start with loading true', () => {
23
+ const requiredPermissions = {
24
+ scopes: ['read:confluence-content']
25
+ };
26
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
27
+ expect(result.current.isLoading).toBe(true);
28
+ expect(result.current.hasPermission).toBe(false);
29
+ expect(result.current.missingPermissions).toBe(null);
30
+ expect(result.current.error).toBe(null);
31
+ });
32
+ it('should set loading to false after context loads', async () => {
33
+ const mockContext = {
34
+ permissions: {
35
+ scopes: ['read:confluence-content']
36
+ }
37
+ };
38
+ mockGetContext.mockResolvedValue(mockContext);
39
+ const requiredPermissions = {
40
+ scopes: ['read:confluence-content']
41
+ };
42
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
43
+ await (0, react_hooks_1.act)(async () => {
44
+ await new Promise((resolve) => setTimeout(resolve, 0));
45
+ });
46
+ expect(result.current.isLoading).toBe(false);
47
+ expect(result.current.hasPermission).toBe(true);
48
+ expect(result.current.missingPermissions).toBe(null);
49
+ });
50
+ });
51
+ describe('Error handling', () => {
52
+ it('should handle context loading errors', async () => {
53
+ const error = new Error('Failed to load context');
54
+ mockGetContext.mockRejectedValue(error);
55
+ const requiredPermissions = {
56
+ scopes: ['read:confluence-content']
57
+ };
58
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
59
+ await (0, react_hooks_1.act)(async () => {
60
+ await new Promise((resolve) => setTimeout(resolve, 0));
61
+ });
62
+ expect(result.current.isLoading).toBe(false);
63
+ expect(result.current.error).toEqual(error);
64
+ expect(result.current.hasPermission).toBe(false);
65
+ expect(result.current.missingPermissions).toBe(null);
66
+ });
67
+ it('should handle non-Error objects in catch block', async () => {
68
+ mockGetContext.mockRejectedValue('String error');
69
+ const requiredPermissions = {
70
+ scopes: ['read:confluence-content']
71
+ };
72
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
73
+ await (0, react_hooks_1.act)(async () => {
74
+ await new Promise((resolve) => setTimeout(resolve, 0));
75
+ });
76
+ expect(result.current.error).toEqual(new Error('Failed to load context'));
77
+ });
78
+ });
79
+ describe('Scope permissions', () => {
80
+ it('should grant permission when all required scopes are present', async () => {
81
+ const mockContext = {
82
+ permissions: {
83
+ scopes: ['read:confluence-content', 'write:confluence-content']
84
+ }
85
+ };
86
+ mockGetContext.mockResolvedValue(mockContext);
87
+ const requiredPermissions = {
88
+ scopes: ['read:confluence-content']
89
+ };
90
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
91
+ await (0, react_hooks_1.act)(async () => {
92
+ await new Promise((resolve) => setTimeout(resolve, 0));
93
+ });
94
+ expect(result.current.isLoading).toBe(false);
95
+ expect(result.current.hasPermission).toBe(true);
96
+ expect(result.current.missingPermissions).toBe(null);
97
+ });
98
+ it('should deny permission when required scopes are missing', async () => {
99
+ const mockContext = {
100
+ permissions: {
101
+ scopes: ['read:confluence-content']
102
+ }
103
+ };
104
+ mockGetContext.mockResolvedValue(mockContext);
105
+ const requiredPermissions = {
106
+ scopes: ['read:confluence-content', 'write:jira-work']
107
+ };
108
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
109
+ await (0, react_hooks_1.act)(async () => {
110
+ await new Promise((resolve) => setTimeout(resolve, 0));
111
+ });
112
+ expect(result.current.isLoading).toBe(false);
113
+ expect(result.current.hasPermission).toBe(false);
114
+ expect(result.current.missingPermissions).toEqual({
115
+ scopes: ['write:jira-work']
116
+ });
117
+ });
118
+ it('should handle scopes as object format', async () => {
119
+ const mockContext = {
120
+ permissions: {
121
+ scopes: {
122
+ 'read:confluence-content': { allowImpersonation: false },
123
+ 'write:confluence-content': { allowImpersonation: true }
124
+ }
125
+ }
126
+ };
127
+ mockGetContext.mockResolvedValue(mockContext);
128
+ const requiredPermissions = {
129
+ scopes: ['read:confluence-content']
130
+ };
131
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
132
+ await (0, react_hooks_1.act)(async () => {
133
+ await new Promise((resolve) => setTimeout(resolve, 0));
134
+ });
135
+ expect(result.current.isLoading).toBe(false);
136
+ expect(result.current.hasPermission).toBe(true);
137
+ });
138
+ it('should handle empty scopes array', async () => {
139
+ const mockContext = {
140
+ permissions: {
141
+ scopes: []
142
+ }
143
+ };
144
+ mockGetContext.mockResolvedValue(mockContext);
145
+ const requiredPermissions = {
146
+ scopes: []
147
+ };
148
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
149
+ await (0, react_hooks_1.act)(async () => {
150
+ await new Promise((resolve) => setTimeout(resolve, 0));
151
+ });
152
+ expect(result.current.isLoading).toBe(false);
153
+ expect(result.current.hasPermission).toBe(true);
154
+ });
155
+ });
156
+ describe('External permissions', () => {
157
+ it('should grant permission for allowed fetch URLs', async () => {
158
+ const mockContext = {
159
+ permissions: {
160
+ external: {
161
+ fetch: {
162
+ backend: ['https://api.example.com', 'https://api.test.com'],
163
+ client: ['https://cdn.example.com']
164
+ }
165
+ }
166
+ }
167
+ };
168
+ mockGetContext.mockResolvedValue(mockContext);
169
+ const requiredPermissions = {
170
+ external: {
171
+ fetch: {
172
+ backend: ['https://api.example.com'],
173
+ client: ['https://cdn.example.com']
174
+ }
175
+ }
176
+ };
177
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
178
+ await (0, react_hooks_1.act)(async () => {
179
+ await new Promise((resolve) => setTimeout(resolve, 0));
180
+ });
181
+ expect(result.current.isLoading).toBe(false);
182
+ expect(result.current.hasPermission).toBe(true);
183
+ });
184
+ it('should deny permission for disallowed fetch URLs', async () => {
185
+ const mockContext = {
186
+ permissions: {
187
+ external: {
188
+ fetch: {
189
+ backend: ['https://api.example.com']
190
+ }
191
+ }
192
+ }
193
+ };
194
+ mockGetContext.mockResolvedValue(mockContext);
195
+ const requiredPermissions = {
196
+ external: {
197
+ fetch: {
198
+ backend: ['https://api.example.com', 'https://api.unauthorized.com']
199
+ }
200
+ }
201
+ };
202
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
203
+ await (0, react_hooks_1.act)(async () => {
204
+ await new Promise((resolve) => setTimeout(resolve, 0));
205
+ });
206
+ expect(result.current.isLoading).toBe(false);
207
+ expect(result.current.hasPermission).toBe(false);
208
+ expect(result.current.missingPermissions).toEqual({
209
+ external: {
210
+ fetch: {
211
+ backend: ['https://api.unauthorized.com']
212
+ }
213
+ }
214
+ });
215
+ });
216
+ it('should handle wildcard URL patterns', async () => {
217
+ const mockContext = {
218
+ permissions: {
219
+ external: {
220
+ fetch: {
221
+ backend: ['https://api.example.com/*', 'https://*.test.com']
222
+ }
223
+ }
224
+ }
225
+ };
226
+ mockGetContext.mockResolvedValue(mockContext);
227
+ const requiredPermissions = {
228
+ external: {
229
+ fetch: {
230
+ backend: ['https://api.example.com/users', 'https://subdomain.test.com/api']
231
+ }
232
+ }
233
+ };
234
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
235
+ await (0, react_hooks_1.act)(async () => {
236
+ await new Promise((resolve) => setTimeout(resolve, 0));
237
+ });
238
+ expect(result.current.isLoading).toBe(false);
239
+ expect(result.current.hasPermission).toBe(true);
240
+ });
241
+ it('should handle resource permissions (fonts, images, etc.)', async () => {
242
+ const mockContext = {
243
+ permissions: {
244
+ external: {
245
+ fonts: ['https://fonts.googleapis.com'],
246
+ images: ['https://images.example.com'],
247
+ scripts: ['https://scripts.example.com']
248
+ }
249
+ }
250
+ };
251
+ mockGetContext.mockResolvedValue(mockContext);
252
+ const requiredPermissions = {
253
+ external: {
254
+ fonts: ['https://fonts.googleapis.com'],
255
+ images: ['https://images.example.com']
256
+ }
257
+ };
258
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
259
+ await (0, react_hooks_1.act)(async () => {
260
+ await new Promise((resolve) => setTimeout(resolve, 0));
261
+ });
262
+ expect(result.current.isLoading).toBe(false);
263
+ expect(result.current.hasPermission).toBe(true);
264
+ });
265
+ });
266
+ // Note: Content permissions are not supported in the current RuntimePermissions type
267
+ describe('Complex permission scenarios', () => {
268
+ it('should handle mixed permission types', async () => {
269
+ const mockContext = {
270
+ permissions: {
271
+ scopes: ['read:confluence-content'],
272
+ external: {
273
+ fetch: {
274
+ backend: ['https://api.example.com']
275
+ },
276
+ images: ['https://images.example.com']
277
+ }
278
+ }
279
+ };
280
+ mockGetContext.mockResolvedValue(mockContext);
281
+ const requiredPermissions = {
282
+ scopes: ['read:confluence-content'],
283
+ external: {
284
+ fetch: {
285
+ backend: ['https://api.example.com']
286
+ },
287
+ images: ['https://images.example.com']
288
+ }
289
+ };
290
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
291
+ await (0, react_hooks_1.act)(async () => {
292
+ await new Promise((resolve) => setTimeout(resolve, 0));
293
+ });
294
+ expect(result.current.isLoading).toBe(false);
295
+ expect(result.current.hasPermission).toBe(true);
296
+ });
297
+ it('should identify missing permissions across different types', async () => {
298
+ const mockContext = {
299
+ permissions: {
300
+ scopes: ['read:confluence-content'],
301
+ external: {
302
+ fetch: {
303
+ backend: ['https://api.example.com']
304
+ }
305
+ }
306
+ }
307
+ };
308
+ mockGetContext.mockResolvedValue(mockContext);
309
+ const requiredPermissions = {
310
+ scopes: ['read:confluence-content', 'write:jira-work'],
311
+ external: {
312
+ fetch: {
313
+ backend: ['https://api.example.com', 'https://api.unauthorized.com'],
314
+ client: ['https://cdn.example.com']
315
+ },
316
+ images: ['https://images.example.com']
317
+ }
318
+ };
319
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
320
+ await (0, react_hooks_1.act)(async () => {
321
+ await new Promise((resolve) => setTimeout(resolve, 0));
322
+ });
323
+ expect(result.current.isLoading).toBe(false);
324
+ expect(result.current.hasPermission).toBe(false);
325
+ expect(result.current.missingPermissions).toEqual({
326
+ scopes: ['write:jira-work'],
327
+ external: {
328
+ fetch: {
329
+ backend: ['https://api.unauthorized.com'],
330
+ client: ['https://cdn.example.com']
331
+ },
332
+ images: ['https://images.example.com']
333
+ }
334
+ });
335
+ });
336
+ });
337
+ describe('Edge cases', () => {
338
+ it('should handle empty required permissions', async () => {
339
+ const mockContext = {
340
+ permissions: {
341
+ scopes: ['read:confluence-content']
342
+ }
343
+ };
344
+ mockGetContext.mockResolvedValue(mockContext);
345
+ const requiredPermissions = {};
346
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
347
+ await (0, react_hooks_1.act)(async () => {
348
+ await new Promise((resolve) => setTimeout(resolve, 0));
349
+ });
350
+ expect(result.current.isLoading).toBe(false);
351
+ expect(result.current.hasPermission).toBe(true);
352
+ expect(result.current.missingPermissions).toBe(null);
353
+ });
354
+ it('should handle complex external permission objects', async () => {
355
+ const mockContext = {
356
+ permissions: {
357
+ external: {
358
+ fetch: {
359
+ backend: [
360
+ 'https://api.example.com',
361
+ { address: 'https://api.address.com' },
362
+ { remote: 'https://api.remote.com' }
363
+ ]
364
+ }
365
+ }
366
+ }
367
+ };
368
+ mockGetContext.mockResolvedValue(mockContext);
369
+ const requiredPermissions = {
370
+ external: {
371
+ fetch: {
372
+ backend: ['https://api.example.com', 'https://api.address.com']
373
+ }
374
+ }
375
+ };
376
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
377
+ await (0, react_hooks_1.act)(async () => {
378
+ await new Promise((resolve) => setTimeout(resolve, 0));
379
+ });
380
+ expect(result.current.isLoading).toBe(false);
381
+ expect(result.current.hasPermission).toBe(true);
382
+ });
383
+ });
384
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * TODO: reuse logic in permissions.ts from @forge/api
3
+ */
4
+ export interface PermissionRequirements {
5
+ scopes?: string[];
6
+ external?: {
7
+ fetch?: {
8
+ backend?: string[];
9
+ client?: string[];
10
+ };
11
+ fonts?: string[];
12
+ styles?: string[];
13
+ frames?: string[];
14
+ images?: string[];
15
+ media?: string[];
16
+ scripts?: string[];
17
+ };
18
+ content?: Record<string, unknown>;
19
+ }
20
+ /**
21
+ * Missing permissions information
22
+ */
23
+ export interface MissingPermissions {
24
+ scopes?: string[];
25
+ external?: {
26
+ fetch?: {
27
+ backend?: string[];
28
+ client?: string[];
29
+ };
30
+ fonts?: string[];
31
+ styles?: string[];
32
+ frames?: string[];
33
+ images?: string[];
34
+ media?: string[];
35
+ scripts?: string[];
36
+ };
37
+ content?: Record<string, unknown>;
38
+ }
39
+ /**
40
+ * Permission check result
41
+ */
42
+ export interface PermissionCheckResult {
43
+ granted: boolean;
44
+ missing: MissingPermissions | null;
45
+ }
46
+ /**
47
+ * Hook for checking permissions in Forge apps
48
+ *
49
+ * @param requiredPermissions - The permissions required for the component
50
+ * @returns Object containing permission state and loading status
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * const MyComponent: React.FC = () => {
55
+ * const { hasPermission, isLoading, missingPermissions } = usePermissions({
56
+ * scopes: ['write:confluence-content'],
57
+ * external: {
58
+ * fetch: {
59
+ * backend: ['https://api.example.com']
60
+ * }
61
+ * }
62
+ * });
63
+ *
64
+ * if (isLoading) return <LoadingSpinner />;
65
+ *
66
+ * if (!hasPermission) {
67
+ * return <PermissionDenied missingPermissions={missingPermissions} />;
68
+ * }
69
+ *
70
+ * return <ProtectedFeature />;
71
+ * };
72
+ * ```
73
+ */
74
+ export declare const usePermissions: (requiredPermissions: PermissionRequirements) => {
75
+ hasPermission: boolean;
76
+ isLoading: boolean;
77
+ missingPermissions: MissingPermissions | null;
78
+ error: Error | null;
79
+ };
80
+ //# sourceMappingURL=usePermissions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usePermissions.d.ts","sourceRoot":"","sources":["../../src/hooks/usePermissions.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE;QACT,KAAK,CAAC,EAAE;YACN,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;YACnB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;SACnB,CAAC;QACF,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;IACF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE;QACT,KAAK,CAAC,EAAE;YACN,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;YACnB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;SACnB,CAAC;QACF,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;IACF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACpC;AAwBD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,cAAc,wBAAyB,sBAAsB;;;;;CAqJzE,CAAC"}