feat: update state to return the roles and permissions metadata

This commit is contained in:
Diana Olarte
2025-10-02 09:10:20 +10:00
committed by Adolfo R. Brandes
parent 6d8f6fa1c7
commit 6316586cc0
7 changed files with 166 additions and 7 deletions

View File

@@ -8,6 +8,12 @@ export interface GetTeamMembersResponse {
totalCount: number;
}
export type PermissionsByRole = {
key: string;
permissions: string[];
userCount: number;
};
// TODO: replece api path once is created
export const getTeamMembers = async (object: string): Promise<TeamMember[]> => {
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> =>
slug: data.slug,
};
};
export const getPermissionsByRole = async (scope: string): Promise<PermissionsByRole[]> => {
const url = new URL(getApiUrl('/api/authz/v1/roles'));
url.searchParams.append('scope', scope);
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
};

View File

@@ -2,7 +2,7 @@ import { ReactNode } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useLibrary, useTeamMembers } from './hooks';
import { useLibrary, usePermissionsByRole, useTeamMembers } from './hooks';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
@@ -123,3 +123,36 @@ describe('useLibrary', () => {
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
});
});
describe('usePermissionsByRole', () => {
it('fetches roles for a given scope', async () => {
const mockRoles = [
{ key: 'admin', permissions: ['perm1'], userCount: 1 },
{ key: 'user', permissions: ['perm2'], userCount: 2 },
];
getAuthenticatedHttpClient.mockReturnValue({
get: jest.fn().mockResolvedValue({ data: mockRoles }),
});
const wrapper = createWrapper();
const { result } = renderHook(() => usePermissionsByRole('lib'), { wrapper });
await waitFor(() => result.current.data !== undefined);
expect(result.current.data).toEqual(mockRoles);
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
});
it('returns error if getRoles fails', async () => {
getAuthenticatedHttpClient.mockReturnValue({
get: jest.fn().mockRejectedValue(new Error('Not found')),
});
const wrapper = createWrapper();
try {
act(() => {
renderHook(() => usePermissionsByRole('lib'), { wrapper });
});
} catch (e) {
expect(e).toEqual(new Error('Not found'));
}
});
});

View File

