Skip to content

Commit 2c9c885

Browse files
committed
feat: enhance usePermissions hook with error handling and loading states
1 parent f4976dc commit 2c9c885

3 files changed

Lines changed: 76 additions & 13 deletions

File tree

docs/how_tos/permissions.md

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,30 +52,43 @@ Permission keys are spread at the top level — no nested `.permissions` object.
5252

5353
```typescript
5454
import { usePermissions } from '@openedx/frontend-base';
55-
import { getConfig } from '@edx/frontend-platform';
5655

5756
// featureEnabled is required — always pass the resolved waffle flag boolean:
5857
const { enableAuthz } = useWaffleFlags(resourceId);
59-
const { isLoading, isAuthzEnabled, canViewGrading, canEditGrading } = usePermissions(
58+
const { isLoading, isError, isAuthzEnabled, canViewGrading, canEditGrading } = usePermissions(
6059
{
6160
canViewGrading: { action: 'courses.view_grading_settings', scope: resourceId },
6261
canEditGrading: { action: 'courses.edit_grading_settings', scope: resourceId },
6362
},
6463
enableAuthz ?? false,
6564
);
6665

67-
// Override the backend URL (e.g. MFEs using @edx/frontend-platform):
68-
const { isLoading, canViewGrading } = usePermissions(
66+
if (isLoading) { return <LoadingSpinner />; }
67+
if (isError) { return <ErrorAlert />; }
68+
if (!canViewGrading) { return <PermissionDeniedAlert />; }
69+
```
70+
71+
When `featureEnabled` is `false`: no API call is made and all keys return `true`,
72+
preserving the pre-authz behavior during rollout.
73+
74+
To override the backend URL (e.g. MFEs using `@edx/frontend-platform`), pass `apiBaseUrl`
75+
in the options argument:
76+
77+
```typescript
78+
import { usePermissions } from '@openedx/frontend-base';
79+
import { getConfig } from '@edx/frontend-platform';
80+
81+
const { enableAuthz } = useWaffleFlags(courseId);
82+
const { isLoading, isError, canViewGrading } = usePermissions(
6983
{ canViewGrading: { action: 'courses.view_grading_settings', scope: courseId } },
7084
enableAuthz ?? false,
7185
{ apiBaseUrl: getConfig().LMS_BASE_URL },
7286
);
73-
74-
if (!canViewGrading) { return <PermissionDeniedAlert />; }
7587
```
7688

77-
When `featureEnabled` is `false`: no API call is made and all keys return `true`,
78-
preserving the pre-authz behavior during rollout.
89+
> **Service unavailability:** if the authz API call fails, `isError` is `true` and all
90+
> permission keys resolve to `false`. Always check `isLoading` and `isError` before
91+
> rendering gated UI to avoid incorrectly denying access during transient failures.
7992
8093
---
8194

runtime/authz/hooks.test.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe('usePermissions', () => {
4545
expect(result.current.canView).toBe(true);
4646
expect(result.current.canEdit).toBe(false);
4747
expect(result.current.isAuthzEnabled).toBe(true);
48+
expect(result.current.isError).toBe(false);
4849
});
4950

