Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
13c54cd
feat(authz): add role assignation wizard scope selection step
bra-i-am Apr 21, 2026
c33db58
fix: solve lint issues
bra-i-am Apr 21, 2026
24ea006
feat(authz): enhance role assignment wizard with internationalization…
bra-i-am Apr 21, 2026
715c7fd
feat(authz): implement error handling for role assignment with locali…
bra-i-am Apr 21, 2026
1cc4ae4
feat(tests): add intlWrapper for testing with internationalization su…
bra-i-am Apr 21, 2026
1e7a9a7
fix: solve lint issues
bra-i-am Apr 21, 2026
d8afbcd
feat(authz): enhance role assignment wizard with error handling and u…
bra-i-am Apr 21, 2026
01e1205
fix: address feedback
bra-i-am Apr 21, 2026
6d2b7a3
fix(authz): boost CSS specificity to prevent Paragon overrides on hig…
bra-i-am Apr 21, 2026
0384f03
fix: address feedback
bra-i-am Apr 21, 2026
6eccc79
fix: address feedback
bra-i-am Apr 22, 2026
aaae8b9
feat(authz): enhance role assignment wizard with improved scope handl…
bra-i-am Apr 22, 2026
1c5c21d
feat(authz): update role assignment wizard navigation for single and …
bra-i-am Apr 22, 2026
be84d9f
feat(authz): update role assignment wizard to support multiple organi…
bra-i-am Apr 22, 2026
c8a417b
feat(authz): update tests for role assignment wizard to reflect new s…
bra-i-am Apr 23, 2026
26b8731
fix: increase test coverage
bra-i-am Apr 23, 2026
912a1d8
feat(authz): integrate useScopePermissions for enhanced permission ha…
bra-i-am Apr 23, 2026
f710eb2
feat(authz): implement getOrgAggregateScopeKey for dynamic scope gene…
bra-i-am Apr 23, 2026
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
11 changes: 10 additions & 1 deletion src/authz-module/authz-home/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ const emptyResponse = {
refetch: jest.fn(),
};

const emptyScopesResponse = {
data: { pages: [] },
error: null,
isLoading: false,
hasNextPage: false,
fetchNextPage: jest.fn(),
isFetchingNextPage: false,
};