@@ -1,11 +1,14 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { appId } from '@src/constants';
import { LibraryMetadata, TeamMember } from '@src/types';
import { getLibrary, getTeamMembers } from './api';
import {
getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole,
} from './api';
const authzQueryKeys = {
all: [appId, 'authz'] as const,
teamMembers: (object: string) => [...authzQueryKeys.all, 'teamMembers', object] as const,
permissionsByRole: (scope: string) => [...authzQueryKeys.all, 'permissionsByRole', scope] as const,
library: (libraryId: string) => [...authzQueryKeys.all, 'library', libraryId] as const,
};
@@ -26,6 +29,23 @@ export const useTeamMembers = (object: string) => useQuery<TeamMember[], Error>(
staleTime: 1000 * 60 * 30, // refetch after 30 minutes
});
/**
* React Query hook to fetch all the roles for the specific object/scope.
* It retrieves the full list of roles with the corresponding permissions.
*
* @param scope - The unique identifier of the object/scope
*
* @example
* ```tsx
* const { data: roles, isLoading, isError } = useTeamMembers('lib:123');
* ```
*/
export const usePermissionsByRole = (scope: string) => useSuspenseQuery<PermissionsByRole[], Error>({
queryKey: authzQueryKeys.permissionsByRole(scope),
queryFn: () => getPermissionsByRole(scope),
retry: false,
});
/**
* React Query hook to retrieve the information of the current library.
*

View File

@@ -0,0 +1,41 @@
import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types';
// Note: this information will eventually come from the backend API
// but for the MVP we decided to manage it in the frontend
export const libraryRolesMetadata: RoleMetadata[] = [
{ 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.' },
{ 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.' },
{ 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.' },
{ key: 'library_user', name: 'Library User', description: 'The Library User can view and reuse content but cannot edit or delete any resource.' },
];
export const libraryResourceTypes: ResourceMetadata[] = [
{ key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' },
{ key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' },
{ key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.' },
{ key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.' },
];
export const libraryPermissions: PermissionMetadata[] = [
{ key: 'create_library', resource: 'library', description: 'Allows the user to create new libraries.' },
{ key: 'edit_library', resource: 'library', description: 'Allows the user to modify library settings and metadata.' },
{ key: 'delete_library', resource: 'library', description: 'Allows the user to delete the library and all its contents.' },
{ key: 'publish_library', resource: 'library', description: 'Publish the library (change from draft mode to published).' },
{ key: 'view_library', resource: 'library', description: 'View content, search, filter, and sort within the library.' },
{
key: 'manage_library_tags', resource: 'library', description: 'Add or remove tags from content.',
},
{ key: 'create_library_content', resource: 'library_content', description: 'Create new components or content units.' },
{ key: 'edit_library_content', resource: 'library_content', description: 'Edit content in draft mode' },
{ key: 'delete_library_content', resource: 'library_content', description: 'Delete individual content (not collections).' },
{ key: 'publish_library_content', resource: 'library_content', description: 'Publish content, making it available for reuse' },
{ key: 'reuse_library_content', resource: 'library_content', description: 'Reuse published content within a course.' },
{ key: 'create_library_collection', resource: 'library_collection', description: 'Create new collections within a library.' },
{ key: 'edit_library_collection', resource: 'library_collection', description: 'Add or remove content from existing collections.' },
{ key: 'delete_library_collection', resource: 'library_collection', description: 'Delete entire collections from the library.' },
{ key: 'manage_library_team', resource: 'library_team', description: 'View the list of users who have access to the library.' },
{ key: 'view_library_team', resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' },
];

View File

@@ -12,6 +12,20 @@ jest.mock('react-router-dom', () => ({
jest.mock('@src/data/hooks', () => ({
useValidateUserPermissions: jest.fn(),
}));
jest.mock('@src/authz-module/data/hooks', () => ({
usePermissionsByRole: jest.fn().mockReturnValue({
data: [
{
key: 'library_author',
permissions: [
'view_library_team',
'edit_library',
],
user_count: 12,
},
],
}),
}));
const TestComponent = () => {
const context = useLibraryAuthZ();
@@ -20,6 +34,9 @@ const TestComponent = () => {
<div data-testid="username">{context.username}</div>
<div data-testid="libraryId">{context.libraryId}</div>
<div data-testid="canManageTeam">{context.canManageTeam ? 'true' : 'false'}</div>
<div data-testid="roles">{Array.isArray(context.roles) ? context.roles.length : 'undefined'}</div>
<div data-testid="permissions">{Array.isArray(context.permissions) ? context.permissions.length : 'undefined'}</div>
<div data-testid="resources">{Array.isArray(context.resources) ? context.resources.length : 'undefined'}</div>
</div>
);
};
@@ -47,6 +64,9 @@ describe('LibraryAuthZProvider', () => {
expect(screen.getByTestId('username')).toHaveTextContent('testuser');
expect(screen.getByTestId('libraryId')).toHaveTextContent('lib123');
expect(screen.getByTestId('canManageTeam')).toHaveTextContent('true');
expect(Number(screen.getByTestId('roles').textContent)).not.toBeNaN();
expect(Number(screen.getByTestId('permissions').textContent)).not.toBeNaN();
expect(Number(screen.getByTestId('resources').textContent)).not.toBeNaN();
});
it('throws error when user lacks both view and manage permissions', () => {

View File

@@ -4,8 +4,12 @@ import {
import { useParams } from 'react-router-dom';
import { AppContext } from '@edx/frontend-platform/react';
import { useValidateUserPermissions } from '@src/data/hooks';
import { usePermissionsByRole } from '@src/authz-module/data/hooks';
import { PermissionMetadata, ResourceMetadata, Role } from 'types';
import { libraryPermissions, libraryResourceTypes, libraryRolesMetadata } from './constants';
const LIBRARY_TEAM_PERMISSIONS = ['act:view_library_team', 'act:manage_library_team'];
const LIBRARY_AUTHZ_SCOPE = 'lib:*';
export type AppContextType = {
authenticatedUser: {
@@ -18,8 +22,9 @@ type LibraryAuthZContextType = {
canManageTeam: boolean;
username: string;
libraryId: string;
roles: string[];
permissions: string[];
resources: ResourceMetadata[];
roles: Role[];
permissions: PermissionMetadata[];
};
const LibraryAuthZContext = createContext<LibraryAuthZContextType | undefined>(undefined);
@@ -45,13 +50,17 @@ export const LibraryAuthZProvider: React.FC<AuthZProviderProps> = ({ children }:
throw new Error('NoAccess');
}
const { data: libraryRoles } = usePermissionsByRole(LIBRARY_AUTHZ_SCOPE);
const roles = libraryRoles.map(role => ({ ...role, ...libraryRolesMetadata.find(r => r.key === role.key) } as Role));
const value = useMemo((): LibraryAuthZContextType => ({
username: authenticatedUser.username,
libraryId,
roles: [],
permissions: [],
roles,
permissions: libraryPermissions,
resources: libraryResourceTypes,
canManageTeam,
}), [libraryId, authenticatedUser.username, canManageTeam]);
}), [libraryId, authenticatedUser.username, canManageTeam, roles]);
return (
<LibraryAuthZContext.Provider value={value}>

View File

@@ -22,6 +22,29 @@ export interface LibraryMetadata {
slug: string;
}
export interface RoleMetadata {
key: string;
name: string;
description: string;
}
export interface Role extends RoleMetadata {
userCount: number;
permissions: string[];
}
export type ResourceMetadata = {
key: string;
label: string;
description: string;
};
export type PermissionMetadata = {
key: string;
resource: string;
label?: string;
description?: string;
};
// Paragon table type
export interface TableCellValue<T> {
row: {