5051
it('returns all keys as true and makes no API call when featureEnabled is false', () => {
@@ -60,6 +61,7 @@ describe('usePermissions', () => {
6061
expect(result.current.canView).toBe(true);
6162
expect(result.current.canEdit).toBe(true);
6263
expect(result.current.isLoading).toBe(false);
64+
expect(result.current.isError).toBe(false);
6365
expect(result.current.isAuthzEnabled).toBe(false);
6466
});
6567

@@ -97,6 +99,39 @@ describe('usePermissions', () => {
9799
expect('permissions' in result.current).toBe(false);
98100
});
99101

102+
it('returns undefined permission keys and isLoading=true while the API call is in flight', async () => {
103+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
104+
post: jest.fn(() => new Promise(() => {})), // never resolves
105+
});
106+
107+
const { result } = renderHook(
108+
() => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
109+
{ wrapper: createWrapper() },
110+
);
111+
112+
expect(result.current.isLoading).toBe(true);
113+
expect(result.current.canView).toBeUndefined();
114+
expect(result.current.canEdit).toBeUndefined();
115+
});
116+
117+
it('sets isError=true and defaults all keys to false when the API call fails', async () => {
118+
jest.spyOn(console, 'error').mockImplementation(() => {});
119+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
120+
post: jest.fn().mockRejectedValue(new Error('network error')),
121+
});
122+
123+
const { result } = renderHook(
124+
() => usePermissions(QUERY, true, { apiBaseUrl: BASE_URL }),
125+
{ wrapper: createWrapper() },
126+
);
127+
await waitFor(() => expect(result.current.isLoading).toBe(false));
128+
129+
expect(result.current.isError).toBe(true);
130+
expect(result.current.canView).toBe(false);
131+
expect(result.current.canEdit).toBe(false);
132+
jest.restoreAllMocks();
133+
});
134+
100135
it('scopes cache by apiBaseUrl — different base URLs produce distinct query keys', () => {
101136
const keyA = permissionsQueryKeys.validate(QUERY, 'http://lms-a.example.com');
102137
const keyB = permissionsQueryKeys.validate(QUERY, 'http://lms-b.example.com');

runtime/authz/hooks.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ import { getSiteConfig } from '../config';
33
import type { PermissionValidationQuery, PermissionValidationAnswer } from './types';
44
import { validatePermissions } from './api';
55

6+
/**
7+
* TanStack Query cache key factory for permission queries.
8+
* Use `validate` to scope cache reads and invalidations to a specific
9+
* query + backend combination.
10+
*
11+
* @example
12+
* queryClient.invalidateQueries({ queryKey: permissionsQueryKeys.validate(myQuery) });
13+
*/
614
export const permissionsQueryKeys = {
715
all: ['authz'] as const,
816
validate: (query: PermissionValidationQuery, apiBaseUrl: string = getSiteConfig().lmsBaseUrl) =>
@@ -22,14 +30,20 @@ export interface UsePermissionsOptions {
2230

2331
/**
2432
* Intersection return type: metadata fields plus every permission key spread at the top level.
25-
* Consumers destructure permission keys directly, no nested from `permissions.*` object.
33+
* Consumers destructure permission keys directlyno nested `.permissions` object.
2634
*
2735
* @example
28-
* const { isLoading, canViewGradingSettings, canEditGradingSettings } =
29-
* usePermissions(query, featureEnabled);
36+
* const { enableAuthz } = useWaffleFlags(courseId);
37+
* const { isLoading, isError, canViewGrading, canEditGrading } = usePermissions(
38+
* { canViewGrading: { action: 'courses.view_grading_settings', scope: courseId },
39+
* canEditGrading: { action: 'courses.edit_grading_settings', scope: courseId } },
40+
* enableAuthz ?? false,
41+
* { apiBaseUrl: getConfig().LMS_BASE_URL },
42+
* );
3043
*/
3144
export type UsePermissionsResult<Query extends PermissionValidationQuery> = {
3245
isLoading: boolean,
46+
isError: boolean,
3347
isAuthzEnabled: boolean,
3448
} & PermissionValidationAnswer<Query>;
3549

@@ -41,7 +55,7 @@ export type UsePermissionsResult<Query extends PermissionValidationQuery> = {
4155
* When featureEnabled is true: hits the authz API; returns actual server values.
4256
*
4357
* The caller is responsible for reading its own waffle flag and passing the result
44-
* as featureEnabled — waffle flag differ per MFE
58+
* as featureEnabled — waffle flag names differ per MFE
4559
*
4660
* @param query - Key/value map of permission check descriptors.
4761
* @param featureEnabled - Pass the result of your waffle flag check here.
@@ -63,7 +77,7 @@ export const usePermissions = <Query extends PermissionValidationQuery>(
6377
): UsePermissionsResult<Query> => {
6478
const { retry = false, apiBaseUrl = getSiteConfig().lmsBaseUrl } = options;
6579

66-
const { isLoading, data } = useQuery<PermissionValidationAnswer<Query>, Error>({
80+
const { isLoading, isError, data } = useQuery<PermissionValidationAnswer<Query>, Error>({
6781
queryKey: permissionsQueryKeys.validate(query, apiBaseUrl),
6882
queryFn: featureEnabled ? () => validatePermissions(apiBaseUrl, query) : skipToken,
6983
retry,
@@ -81,6 +95,7 @@ export const usePermissions = <Query extends PermissionValidationQuery>(
8195

8296
return {
8397
isLoading: featureEnabled ? isLoading : false,
98+
isError: featureEnabled ? isError : false,
8499
isAuthzEnabled: featureEnabled,
85100
...permissionResults,
86101
} as UsePermissionsResult<Query>;

0 commit comments

Comments
 (0)