Skip to content

Commit a07b972

Browse files
committed
feat: update state to return the roles and permissions metadata
1 parent 6d8f6fa commit a07b972

7 files changed

Lines changed: 166 additions & 7 deletions

File tree

src/authz-module/data/api.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ export interface GetTeamMembersResponse {
88
totalCount: number;
99
}
1010

11+
export type PermissionsByRole = {
12+
key: string;
13+
permissions: string[];
14+
userCount: number;
15+
};
16+
1117
// TODO: replece api path once is created
1218
export const getTeamMembers = async (object: string): Promise<TeamMember[]> => {
1319
const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users?scope=${object}`));
@@ -24,3 +30,10 @@ export const getLibrary = async (libraryId: string): Promise<LibraryMetadata> =>
2430
slug: data.slug,
2531
};
2632
};
33+
34+
export const getPermissionsByRole = async (scope: string): Promise<PermissionsByRole[]> => {
35+
const url = new URL(getApiUrl('/api/authz/v1/roles'));
36+
url.searchParams.append('scope', scope);
37+
const { data } = await getAuthenticatedHttpClient().get(url);
38+
return camelCaseObject(data);
39+
};

src/authz-module/data/hooks.test.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ReactNode } from 'react';
22
import { act, renderHook, waitFor } from '@testing-library/react';
33
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
5-
import { useLibrary, useTeamMembers } from './hooks';
5+
import { useLibrary, usePermissionsByRole, useTeamMembers } from './hooks';
66

77
jest.mock('@edx/frontend-platform/auth', () => ({
88
getAuthenticatedHttpClient: jest.fn(),
@@ -123,3 +123,36 @@ describe('useLibrary', () => {
123123
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
124124
});
125125
});
126+
127+
describe('usePermissionsByRole', () => {
128+
it('fetches roles for a given scope', async () => {
129+
const mockRoles = [
130+
{ key: 'admin', permissions: ['perm1'], userCount: 1 },
131+
{ key: 'user', permissions: ['perm2'], userCount: 2 },
132+
];
133+
134+
getAuthenticatedHttpClient.mockReturnValue({
135+
get: jest.fn().mockResolvedValue({ data: mockRoles }),
136+
});
137+
138+
const wrapper = createWrapper();
139+
const { result } = renderHook(() => usePermissionsByRole('lib'), { wrapper });
140+
await waitFor(() => result.current.data !== undefined);
141+
expect(result.current.data).toEqual(mockRoles);
142+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
143+
});
144+
145+
it('returns error if getRoles fails', async () => {
146+
getAuthenticatedHttpClient.mockReturnValue({
147+
get: jest.fn().mockRejectedValue(new Error('Not found')),
148+
});
149+
const wrapper = createWrapper();
150+
try {
151+
act(() => {
152+
renderHook(() => usePermissionsByRole('lib'), { wrapper });
153+
});
154+
} catch (e) {
155+
expect(e).toEqual(new Error('Not found'));
156+
}
157+
});
158+
});

src/authz-module/data/hooks.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
22
import { appId } from '@src/constants';
33
import { LibraryMetadata, TeamMember } from '@src/types';
4-
import { getLibrary, getTeamMembers } from './api';
4+
import {
5+
getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole,
6+
} from './api';
57

68
const authzQueryKeys = {
79
all: [appId, 'authz'] as const,
810
teamMembers: (object: string) => [...authzQueryKeys.all, 'teamMembers', object] as const,
11+
permissionsByRole: (scope: string) => [...authzQueryKeys.all, 'permissionsByRole', scope] as const,
912
library: (libraryId: string) => [...authzQueryKeys.all, 'library', libraryId] as const,
1013
};
1114

@@ -26,6 +29,23 @@ export const useTeamMembers = (object: string) => useQuery<TeamMember[], Error>(
2629
staleTime: 1000 * 60 * 30, // refetch after 30 minutes
2730
});
2831

32+
/**
33+
* React Query hook to fetch all the roles for the specific object/scope.
34+
* It retrieves the full list of roles with the corresponding permissions.
35+
*
36+
* @param scope - The unique identifier of the object/scope
37+
*
38+
* @example
39+
* ```tsx
40+
* const { data: roles, isLoading, isError } = useTeamMembers('lib:123');
41+
* ```
42+
*/
43+
export const usePermissionsByRole = (scope: string) => useSuspenseQuery<PermissionsByRole[], Error>({
44+
queryKey: authzQueryKeys.permissionsByRole(scope),
45+
queryFn: () => getPermissionsByRole(scope),
46+
retry: false,
47+
});
48+
2949
/**
3050
* React Query hook to retrieve the information of the current library.
3151
*
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types';
2+
3+
// Note: this information will eventually come from the backend API
4+
// but for the MVP we decided to manage it in the frontend
5+
export const libraryRolesMetadata: RoleMetadata[] = [
6+
{ key: 'library_admin', name: 'Library Admin', description: 'The Library Admin has full control over the library, including managing users, modifying content, and handling publishing workflows. They ensure content is properly maintained and accessible as needed.' },
7+
{ key: 'library_author', name: 'Library Author', description: 'The Library Author is responsible for creating, editing, and publishing content within a library. They can manage tags and collections but cannot delete libraries or manage users.' },
8+
{ key: 'library_collaborator', name: 'Library Collaborator', description: 'The Library Collaborator can create and edit content within a library but cannot publish it. They support the authoring process while leaving final publishing to Authors or Admins.' },
9+
{ key: 'library_user', name: 'Library User', description: 'The Library User can view and reuse content but cannot edit or delete any resource.' },
10+
];
11+
12+
export const libraryResourceTypes: ResourceMetadata[] = [
13+
{ key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' },
14+
{ key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' },
15+
{ key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.' },
16+
{ key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.' },
17+
];
18+
19+
export const libraryPermissions: PermissionMetadata[] = [
20+
{ key: 'create_library', resource: 'library', description: 'Allows the user to create new libraries.' },
21+
{ key: 'edit_library', resource: 'library', description: 'Allows the user to modify library settings and metadata.' },
22+
{ key: 'delete_library', resource: 'library', description: 'Allows the user to delete the library and all its contents.' },
23+
{ key: 'publish_library', resource: 'library', description: 'Publish the library (change from draft mode to published).' },
24+
{ key: 'view_library', resource: 'library', description: 'View content, search, filter, and sort within the library.' },
25+
{
26+
key: 'manage_library_tags', resource: 'library', description: 'Add or remove tags from content.',
27+
},
28+
29+
{ key: 'create_library_content', resource: 'library_content', description: 'Create new components or content units.' },
30+
{ key: 'edit_library_content', resource: 'library_content', description: 'Edit content in draft mode' },
31+
{ key: 'delete_library_content', resource: 'library_content', description: 'Delete individual content (not collections).' },
32+
{ key: 'publish_library_content', resource: 'library_content', description: 'Publish content, making it available for reuse' },
33+
{ key: 'reuse_library_content', resource: 'library_content', description: 'Reuse published content within a course.' },
34+
35+
{ key: 'create_library_collection', resource: 'library_collection', description: 'Create new collections within a library.' },
36+
{ key: 'edit_library_collection', resource: 'library_collection', description: 'Add or remove content from existing collections.' },
37+
{ key: 'delete_library_collection', resource: 'library_collection', description: 'Delete entire collections from the library.' },
38+
39+
{ key: 'manage_library_team', resource: 'library_team', description: 'View the list of users who have access to the library.' },
40+
{ key: 'view_library_team', resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' },
41+
];

src/authz-module/libraries-manager/context.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ jest.mock('react-router-dom', () => ({
1212
jest.mock('@src/data/hooks', () => ({
1313
useValidateUserPermissions: jest.fn(),
1414
}));
15+
jest.mock('@src/authz-module/data/hooks', () => ({
16+
usePermissionsByRole: jest.fn().mockReturnValue({
17+
data: [
18+
{
19+
key: 'library_author',
20+
permissions: [
21+
'view_library_team',
22+
'edit_library',
23+
],
24+
user_count: 12,
25+
},
26+
],
27+
}),
28+
}));
1529

1630
const TestComponent = () => {
1731
const context = useLibraryAuthZ();
@@ -20,6 +34,9 @@ const TestComponent = () => {
2034
<div data-testid="username">{context.username}</div>
2135
<div data-testid="libraryId">{context.libraryId}</div>
2236
<div data-testid="canManageTeam">{context.canManageTeam ? 'true' : 'false'}</div>
37+
<div data-testid="roles">{Array.isArray(context.roles) ? context.roles.length : 'undefined'}</div>
38+
<div data-testid="permissions">{Array.isArray(context.permissions) ? context.permissions.length : 'undefined'}</div>
39+
<div data-testid="resources">{Array.isArray(context.resources) ? context.resources.length : 'undefined'}</div>
2340
</div>
2441
);
2542
};
@@ -47,6 +64,9 @@ describe('LibraryAuthZProvider', () => {
4764
expect(screen.getByTestId('username')).toHaveTextContent('testuser');
4865
expect(screen.getByTestId('libraryId')).toHaveTextContent('lib123');
4966
expect(screen.getByTestId('canManageTeam')).toHaveTextContent('true');
67+
expect(Number(screen.getByTestId('roles').textContent)).not.toBeNaN();
68+
expect(Number(screen.getByTestId('permissions').textContent)).not.toBeNaN();
69+
expect(Number(screen.getByTestId('resources').textContent)).not.toBeNaN();
5070
});
5171

5272
it('throws error when user lacks both view and manage permissions', () => {

src/authz-module/libraries-manager/context.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ import {
44
import { useParams } from 'react-router-dom';
55
import { AppContext } from '@edx/frontend-platform/react';
66
import { useValidateUserPermissions } from '@src/data/hooks';
7+
import { usePermissionsByRole } from '@src/authz-module/data/hooks';
8+
import { PermissionMetadata, ResourceMetadata, Role } from 'types';
9+
import { libraryPermissions, libraryResourceTypes, libraryRolesMetadata } from './constants';
710

811
const LIBRARY_TEAM_PERMISSIONS = ['act:view_library_team', 'act:manage_library_team'];
12+
const LIBRARY_AUTHZ_SCOPE = 'lib:*';
913

1014
export type AppContextType = {
1115
authenticatedUser: {
@@ -18,8 +22,9 @@ type LibraryAuthZContextType = {
1822
canManageTeam: boolean;
1923
username: string;
2024
libraryId: string;
21-
roles: string[];
22-
permissions: string[];
25+
resources: ResourceMetadata[];
26+
roles: Role[];
27+
permissions: PermissionMetadata[];
2328
};
2429

2530
const LibraryAuthZContext = createContext<LibraryAuthZContextType | undefined>(undefined);
@@ -45,13 +50,17 @@ export const LibraryAuthZProvider: React.FC<AuthZProviderProps> = ({ children }:
4550
throw new Error('NoAccess');
4651
}
4752

53+
const { data: libraryRoles } = usePermissionsByRole(LIBRARY_AUTHZ_SCOPE);
54+
const roles = libraryRoles.map(role => ({ ...role, ...libraryRolesMetadata.find(r => r.key === role.key) } as Role));
55+
4856
const value = useMemo((): LibraryAuthZContextType => ({
4957
username: authenticatedUser.username,
5058
libraryId,
51-
roles: [],
52-
permissions: [],
59+
roles,
60+
permissions: libraryPermissions,
61+
resources: libraryResourceTypes,
5362
canManageTeam,
54-
}), [libraryId, authenticatedUser.username, canManageTeam]);
63+
}), [libraryId, authenticatedUser.username, canManageTeam, roles]);
5564

5665
return (
5766
<LibraryAuthZContext.Provider value={value}>

src/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,29 @@ export interface LibraryMetadata {
2222
slug: string;
2323
}
2424

25+
export interface RoleMetadata {
26+
key: string;
27+
name: string;
28+
description: string;
29+
}
30+
export interface Role extends RoleMetadata {
31+
userCount: number;
32+
permissions: string[];
33+
}
34+
35+
export type ResourceMetadata = {
36+
key: string;
37+
label: string;
38+
description: string;
39+
};
40+
41+
export type PermissionMetadata = {
42+
key: string;
43+
resource: string;
44+
label?: string;
45+
description?: string;
46+
};
47+
2548
// Paragon table type
2649
export interface TableCellValue<T> {
2750
row: {

0 commit comments

Comments
 (0)