Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/authz-module/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@ export interface GetTeamMembersResponse {
totalCount: number;
}

export type PermissionsByRole = {
role: 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}`));
const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
return camelCaseObject(data.results);
};

Expand All @@ -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.results);
};
39 changes: 36 additions & 3 deletions src/authz-module/data/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@ 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(),
}));

const mockMembers = [
{
displayName: 'Alice',
fullName: 'Alice',
username: 'user1',
email: 'alice@example.com',
roles: ['admin', 'author'],
},
{
displayName: 'Bob',
fullName: 'Bob',
username: 'user2',
email: 'bob@example.com',
roles: ['collaborator'],
Expand Down Expand Up @@ -123,3 +123,36 @@ describe('useLibrary', () => {
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
});
});

describe('usePermissionsByRole', () => {
it('fetches roles for a given scope', async () => {
const mockRoles = [
{ role: 'admin', permissions: ['perm1'], userCount: 1 },
{ role: 'user', permissions: ['perm2'], userCount: 2 },
];

getAuthenticatedHttpClient.mockReturnValue({
get: jest.fn().mockResolvedValue({ data: { results: 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'));
}
});
});
22 changes: 21 additions & 1 deletion src/authz-module/data/hooks.ts
Original file line number Diff line number Diff line change
@@ -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,
};

Expand All @@ -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 } = usePermissionsByRole('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.
*
Expand Down
34 changes: 34 additions & 0 deletions src/authz-module/libraries-manager/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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[] = [
{ role: '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.' },
{ role: '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.' },
{ role: '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.' },
{ role: '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[] = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not a blocker comment, but I think it will be helpful to align the name of the permission and the grouping with this: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/4840095745/Library+Roles+and+Permissions#Table-of-Roles-and-Permissions
I know we had something different in the Hi-Fi design, but after discussing it with @gviedma-aulasneo, we agreed that it's best to maintain the grouping as mentioned in the URL.
Note: It only modifies the library and library _content grouping.

Copy link
Contributor Author

@dcoa dcoa Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank @MaferMazu I updated it accordingly.

{ key: 'view_library', resource: 'library', description: 'View content, search, filter, and sort within the library.' },
{ key: 'delete_library', resource: 'library', description: 'Allows the user to delete the library and all its contents.' },
{ key: 'manage_library_tags', resource: 'library', description: 'Add or remove tags from content.' },

{ key: 'edit_library_content', resource: 'library_content', description: 'Edit content in draft mode' },
{ 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.' },
];
20 changes: 20 additions & 0 deletions src/authz-module/libraries-manager/context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
role: 'library_author',
permissions: [
'view_library_team',
'edit_library',
],
user_count: 12,
},
],
}),
}));

const TestComponent = () => {
const context = useLibraryAuthZ();
Expand All @@ -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>
);
};
Expand Down Expand Up @@ -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', () => {
Expand Down
25 changes: 18 additions & 7 deletions src/authz-module/libraries-manager/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ 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_TEAM_PERMISSIONS = ['view_library_team', 'manage_library_team'];

export type AppContextType = {
authenticatedUser: {
Expand All @@ -18,8 +21,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);
Expand All @@ -36,7 +40,7 @@ export const LibraryAuthZProvider: React.FC<AuthZProviderProps> = ({ children }:
if (!libraryId) {
throw new Error('MissingLibrary');
}
const permissions = LIBRARY_TEAM_PERMISSIONS.map(action => ({ action, object: libraryId }));
const permissions = LIBRARY_TEAM_PERMISSIONS.map(action => ({ action, scope: libraryId }));

const { data: userPermissions } = useValidateUserPermissions(permissions);
const [{ allowed: canViewTeam }, { allowed: canManageTeam }] = userPermissions;
Expand All @@ -45,13 +49,20 @@ export const LibraryAuthZProvider: React.FC<AuthZProviderProps> = ({ children }:
throw new Error('NoAccess');
}

const { data: libraryRoles } = usePermissionsByRole(libraryId);
const roles = libraryRoles.map(role => ({
...role,
...libraryRolesMetadata.find(r => r.role === role.role),
} 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}>
Expand Down
25 changes: 24 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export interface PermissionValidationRequest {
action: string;
object?: string;
scope?: string;
}

Expand All @@ -11,6 +10,7 @@ export interface PermissionValidationResponse extends PermissionValidationReques
// Libraries AuthZ types
export interface TeamMember {
username: string;
fullName: string;
email: string;
roles: string[];
}
Expand All @@ -22,6 +22,29 @@ export interface LibraryMetadata {
slug: string;
}

export interface RoleMetadata {
role: 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: {
Expand Down