@forge/react 11.9.0 → 11.9.1-experimental-60ea29e

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,37 @@
1
1
  # @forge/react
2
2
 
3
+ ## 11.9.1-experimental-60ea29e
4
+
5
+ ### Minor Changes
6
+
7
+ - f058dd8: Expose Permissions API in @forge/bridge for Custom UI apps
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [f058dd8]
12
+ - @forge/bridge@5.10.2-experimental-60ea29e
13
+
14
+ ## 11.9.1
15
+
16
+ ### Patch Changes
17
+
18
+ - f57dd69: Bug fix for usePermissions hook
19
+ - Updated dependencies [4205052]
20
+ - @forge/bridge@5.10.2
21
+
22
+ ## 11.9.1-next.1
23
+
24
+ ### Patch Changes
25
+
26
+ - Updated dependencies [4205052]
27
+ - @forge/bridge@5.10.2-next.0
28
+
29
+ ## 11.9.1-next.0
30
+
31
+ ### Patch Changes
32
+
33
+ - f57dd69: Bug fix for usePermissions hook
34
+
3
35
  ## 11.9.0
4
36
 
5
37
  ### Minor Changes
package/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2025 Atlassian
1
+ Copyright (c) 2026 Atlassian
2
2
  Permission is hereby granted to use this software in accordance with the terms
3
3
  and conditions outlined in the Atlassian Developer Terms, which can be found
4
4
  at the following URL:
@@ -4,11 +4,25 @@ const react_hooks_1 = require("@testing-library/react-hooks");
4
4
  const usePermissions_1 = require("../usePermissions");
5
5
  const testUtils_1 = require("../../__test__/testUtils");
6
6
  // Mock @forge/bridge