const renderAuthzHome = () => renderWithAllProviders(
<ToastManagerProvider>
<AuthzHome />
Expand All @@ -32,7 +41,7 @@ describe('AuthzHome', () => {
beforeEach(() => {
(useAllRoleAssignments as jest.Mock).mockReturnValue(emptyResponse);
(useOrgs as jest.Mock).mockReturnValue(emptyResponse);
(useScopes as jest.Mock).mockReturnValue(emptyResponse);
(useScopes as jest.Mock).mockReturnValue(emptyScopesResponse);
});

it('renders without crashing', () => {
Expand Down
22 changes: 13 additions & 9 deletions src/authz-module/components/TableControlBar/ScopesFilter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ import ScopesFilter from './ScopesFilter';
jest.mock('@src/authz-module/data/hooks', () => ({
useScopes: () => ({
data: {
results: [
pages: [
{
externalKey: 'course:123',
name: 'Test Course',
organization: { name: 'Test Org' },
},
{
externalKey: 'library:456',
name: 'Test Library',
organization: { name: 'Another Org' },
results: [
{
externalKey: 'course:123',
name: 'Test Course',
organization: { name: 'Test Org' },
},
{
externalKey: 'library:456',
name: 'Test Library',
organization: { name: 'Another Org' },
},
],
},
],
},
Expand Down
6 changes: 3 additions & 3 deletions src/authz-module/components/TableControlBar/ScopesFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ const ScopesFilter = ({
}: ScopesFilterProps) => {
const { formatMessage } = useIntl();
const [searchValue, setSearchValue] = useState<string | undefined>(undefined);
const { data: scopesData = { results: [] } } = useScopes(searchValue, 1, DEFAULT_FILTER_PAGE_SIZE);
const { data: scopesData } = useScopes({ search: searchValue, pageSize: DEFAULT_FILTER_PAGE_SIZE });

const filterChoices = useMemo(() => scopesData.results.map((scope) => {
const filterChoices = useMemo(() => (scopesData?.pages?.flatMap((p) => p.results) ?? []).map((scope) => {
const scopeIcon = scope.externalKey?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE;
let groupName = formatMessage(messages['authz.team.members.table.group.courses']);
if (scope.externalKey?.startsWith('lib')) {
Expand All @@ -30,7 +30,7 @@ const ScopesFilter = ({
groupName,
groupIcon: scopeIcon,
};
}), [scopesData, formatMessage]);
}), [scopesData?.pages, formatMessage]);

const handleSearchChange = (value: string) => {
setSearchValue(value);
Expand Down
16 changes: 15 additions & 1 deletion src/authz-module/constants.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { buildWizardPath, ROUTES } from './constants';
import { buildWizardPath, getOrgAggregateScopeKey, ROUTES } from './constants';

const BASE = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`;

Expand Down Expand Up @@ -32,3 +32,17 @@ describe('buildWizardPath', () => {
expect(buildWizardPath({ users: '', from: '' })).toBe(BASE);
});
});

describe('getOrgAggregateScopeKey', () => {
it('returns course wildcard scope for course context', () => {
expect(getOrgAggregateScopeKey('course', 'MIT')).toBe('course-v1:MIT+*');
});

it('returns library wildcard scope for library context', () => {
expect(getOrgAggregateScopeKey('library', 'MIT')).toBe('lib:MIT:*');
});

it('throws for an unknown contextType', () => {
expect(() => getOrgAggregateScopeKey('unknown', 'MIT')).toThrow('Unknown contextType: "unknown"');
});
});
11 changes: 11 additions & 0 deletions src/authz-module/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ export const CONTENT_COURSE_PERMISSIONS = {
VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS: 'courses.view_global_staff_and_superadmins',
};

const ORG_AGGREGATE_SCOPE_BUILDERS = {
course: (orgSlug: string) => `course-v1:${orgSlug}+*`,
library: (orgSlug: string) => `lib:${orgSlug}:*`,
};

export const getOrgAggregateScopeKey = (contextType: string, orgSlug: string): string => {
const builder = ORG_AGGREGATE_SCOPE_BUILDERS[contextType];
if (!builder) { throw new Error(`Unknown contextType: "${contextType}"`); }
return builder(orgSlug);
};

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.' },
Expand Down
17 changes: 12 additions & 5 deletions src/authz-module/data/api.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,30 +277,37 @@ describe('API functions', () => {
it('should fetch scopes successfully', async () => {
const mockResponse = {
data: {
scopes: [
results: [
{ displayName: 'Library 1', scope: 'lib:test1' },
],
count: 1,
next: null,
previous: null,
},
};

getAuthenticatedHttpClient.mockReturnValue({
get: jest.fn().mockResolvedValue(mockResponse),
});

const result = await getScopes();
const result = await getScopes({});

expect(result.scopes).toHaveLength(1);
expect(result.results).toHaveLength(1);
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
});

it('should handle search, page, and pageSize parameters', async () => {
const mockResponse = { data: { scopes: [] } };
const mockResponse = {
data: {
results: [], count: 0, next: null, previous: null,
},
};
const mockGet = jest.fn().mockResolvedValue(mockResponse);
getAuthenticatedHttpClient.mockReturnValue({
get: mockGet,
});

await getScopes('library', 3, 50);
await getScopes({ search: 'library', page: 3, pageSize: 50 });

expect(mockGet).toHaveBeenCalled();
const calledUrl = mockGet.mock.calls[0][0];
Expand Down
30 changes: 18 additions & 12 deletions src/authz-module/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,13 @@ export type PermissionsByRole = {
};
export interface PutAssignTeamMembersRoleResponse {
completed: { userIdentifier: string; status: string }[];
errors: { userIdentifier: string; error: string }[];
errors: { userIdentifier: string; scope: string; error: string }[];
}

export interface AssignTeamMembersRoleRequest {
users: string[];
role: string;
scope: string;
scopes: string[];
}

export interface GetAllRoleAssignmentsResponse {
Expand Down Expand Up @@ -97,6 +97,15 @@ export type ValidateUsersResponse = {
};
};

export interface GetScopesParams {
scopeType?: string;
search?: string;
orgs?: string[];
page?: number;
pageSize?: number;
managementPermissionOnly?: boolean;
}

export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise<GetTeamMembersResponse> => {
const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));

Expand Down Expand Up @@ -208,17 +217,14 @@ export const getOrgs = async (search?: string, page?: number, pageSize?: number)
return camelCaseObject(data);
};

export const getScopes = async (search?: string, page?: number, pageSize?: number): Promise<GetScopesResponse> => {
export const getScopes = async (params: GetScopesParams): Promise<GetScopesResponse> => {
const url = new URL(getApiUrl('/api/authz/v1/scopes/'));
if (search !== undefined) {
url.searchParams.set('search', search);
}
if (page !== undefined) {
url.searchParams.set('page', page.toString());
}
if (pageSize !== undefined) {
url.searchParams.set('page_size', pageSize.toString());
}
if (params.search) { url.searchParams.set('search', params.search); }
if (params.scopeType) { url.searchParams.set('scope_type', params.scopeType); }
if (params.orgs?.length) { url.searchParams.set('orgs', params.orgs.join(',')); }
if (params.managementPermissionOnly) { url.searchParams.set('management_permission_only', 'true'); }
url.searchParams.set('page', (params.page ?? 1).toString());
url.searchParams.set('page_size', (params.pageSize ?? 10).toString());
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
};
Expand Down
Loading