Skip to content

Commit a6747d9

Browse files
committed
feat: add scope management and organization selection to Assign Role Wizard
1 parent 2ddf48b commit a6747d9

3 files changed

Lines changed: 355 additions & 11 deletions

File tree

src/authz-module/data/api.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,52 @@ export const getPermissionsByRole = async (scope: string): Promise<PermissionsBy
116116
return camelCaseObject(data.results);
117117
};
118118

119+
export interface ScopeItem {
120+
id: string;
121+
name: string;
122+
org: string;
123+
contextType: 'course' | 'library';
124+
description?: string;
125+
}
126+
127+
export interface GetScopesResponse {
128+
results: ScopeItem[];
129+
count: number;
130+
next: string | null;
131+
previous: string | null;
132+
}
133+
134+
export interface GetScopesParams {
135+
contextType?: string;
136+
search?: string;
137+
org?: string;
138+
page?: number;
139+
pageSize?: number;
140+
}
141+
142+
export interface OrganizationItem {
143+
org: string;
144+
name: string;
145+
}
146+
147+
export const getScopes = async (params: GetScopesParams): Promise<GetScopesResponse> => {
148+
const url = new URL(getApiUrl('/api/authz/v1/scopes/'));
149+
if (params.contextType) { url.searchParams.set('context_type', params.contextType); }
150+
if (params.search) { url.searchParams.set('search', params.search); }
151+
if (params.org) { url.searchParams.set('org', params.org); }
152+
url.searchParams.set('page', (params.page ?? 1).toString());
153+
url.searchParams.set('page_size', (params.pageSize ?? 10).toString());
154+
const { data } = await getAuthenticatedHttpClient().get(url);
155+
return camelCaseObject(data);
156+
};
157+
158+
export const getOrganizations = async (contextType?: string): Promise<OrganizationItem[]> => {
159+
const url = new URL(getApiUrl('/api/authz/v1/organizations/'));
160+
if (contextType) { url.searchParams.set('context_type', contextType); }
161+
const { data } = await getAuthenticatedHttpClient().get(url);
162+
return camelCaseObject(data.results ?? data);
163+
};
164+
119165
export const revokeUserRoles = async (
120166
data: RevokeUserRolesRequest,
121167
): Promise<DeleteRevokeUserRolesResponse> => {

src/authz-module/data/hooks.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import {
2-
useMutation, useQuery, useQueryClient, useSuspenseQuery,
2+
useInfiniteQuery, useMutation, useQuery, useQueryClient, useSuspenseQuery,
33
} from '@tanstack/react-query';
44
import { appId } from '@src/constants';
55
import { LibraryMetadata } from '@src/types';
66
import {
7-
assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers,
8-
GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest,
9-
validateUsers, ValidateUsersRequest,
7+
assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getOrganizations,
8+
getPermissionsByRole, getScopes, GetScopesParams, GetScopesResponse, getTeamMembers,
9+
GetTeamMembersResponse, OrganizationItem, PermissionsByRole, QuerySettings, revokeUserRoles,
10+
RevokeUserRolesRequest, validateUsers, ValidateUsersRequest,
1011
} from './api';
1112

1213
const authzQueryKeys = {
@@ -106,6 +107,41 @@ export const useValidateUsers = () => useMutation({
106107
}) => validateUsers(data),
107108
});
108109

