@forge/react

11.9.1-experimental-60ea29e11.9.2-next.0
out/hooks/usePermissions.js
~out/hooks/usePermissions.jsModified
+120−29
Index: package/out/hooks/usePermissions.js
===================================================================
--- package/out/hooks/usePermissions.js
+++ package/out/hooks/usePermissions.js
@@ -2,9 +2,35 @@
 Object.defineProperty(exports, "__esModule", { value: true });
 exports.usePermissions = void 0;
 const react_1 = require("react");
 const bridge_1 = require("@forge/bridge");
+const egress_1 = require("@forge/egress");
 /**
+ * https://ecosystem-platform.atlassian.net/browse/DEPLOY-1411
+ * Uses @forge/egress for URL matching (same logic as @forge/api)
+ */
+/**
+ * Helper function to extract URL string from external URL permissions.
+ * Matches the implementation in @forge/api for consistency.
+ */
+function extractUrlString(url) {
+    if (typeof url === 'string') {
+        return url;
+    }
+    if ('address' in url && url.address) {
+        return url.address;
+    }
+    return url.remote || '';
+}
+/**
+ * Resource types that can be loaded externally
+ */
+const RESOURCE_TYPES = ['fonts', 'styles', 'frames', 'images', 'media', 'scripts'];
+/**
+ * Fetch types for external requests
+ */
+const FETCH_TYPES = ['backend', 'client'];
+/**
  * Hook for checking permissions in Forge apps
  *
  * @param requiredPermissions - The permissions required for the component
  * @returns Object containing permission state, loading status, and error information
@@ -42,12 +68,8 @@
 const usePermissions = (requiredPermissions) => {
     const [context, setContext] = (0, react_1.useState)();
     const [isLoading, setIsLoading] = (0, react_1.useState)(true);
     const [error, setError] = (0, react_1.useState)(null);
-    const [permissionResult, setPermissionResult] = (0, react_1.useState)({
-        granted: false,
-        missing: null
-    });
     // Load context on mount
     (0, react_1.useEffect)(() => {
         const loadContext = async () => {
             try {
@@ -64,39 +86,108 @@
             }
         };
         void loadContext();
     }, []);
-    // Check permissions using shared utility
-    (0, react_1.useEffect)(() => {
-        // Skip if still loading context
-        if (isLoading) {
-            return;
+    // Permission checking utilities
+    const permissionUtils = (0, react_1.useMemo)(() => {
+        if (!context?.permissions)
+            return null;
+        const { scopes, external = {} } = context.permissions;
+        const scopeArray = Array.isArray(scopes) ? scopes : Object.keys(scopes || {});
+        return {
+            hasScope: (scope) => scopeArray.includes(scope),
+            canFetchFrom: (type, url) => {
+                const fetchUrls = external.fetch?.[type];
+                if (!fetchUrls?.length)
+                    return false;
+                // Extract URLs and create egress filter
+                const allowList = fetchUrls.map(extractUrlString).filter((u) => u.length > 0);
+                if (allowList.length === 0)
+                    return false;
+                const egressFilter = new egress_1.EgressFilteringService(allowList);
+                // Backend: hostname-only matching, Client: CSP validation (includes paths)
+                return type === 'client' ? egressFilter.isValidUrlCSP(url) : egressFilter.isValidUrl(url);
+            },
+            canLoadResource: (type, url) => {
+                const resourceUrls = external[type];
+                if (!resourceUrls?.length)
+                    return false;
+                // Extract URLs and create egress filter
+                const allowList = resourceUrls.map(extractUrlString).filter((u) => u.length > 0);
+                if (allowList.length === 0)
+                    return false;
+                const egressFilter = new egress_1.EgressFilteringService(allowList);
+                // All resources use CSP validation (checks protocol + hostname + paths)
+                return egressFilter.isValidUrlCSP(url);
+            },
+            getScopes: () => scopeArray,
+            getExternalPermissions: () => external,
+            hasAnyPermissions: () => scopeArray.length > 0 || Object.keys(external).length > 0
+        };
+    }, [context?.permissions]);
+    // Check permissions
+    const permissionResult = (0, react_1.useMemo)(() => {
+        if (!requiredPermissions) {
+            return { granted: false, missing: null };
         }
-        const checkPerms = async () => {
-            if (!requiredPermissions) {
-                setPermissionResult({ granted: false, missing: null });
-                return;
+        if (!permissionUtils) {
+            // If still loading or there's an error, return null for missing permissions
+            if (isLoading || error) {
+                return { granted: false, missing: null };
             }
-            if (!context?.permissions) {
-                // If context loaded but has no permissions, set error
-                if (context !== undefined) {
-                    setError(new Error('This feature is not available yet'));
-                    setPermissionResult({ granted: false, missing: null });
+            throw new Error('This feature is not available yet');
+        }
+        const missing = {};
+        let hasAllRequiredPermissions = true;
+        // Check scopes
+        if (requiredPermissions.scopes?.length) {
+            const missingScopes = requiredPermissions.scopes.filter((scope) => !permissionUtils.hasScope(scope));
+            if (missingScopes.length > 0) {
+                missing.scopes = missingScopes;
+                hasAllRequiredPermissions = false;
+            }
+        }
+        // Check external permissions
+        if (requiredPermissions.external) {
+            const missingExternal = {};
+            // Check fetch permissions
+            if (requiredPermissions.external.fetch) {
+                const missingFetch = {};
+                FETCH_TYPES.forEach((type) => {
+                    const requiredUrls = requiredPermissions.external?.fetch?.[type];
+                    if (requiredUrls?.length) {
+                        const missingUrls = requiredUrls.filter((url) => !permissionUtils.canFetchFrom(type, url));
+                        if (missingUrls.length > 0) {
+                            missingFetch[type] = missingUrls;
+                            hasAllRequiredPermissions = false;
+                        }
+                    }
+                });
+                if (Object.keys(missingFetch).length > 0) {
+                    missingExternal.fetch = missingFetch;
                 }
-                return;
             }
-            try {
-                setError(null); // Clear any previous errors
-                const result = await (0, bridge_1.checkPermissions)(requiredPermissions, context.permissions);
-                setPermissionResult(result);
+            // Check resource permissions
+            RESOURCE_TYPES.forEach((type) => {
+                const requiredUrls = requiredPermissions.external?.[type];
+                if (requiredUrls?.length) {
+                    const missingUrls = requiredUrls.filter((url) => !permissionUtils.canLoadResource(type, url));
+                    if (missingUrls.length > 0) {
+                        missingExternal[type] = missingUrls;
+                        hasAllRequiredPermissions = false;
+                    }
+                }
+            });
+            if (Object.keys(missingExternal).length > 0) {
+                missing.external = missingExternal;
             }
-            catch (err) {
-                setError(err instanceof Error ? err : new Error('Failed to check permissions'));
-                setPermissionResult({ granted: false, missing: null });
-            }
+        }
+        // Note: Content permissions are not supported in the current RuntimePermissions type
+        return {
+            granted: hasAllRequiredPermissions,
+            missing: hasAllRequiredPermissions ? null : missing
         };
-        void checkPerms();
-    }, [context, requiredPermissions, isLoading]);
+    }, [permissionUtils, requiredPermissions]);
     return {
         hasPermission: permissionResult.granted,
         isLoading,
         missingPermissions: permissionResult.missing,