7
- jest.mock('@forge/bridge', () => ({
8
- view: {
9
- getContext: jest.fn()
7
+ jest.mock('@forge/bridge', () => {
8
+ // Set up window before requiring actual bridge to avoid initialization issues
9
+ if (typeof window === 'undefined') {
10
+ // @ts-ignore
11
+ global.window = global;
12
+ // @ts-ignore
13
+ global.window.__bridge = {
14
+ callBridge: jest.fn()
15
+ };
10
16
  }
11
- }));
17
+ const actualBridge = jest.requireActual('@forge/bridge');
18
+ return {
19
+ ...actualBridge,
20
+ view: {
21
+ ...actualBridge.view,
22
+ getContext: jest.fn()
23
+ }
24
+ };
25
+ });
12
26
  const mockGetContext = jest.fn();
13
27
  describe('usePermissions', () => {
14
28
  beforeEach(() => {
@@ -363,6 +377,369 @@ describe('usePermissions', () => {
363
377
  });
364
378
  });
365
379
  });
380
+ describe('CSP path matching for client fetch', () => {
381
+ it('should allow any path when allowlist has no path', async () => {
382
+ const mockContext = {
383
+ permissions: {
384
+ external: {
385
+ fetch: {
386
+ client: ['https://cdn.example.com']
387
+ }
388
+ }
389
+ }
390
+ };
391
+ mockGetContext.mockResolvedValue(mockContext);
392
+ const requiredPermissions = {
393
+ external: {
394
+ fetch: {
395
+ client: [
396
+ 'https://cdn.example.com',
397
+ 'https://cdn.example.com/',
398
+ 'https://cdn.example.com/any/path',
399
+ 'https://cdn.example.com/file.js'
400
+ ]
401
+ }
402
+ }
403
+ };
404
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
405
+ await (0, react_hooks_1.act)(async () => {
406
+ await new Promise((resolve) => setTimeout(resolve, 10));
407
+ });
408
+ expect(result.current.isLoading).toBe(false);
409
+ expect(result.current.hasPermission).toBe(true);
410
+ });
411
+ it('should use prefix matching when allowlist has trailing slash', async () => {
412
+ const mockContext = {
413
+ permissions: {
414
+ external: {
415
+ fetch: {
416
+ client: ['https://cdn.example.com/api/']
417
+ }
418
+ }
419
+ }
420
+ };
421
+ mockGetContext.mockResolvedValue(mockContext);
422
+ const requiredPermissions = {
423
+ external: {
424
+ fetch: {
425
+ client: [
426
+ 'https://cdn.example.com/api/',
427
+ 'https://cdn.example.com/api/users',
428
+ 'https://cdn.example.com/api/v1/data'
429
+ ]
430
+ }
431
+ }
432
+ };
433
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
434
+ await (0, react_hooks_1.act)(async () => {
435
+ await new Promise((resolve) => setTimeout(resolve, 10));
436
+ });
437
+ expect(result.current.isLoading).toBe(false);
438
+ expect(result.current.hasPermission).toBe(true);
439
+ });
440
+ it('should block paths outside prefix when using trailing slash', async () => {
441
+ const mockContext = {
442
+ permissions: {
443
+ external: {
444
+ fetch: {
445
+ client: ['https://cdn.example.com/api/']
446
+ }
447
+ }
448
+ }
449
+ };
450
+ mockGetContext.mockResolvedValue(mockContext);
451
+ const requiredPermissions = {
452
+ external: {
453
+ fetch: {
454
+ client: ['https://cdn.example.com/other', 'https://cdn.example.com/']
455
+ }
456
+ }
457
+ };
458
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
459
+ await (0, react_hooks_1.act)(async () => {
460
+ await new Promise((resolve) => setTimeout(resolve, 10));
461
+ });
462
+ expect(result.current.isLoading).toBe(false);
463
+ expect(result.current.hasPermission).toBe(false);
464
+ expect(result.current.missingPermissions?.external?.fetch?.client).toEqual([
465
+ 'https://cdn.example.com/other',
466
+ 'https://cdn.example.com/'
467
+ ]);
468
+ });
469
+ it('should use exact matching when allowlist has no trailing slash', async () => {
470
+ const mockContext = {
471
+ permissions: {
472
+ external: {
473
+ fetch: {
474
+ client: ['https://cdn.example.com/bundle.js']
475
+ }
476
+ }
477
+ }
478
+ };
479
+ mockGetContext.mockResolvedValue(mockContext);
480
+ const requiredPermissions = {
481
+ external: {
482
+ fetch: {
483
+ client: ['https://cdn.example.com/bundle.js']
484
+ }
485
+ }
486
+ };
487
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
488
+ await (0, react_hooks_1.act)(async () => {
489
+ await new Promise((resolve) => setTimeout(resolve, 10));
490
+ });
491
+ expect(result.current.isLoading).toBe(false);
492
+ expect(result.current.hasPermission).toBe(true);
493
+ });
494
+ it('should block non-exact paths when using exact matching', async () => {
495
+ const mockContext = {
496
+ permissions: {
497
+ external: {
498
+ fetch: {
499
+ client: ['https://cdn.example.com/bundle.js']
500
+ }
501
+ }
502
+ }
503
+ };
504
+ mockGetContext.mockResolvedValue(mockContext);
505
+ const requiredPermissions = {
506
+ external: {
507
+ fetch: {
508
+ client: [
509
+ 'https://cdn.example.com/bundle.js/extra',
510
+ 'https://cdn.example.com/other.js',
511
+ 'https://cdn.example.com/'
512
+ ]
513
+ }
514
+ }
515
+ };
516
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
517
+ await (0, react_hooks_1.act)(async () => {
518
+ await new Promise((resolve) => setTimeout(resolve, 10));
519
+ });
520
+ expect(result.current.isLoading).toBe(false);
521
+ expect(result.current.hasPermission).toBe(false);
522
+ expect(result.current.missingPermissions?.external?.fetch?.client).toEqual([
523
+ 'https://cdn.example.com/bundle.js/extra',
524
+ 'https://cdn.example.com/other.js',
525
+ 'https://cdn.example.com/'
526
+ ]);
527
+ });
528
+ });
529
+ describe('Backend vs Client CSP differences', () => {
530
+ it('should allow any path for backend (hostname-only matching)', async () => {
531
+ const mockContext = {
532
+ permissions: {
533
+ external: {
534
+ fetch: {
535
+ backend: ['https://api.example.com/specific/path']
536
+ }
537
+ }
538
+ }
539
+ };
540
+ mockGetContext.mockResolvedValue(mockContext);
541
+ const requiredPermissions = {
542
+ external: {
543
+ fetch: {
544
+ backend: [
545
+ 'https://api.example.com',
546
+ 'https://api.example.com/different/path',
547
+ 'https://api.example.com/specific/path'
548
+ ]
549
+ }
550
+ }
551
+ };
552
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
553
+ await (0, react_hooks_1.act)(async () => {
554
+ await new Promise((resolve) => setTimeout(resolve, 10));
555
+ });
556
+ expect(result.current.isLoading).toBe(false);
557
+ expect(result.current.hasPermission).toBe(true);
558
+ });
559
+ it('should demonstrate backend allows but client blocks different paths', async () => {
560
+ const mockContext = {
561
+ permissions: {
562
+ external: {
563
+ fetch: {
564
+ backend: ['https://api.example.com/api/'],
565
+ client: ['https://api.example.com/api/']
566
+ }
567
+ }
568
+ }
569
+ };
570
+ mockGetContext.mockResolvedValue(mockContext);
571
+ const requiredPermissions = {
572
+ external: {
573
+ fetch: {
574
+ backend: ['https://api.example.com/private/secret.json'],
575
+ client: ['https://api.example.com/private/secret.json']
576
+ }
577
+ }
578
+ };
579
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
580
+ await (0, react_hooks_1.act)(async () => {
581
+ await new Promise((resolve) => setTimeout(resolve, 10));
582
+ });
583
+ expect(result.current.isLoading).toBe(false);
584
+ expect(result.current.hasPermission).toBe(false);
585
+ // Backend should pass (hostname-only), client should fail (CSP path check)
586
+ expect(result.current.missingPermissions?.external?.fetch?.backend).toBeUndefined();
587
+ expect(result.current.missingPermissions?.external?.fetch?.client).toEqual([
588
+ 'https://api.example.com/private/secret.json'
589
+ ]);
590
+ });
591
+ });
592
+ describe('CSP for resource types', () => {
593
+ it('should use CSP validation for images with paths', async () => {
594
+ const mockContext = {
595
+ permissions: {
596
+ external: {
597
+ images: ['https://cdn.example.com/public/']
598
+ }
599
+ }
600
+ };
601
+ mockGetContext.mockResolvedValue(mockContext);
602
+ const requiredPermissions = {
603
+ external: {
604
+ images: ['https://cdn.example.com/public/image.png', 'https://cdn.example.com/public/nested/image.jpg']
605
+ }
606
+ };
607
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
608
+ await (0, react_hooks_1.act)(async () => {
609
+ await new Promise((resolve) => setTimeout(resolve, 10));
610
+ });
611
+ expect(result.current.isLoading).toBe(false);
612
+ expect(result.current.hasPermission).toBe(true);
613
+ });
614
+ it('should block images outside allowed directory', async () => {
615
+ const mockContext = {
616
+ permissions: {
617
+ external: {
618
+ images: ['https://cdn.example.com/public/']
619
+ }
620
+ }
621
+ };
622
+ mockGetContext.mockResolvedValue(mockContext);
623
+ const requiredPermissions = {
624
+ external: {
625
+ images: ['https://cdn.example.com/private/image.png', 'https://cdn.example.com/image.png']
626
+ }
627
+ };
628
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
629
+ await (0, react_hooks_1.act)(async () => {
630
+ await new Promise((resolve) => setTimeout(resolve, 10));
631
+ });
632
+ expect(result.current.isLoading).toBe(false);
633
+ expect(result.current.hasPermission).toBe(false);
634
+ expect(result.current.missingPermissions?.external?.images).toEqual([
635
+ 'https://cdn.example.com/private/image.png',
636
+ 'https://cdn.example.com/image.png'
637
+ ]);
638
+ });
639
+ it('should apply CSP to all resource types', async () => {
640
+ const mockContext = {
641
+ permissions: {
642
+ external: {
643
+ scripts: ['https://cdn.example.com'],
644
+ styles: ['https://cdn.example.com/css/'],
645
+ fonts: ['https://fonts.example.com/font.woff2']
646
+ }
647
+ }
648
+ };
649
+ mockGetContext.mockResolvedValue(mockContext);
650
+ const requiredPermissions = {
651
+ external: {
652
+ scripts: ['https://cdn.example.com/bundle.js'],
653
+ styles: ['https://cdn.example.com/css/main.css'],
654
+ fonts: ['https://fonts.example.com/font.woff2'] // Exact match
655
+ }
656
+ };
657
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
658
+ await (0, react_hooks_1.act)(async () => {
659
+ await new Promise((resolve) => setTimeout(resolve, 10));
660
+ });
661
+ expect(result.current.isLoading).toBe(false);
662
+ expect(result.current.hasPermission).toBe(true);
663
+ });
664
+ });
665
+ describe('URL normalization and protocol handling', () => {
666
+ it('should handle URLs without protocol', async () => {
667
+ const mockContext = {
668
+ permissions: {
669
+ external: {
670
+ fetch: {
671
+ backend: ['api.example.com']
672
+ }
673
+ }
674
+ }
675
+ };
676
+ mockGetContext.mockResolvedValue(mockContext);
677
+ const requiredPermissions = {
678
+ external: {
679
+ fetch: {
680
+ backend: ['https://api.example.com', 'api.example.com']
681
+ }
682
+ }
683
+ };
684
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
685
+ await (0, react_hooks_1.act)(async () => {
686
+ await new Promise((resolve) => setTimeout(resolve, 10));
687
+ });
688
+ expect(result.current.isLoading).toBe(false);
689
+ expect(result.current.hasPermission).toBe(true);
690
+ });
691
+ it('should handle CSP secure protocol upgrades (http -> https)', async () => {
692
+ const mockContext = {
693
+ permissions: {
694
+ external: {
695
+ fetch: {
696
+ client: ['http://example.com']
697
+ }
698
+ }
699
+ }
700
+ };
701
+ mockGetContext.mockResolvedValue(mockContext);
702
+ const requiredPermissions = {
703
+ external: {
704
+ fetch: {
705
+ client: ['http://example.com', 'https://example.com']
706
+ }
707
+ }
708
+ };
709
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
710
+ await (0, react_hooks_1.act)(async () => {
711
+ await new Promise((resolve) => setTimeout(resolve, 10));
712
+ });
713
+ expect(result.current.isLoading).toBe(false);
714
+ expect(result.current.hasPermission).toBe(true);
715
+ });
716
+ it('should not allow protocol downgrades (https -> http)', async () => {
717
+ const mockContext = {
718
+ permissions: {
719
+ external: {
720
+ fetch: {
721
+ client: ['https://example.com']
722
+ }
723
+ }
724
+ }
725
+ };
726
+ mockGetContext.mockResolvedValue(mockContext);
727
+ const requiredPermissions = {
728
+ external: {
729
+ fetch: {
730
+ client: ['http://example.com']
731
+ }
732
+ }
733
+ };
734
+ const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
735
+ await (0, react_hooks_1.act)(async () => {
736
+ await new Promise((resolve) => setTimeout(resolve, 10));
737
+ });
738
+ expect(result.current.isLoading).toBe(false);
739
+ expect(result.current.hasPermission).toBe(false);
740
+ expect(result.current.missingPermissions?.external?.fetch?.client).toEqual(['http://example.com']);
741
+ });
742
+ });
366
743
  describe('Edge cases', () => {
367
744
  it('should handle empty required permissions', async () => {
368
745
  const mockContext = {
@@ -1,17 +1,5 @@
1
- /**
2
- * https://ecosystem-platform.atlassian.net/browse/DEPLOY-1411
3
- * reuse logic from @forge/api
4
- */
5
- /**
6
- * Resource types that can be loaded externally
7
- */
8
- declare const RESOURCE_TYPES: readonly ["fonts", "styles", "frames", "images", "media", "scripts"];
9
- export declare type ResourceType = (typeof RESOURCE_TYPES)[number];
10
- /**
11
- * Fetch types for external requests
12
- */
13
- declare const FETCH_TYPES: readonly ["backend", "client"];
14
- export declare type FetchType = (typeof FETCH_TYPES)[number];
1
+ import { type PermissionRequirements, type MissingPermissions, type PermissionCheckResult, type ResourceType, type FetchType } from '@forge/bridge';
2
+ export type { ResourceType, FetchType };
15
3
  export interface Permissions {
16
4
  scopes?: string[];
17
5
  external?: {
@@ -28,21 +16,7 @@ export interface Permissions {
28
16
  };
29
17
  content?: Record<string, unknown>;
30
18
  }
31
- /**
32
- * Required permissions for a component
33
- */
34
- export declare type PermissionRequirements = Permissions;
35
- /**
36
- * Missing permissions information
37
- */
38
- export declare type MissingPermissions = Permissions;
39
- /**
40
- * Permission check result
41
- */
42
- export interface PermissionCheckResult {
43
- granted: boolean;
44
- missing: MissingPermissions | null;
45
- }
19
+ export type { PermissionRequirements, MissingPermissions, PermissionCheckResult };
46
20
  /**
47
21
  * Hook for checking permissions in Forge apps
48
22
  *
@@ -82,8 +56,7 @@ export interface PermissionCheckResult {
82
56
  export declare const usePermissions: (requiredPermissions: PermissionRequirements) => {
83
57
  hasPermission: boolean;
84
58
  isLoading: boolean;
85
- missingPermissions: Permissions | null;
59
+ missingPermissions: PermissionRequirements | null;
86
60
  error: Error | null;
87
61
  };
88
- export {};
89
62
  //# sourceMappingURL=usePermissions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"usePermissions.d.ts","sourceRoot":"","sources":["../../src/hooks/usePermissions.ts"],"names":[],"mappings":"AAIA;;;GAGG;AAEH;;GAEG;AACH,QAAA,MAAM,cAAc,sEAAuE,CAAC;AAC5F,oBAAY,YAAY,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAE3D;;GAEG;AACH,QAAA,MAAM,WAAW,gCAAiC,CAAC;AACnD,oBAAY,SAAS,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAErD,MAAM,WAAW,WAAW;IAC1B,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,oBAAY,sBAAsB,GAAG,WAAW,CAAC;AAEjD;;GAEG;AACH,oBAAY,kBAAkB,GAAG,WAAW,CAAC;AAE7C;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACpC;AAaD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,cAAc,wBAAyB,sBAAsB;;;;;CA2JzE,CAAC"}
1
+ {"version":3,"file":"usePermissions.d.ts","sourceRoot":"","sources":["../../src/hooks/usePermissions.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,sBAAsB,EAC3B,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC1B,KAAK,YAAY,EACjB,KAAK,SAAS,EACf,MAAM,eAAe,CAAC;AAGvB,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,CAAC;AAExC,MAAM,WAAW,WAAW;IAC1B,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;AAGD,YAAY,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,CAAC;AAElF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,cAAc,wBAAyB,sBAAsB;;;;;CAoEzE,CAAC"}
@@ -3,29 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.usePermissions = void 0;
4
4
  const react_1 = require("react");
5
5
  const bridge_1 = require("@forge/bridge");
6
- const minimatch_1 = require("minimatch");
7
- /**
8
- * https://ecosystem-platform.atlassian.net/browse/DEPLOY-1411
9
- * reuse logic from @forge/api
10
- */
11
- /**
12
- * Resource types that can be loaded externally
13
- */
14
- const RESOURCE_TYPES = ['fonts', 'styles', 'frames', 'images', 'media', 'scripts'];
15
- /**
16
- * Fetch types for external requests
17
- */
18
- const FETCH_TYPES = ['backend', 'client'];
19
- /**
20
- * Helper function to check if a URL matches any of the allowed patterns
21
- * Uses minimatch for robust pattern matching with wildcards
22
- */
23
- const matchesAllowedUrl = (url, allowedUrls) => {
24
- return allowedUrls.some((allowedUrl) => {
25
- // Use minimatch for pattern matching
26
- return (0, minimatch_1.minimatch)(url, allowedUrl);
27
- });
28
- };
29
6
  /**
30
7
  * Hook for checking permissions in Forge apps
31
8
  *
@@ -66,6 +43,10 @@ const usePermissions = (requiredPermissions) => {
66
43
  const [context, setContext] = (0, react_1.useState)();
67
44
  const [isLoading, setIsLoading] = (0, react_1.useState)(true);
68
45
  const [error, setError] = (0, react_1.useState)(null);
46
+ const [permissionResult, setPermissionResult] = (0, react_1.useState)({
47
+ granted: false,
48
+ missing: null
49
+ });
69
50
  // Load context on mount
70
51
  (0, react_1.useEffect)(() => {
71
52
  const loadContext = async () => {
@@ -84,110 +65,37 @@ const usePermissions = (requiredPermissions) => {
84
65
  };
85
66
  void loadContext();
86
67
  }, []);
87
- // Permission checking utilities
88
- const permissionUtils = (0, react_1.useMemo)(() => {
89
- if (!context?.permissions)
90
- return null;
91
- const { scopes, external = {} } = context.permissions;
92
- const scopeArray = Array.isArray(scopes) ? scopes : Object.keys(scopes || {});
93
- return {
94
- hasScope: (scope) => scopeArray.includes(scope),
95
- canFetchFrom: (type, url) => {
96
- const fetchUrls = external.fetch?.[type];
97
- if (!fetchUrls?.length)
98
- return false;
99
- // Extract string URLs from fetch URLs array
100
- const allowedUrls = fetchUrls
101
- .map((item) => {
102
- // If item is already a string, use it directly
103
- if (typeof item === 'string') {
104
- return item;
105
- }
106
- // If item has an address property, use that
107
- if ('address' in item && item.address) {
108
- return item.address;
109
- }
110
- // Otherwise, use the remote property (if it exists)
111
- return item.remote;
112
- })
113
- .filter((url) => typeof url === 'string');
114
- return matchesAllowedUrl(url, allowedUrls);
115
- },
116
- canLoadResource: (type, url) => {
117
- const resourceUrls = external[type];
118
- if (!resourceUrls?.length)
119
- return false;
120
- const stringUrls = resourceUrls.filter((item) => typeof item === 'string');
121
- return matchesAllowedUrl(url, stringUrls);
122
- },
123
- getScopes: () => scopeArray,
124
- getExternalPermissions: () => external,
125
- hasAnyPermissions: () => scopeArray.length > 0 || Object.keys(external).length > 0
126
- };
127
- }, [context?.permissions]);
128
- // Check permissions
129
- const permissionResult = (0, react_1.useMemo)(() => {
130
- if (!requiredPermissions) {
131
- return { granted: false, missing: null };
132
- }
133
- if (!permissionUtils) {
134
- // If still loading or there's an error, return null for missing permissions
135
- if (isLoading || error) {
136
- return { granted: false, missing: null };
137
- }
138
- throw new Error('This feature is not available yet');
68
+ // Check permissions using shared utility
69
+ (0, react_1.useEffect)(() => {
70
+ // Skip if still loading context
71
+ if (isLoading) {
72
+ return;
139
73
  }
140
- const missing = {};
141
- let hasAllRequiredPermissions = true;
142
- // Check scopes
143
- if (requiredPermissions.scopes?.length) {
144
- const missingScopes = requiredPermissions.scopes.filter((scope) => !permissionUtils.hasScope(scope));
145
- if (missingScopes.length > 0) {
146
- missing.scopes = missingScopes;
147
- hasAllRequiredPermissions = false;
74
+ const checkPerms = async () => {
75
+ if (!requiredPermissions) {
76
+ setPermissionResult({ granted: false, missing: null });
77
+ return;
148
78
  }
149
- }
150
- // Check external permissions
151
- if (requiredPermissions.external) {
152
- const missingExternal = {};
153
- // Check fetch permissions
154
- if (requiredPermissions.external.fetch) {
155
- const missingFetch = {};
156
- FETCH_TYPES.forEach((type) => {
157
- const requiredUrls = requiredPermissions.external?.fetch?.[type];
158
- if (requiredUrls?.length) {
159
- const missingUrls = requiredUrls.filter((url) => !permissionUtils.canFetchFrom(type, url));
160
- if (missingUrls.length > 0) {
161
- missingFetch[type] = missingUrls;
162
- hasAllRequiredPermissions = false;
163
- }
164
- }
165
- });
166
- if (Object.keys(missingFetch).length > 0) {
167
- missingExternal.fetch = missingFetch;
79
+ if (!context?.permissions) {
80
+ // If context loaded but has no permissions, set error
81
+ if (context !== undefined) {
82
+ setError(new Error('This feature is not available yet'));
83
+ setPermissionResult({ granted: false, missing: null });
168
84
  }
85
+ return;
169
86
  }
170
- // Check resource permissions
171
- RESOURCE_TYPES.forEach((type) => {
172
- const requiredUrls = requiredPermissions.external?.[type];
173
- if (requiredUrls?.length) {
174
- const missingUrls = requiredUrls.filter((url) => !permissionUtils.canLoadResource(type, url));
175
- if (missingUrls.length > 0) {
176
- missingExternal[type] = missingUrls;
177
- hasAllRequiredPermissions = false;
178
- }
179
- }
180
- });
181
- if (Object.keys(missingExternal).length > 0) {
182
- missing.external = missingExternal;
87
+ try {
88
+ setError(null); // Clear any previous errors
89
+ const result = await (0, bridge_1.checkPermissions)(requiredPermissions, context.permissions);
90
+ setPermissionResult(result);
91
+ }
92
+ catch (err) {
93
+ setError(err instanceof Error ? err : new Error('Failed to check permissions'));
94
+ setPermissionResult({ granted: false, missing: null });
183
95
  }
184
- }
185
- // Note: Content permissions are not supported in the current RuntimePermissions type
186
- return {
187
- granted: hasAllRequiredPermissions,
188
- missing: hasAllRequiredPermissions ? null : missing
189
96
  };
190
- }, [permissionUtils, requiredPermissions]);
97
+ void checkPerms();
98
+ }, [context, requiredPermissions, isLoading]);
191
99
  return {
192
100
  hasPermission: permissionResult.granted,
193
101
  isLoading,