110+
/**
111+
* React Query hook to fetch a paginated, searchable list of scopes (courses or libraries).
112+
* Uses infinite query to support infinite scroll.
113+
*
114+
* @param params - Filter params: contextType, search, org, pageSize
115+
*/
116+
export const useScopes = (params: Omit<GetScopesParams, 'page'>) => useInfiniteQuery<GetScopesResponse, Error>({
117+
queryKey: [...authzQueryKeys.all, 'scopes', params],
118+
queryFn: ({ pageParam }) => getScopes({ ...params, page: pageParam as number }),
119+
getNextPageParam: (lastPage) => {
120+
if (!lastPage.next) { return undefined; }
121+
try {
122+
const nextUrl = new URL(lastPage.next);
123+
const page = nextUrl.searchParams.get('page');
124+
return page ? parseInt(page, 10) : undefined;
125+
} catch {
126+
return undefined;
127+
}
128+
},
129+
initialPageParam: 1,
130+
staleTime: 1000 * 60 * 5,
131+
});
132+
133+
/**
134+
* React Query hook to fetch the list of organizations for a given context type.
135+
* Used to populate the Organization filter dropdown in the scope selector.
136+
*
137+
* @param contextType - 'course' | 'library'
138+
*/
139+
export const useOrganizations = (contextType?: string) => useQuery<OrganizationItem[], Error>({
140+
queryKey: [...authzQueryKeys.all, 'organizations', contextType],
141+
queryFn: () => getOrganizations(contextType),
142+
staleTime: 1000 * 60 * 30,
143+
});
144+
109145
/**
110146
* React Query hook to remove roles for a specific team member within a scope.
111147
*
Lines changed: 269 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,279 @@
1+
import {
2+
useState, useEffect, useRef, useMemo,
3+
} from 'react';
4+
import {
5+
Form, Spinner, Dropdown, Icon, Badge,
6+
} from '@openedx/paragon';
7+
import {
8+
Search, FilterList, ExpandLess, ExpandMore,
9+
} from '@openedx/paragon/icons';
10+
import { useScopes, useOrganizations } from '../data/hooks';
11+
import { courseRolesMetadata, libraryRolesMetadata } from '../constants';
12+
import { ScopeItem } from '../data/api';
13+
14+
const allRolesMetadata = [...courseRolesMetadata, ...libraryRolesMetadata];
15+
16+
function getContextType(role: string | null): string | undefined {
17+
if (!role) { return undefined; }
18+
return allRolesMetadata.find((r) => r.role === role)?.contextType;
19+
}
20+
21+
function getContextLabel(contextType: string | undefined): string {
22+
if (contextType === 'course') { return 'Courses'; }
23+
if (contextType === 'library') { return 'Libraries'; }
24+
return 'Items';
25+
}
26+
27+
interface ScopeCheckboxItemProps {
28+
scope: ScopeItem;
29+
checked: boolean;
30+
onToggle: (scopeId: string) => void;
31+
}
32+
33+
const ScopeCheckboxItem = ({ scope, checked, onToggle }: ScopeCheckboxItemProps) => (
34+
<div className="d-flex align-items-start mb-2" style={{ gap: '8px' }}>
35+
<input
36+
type="checkbox"
37+
id={`scope-${scope.id}`}
38+
checked={checked}
39+
onChange={() => onToggle(scope.id)}
40+
style={{
41+
width: '16px', height: '16px', marginTop: '2px', flexShrink: 0, cursor: 'pointer',
42+
}}
43+
/>
44+
<label htmlFor={`scope-${scope.id}`} className="mb-0" style={{ cursor: 'pointer' }}>
45+
<span>{scope.name}</span>
46+
{scope.description && (
47+
<small className="d-block text-muted">{scope.description}</small>
48+
)}
49+
</label>
50+
</div>
51+
);
52+
53+
interface OrgSectionProps {
54+
org: string;
55+
orgName: string;
56+
scopes: ScopeItem[];
57+
selectedScopes: Set<string>;
58+
onScopeToggle: (scopeId: string) => void;
59+
}
60+
61+
const OrgSection = ({
62+
orgName, scopes, selectedScopes, onScopeToggle,
63+
}: OrgSectionProps) => {
64+
const [collapsed, setCollapsed] = useState(false);
65+
66+
return (
67+
<div className="scope-org-group mb-3">
68+
<button
69+
type="button"
70+
className="d-flex align-items-center bg-transparent border-0 p-0 mb-2 text-primary font-weight-bold"
71+
style={{ gap: '4px' }}
72+
onClick={() => setCollapsed((prev) => !prev)}
73+
>
74+
<span>Org: {orgName}</span>
75+
<Icon src={collapsed ? ExpandMore : ExpandLess} className="text-primary" />
76+
</button>
77+
78+
{!collapsed && (
79+
<div className="pl-2">
80+
{scopes.map((scope) => (
81+
<ScopeCheckboxItem
82+
key={scope.id}
83+
scope={scope}
84+
checked={selectedScopes.has(scope.id)}
85+
onToggle={onScopeToggle}
86+
/>
87+
))}
88+
</div>
89+
)}
90+
</div>
91+
);
92+
};
93+
194
interface DefineApplicationScopeStepProps {
295
selectedRole: string | null;
396
selectedScopes: Set<string>;
497
onScopeToggle: (scopeId: string) => void;
598
}
699

7-
const DefineApplicationScopeStep = (
8-
{ selectedRole, selectedScopes, onScopeToggle }: DefineApplicationScopeStepProps,
9-
) => {
10-
// Placeholder for Step 2 - "Define Application Scope"
11-
// This will be implemented in future iterations
12-
console.log(selectedRole, selectedScopes, onScopeToggle);
100+
const DefineApplicationScopeStep = ({
101+
selectedRole,
102+
selectedScopes,
103+
onScopeToggle,
104+
}: DefineApplicationScopeStepProps) => {
105+
const [search, setSearch] = useState('');
106+
const [debouncedSearch, setDebouncedSearch] = useState('');
107+
const [selectedOrg, setSelectedOrg] = useState<string>('');
108+
const loadMoreRef = useRef<HTMLDivElement>(null);
109+
110+
useEffect(() => {
111+
const timer = setTimeout(() => setDebouncedSearch(search), 300);
112+
return () => clearTimeout(timer);
113+
}, [search]);
114+
115+
const contextType = getContextType(selectedRole);
116+
const contextLabel = getContextLabel(contextType);
117+
118+
const {
119+
data: scopesData,
120+
fetchNextPage,
121+
hasNextPage,
122+
isFetchingNextPage,
123+
isLoading,
124+
} = useScopes({
125+
contextType,
126+
search: debouncedSearch || undefined,
127+
org: selectedOrg || undefined,
128+
});
129+
130+
const { data: organizations } = useOrganizations(contextType);
131+
132+
const allScopes = useMemo(
133+
() => scopesData?.pages.flatMap((page) => page.results) ?? [],
134+
[scopesData],
135+
);
136+
137+
const totalCount = scopesData?.pages[0]?.count ?? 0;
138+
139+
const platformScopes = useMemo(
140+
() => allScopes.filter((s) => !s.org),
141+
[allScopes],
142+
);
143+
144+
const scopesByOrg = useMemo(() => {
145+
const orgScopes = allScopes.filter((s) => !!s.org);
146+
return orgScopes.reduce<Record<string, ScopeItem[]>>((acc, scope) => {
147+
if (!acc[scope.org]) { acc[scope.org] = []; }
148+
acc[scope.org].push(scope);
149+
return acc;
150+
}, {});
151+
}, [allScopes]);
152+
153+
const orderedOrgs = useMemo(() => Object.keys(scopesByOrg), [scopesByOrg]);
154+
155+
useEffect(() => {
156+
const el = loadMoreRef.current;
157+
if (!el) { return undefined; }
158+
const observer = new IntersectionObserver(
159+
(entries) => {
160+
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
161+
fetchNextPage();
162+
}
163+
},
164+
{ threshold: 0.1 },
165+
);
166+
observer.observe(el);
167+
return () => observer.disconnect();
168+
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
169+
170+
const selectedOrgLabel = organizations?.find((o) => o.org === selectedOrg)?.name
171+
|| organizations?.find((o) => o.org === selectedOrg)?.org
172+
|| 'Organization';
173+
174+
return (
175+
<div className="define-application-scope-step">
176+
<h3 className="mb-4">Applies to</h3>
177+
178+
{/* Search + Organization filter + count */}
179+
<div className="d-flex align-items-center justify-content-between gap-3 mb-2 flex-wrap">
180+
<div className="d-flex align-items-center gap-3">
181+
<div style={{ width: '300px' }}>
182+
<Form.Group controlId="scope-search" className="mb-0">
183+
<Form.Control
184+
type="text"
185+
value={search}
186+
onChange={(e: { target: { value: string } }) => setSearch(e.target.value)}
187+
placeholder="Search"
188+
trailingElement={<Icon src={Search} />}
189+
/>
190+
</Form.Group>
191+
</div>
192+
193+
<Dropdown>
194+
<Dropdown.Toggle variant="outline-primary" id="org-filter-toggle">
195+
<Icon src={FilterList} className="mr-2" />
196+
{selectedOrg ? selectedOrgLabel : 'Organization'}
197+
</Dropdown.Toggle>
198+
<Dropdown.Menu>
199+
<Dropdown.Item onClick={() => setSelectedOrg('')} active={!selectedOrg}>
200+
All Organizations
201+
</Dropdown.Item>
202+
{organizations?.map((org) => (
203+
<Dropdown.Item
204+
key={org.org}
205+
onClick={() => setSelectedOrg(org.org)}
206+
active={selectedOrg === org.org}
207+
>
208+
{org.name || org.org}
209+
</Dropdown.Item>
210+
))}
211+
</Dropdown.Menu>
212+
</Dropdown>
213+
</div>
214+
215+
<span className="text-muted small text-nowrap">
216+
Showing {allScopes.length} of {totalCount}.
217+
</span>
218+
</div>
219+
220+
{/* Active filter chip */}
221+
{contextType && (
222+
<div className="mb-3 d-flex align-items-center gap-2">
223+
<span className="text-muted small">Filter applied:</span>
224+
<Badge className="py-1 px-2" style={{ background: '#e8e8e8', color: '#333', fontWeight: 'normal' }}>
225+
{contextLabel}
226+
</Badge>
227+
</div>
228+
)}
229+
230+
{/* Scopes list */}
231+
<div
232+
className="scope-list border rounded p-3"
233+
style={{ maxHeight: '500px', overflowY: 'auto' }}
234+
>
235+
{isLoading ? (
236+
<div className="d-flex justify-content-center p-4">
237+
<Spinner animation="border" screenReaderText="Loading scopes..." />
238+
</div>
239+
) : (
240+
<>
241+
{platformScopes.map((scope) => (
242+
<ScopeCheckboxItem
243+
key={scope.id}
244+
scope={scope}
245+
checked={selectedScopes.has(scope.id)}
246+
onToggle={onScopeToggle}
247+
/>
248+
))}
249+
250+
{orderedOrgs.map((org) => (
251+
<OrgSection
252+
key={org}
253+
org={org}
254+
orgName={organizations?.find((o) => o.org === org)?.name || org}
255+
scopes={scopesByOrg[org]}
256+
selectedScopes={selectedScopes}
257+
onScopeToggle={onScopeToggle}
258+
/>
259+
))}
260+
261+
{allScopes.length === 0 && (
262+
<p className="text-muted text-center py-3">No scopes found.</p>
263+
)}
264+
265+
<div ref={loadMoreRef} />
13266

14-
return <h1>Step 2</h1>;
267+
{isFetchingNextPage && (
268+
<div className="d-flex justify-content-center py-2">
269+
<Spinner animation="border" size="sm" screenReaderText="Loading more..." />
270+
</div>
271+
)}
272+
</>
273+
)}
274+
</div>
275+
</div>
276+
);
15277
};
16278

17279
export default DefineApplicationScopeStep;

0 commit comments

Comments
 (0)