Skip to content

Commit 96f6055

Browse files
committed
feat: enhance Assign Role Wizard with management permission filtering and aggregate scope options
1 parent a6747d9 commit 96f6055

5 files changed

Lines changed: 127 additions & 30 deletions

File tree

src/authz-module/data/api.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export interface GetScopesParams {
137137
org?: string;
138138
page?: number;
139139
pageSize?: number;
140+
managementPermissionOnly?: boolean;
140141
}
141142

142143
export interface OrganizationItem {
@@ -146,9 +147,10 @@ export interface OrganizationItem {
146147

147148
export const getScopes = async (params: GetScopesParams): Promise<GetScopesResponse> => {
148149
const url = new URL(getApiUrl('/api/authz/v1/scopes/'));
149-
if (params.contextType) { url.searchParams.set('context_type', params.contextType); }
150+
// if (params.contextType) { url.searchParams.set('context_type', params.contextType); }
150151
if (params.search) { url.searchParams.set('search', params.search); }
151152
if (params.org) { url.searchParams.set('org', params.org); }
153+
if (params.managementPermissionOnly) { url.searchParams.set('management_permission_only', 'true'); }
152154
url.searchParams.set('page', (params.page ?? 1).toString());
153155
url.searchParams.set('page_size', (params.pageSize ?? 10).toString());
154156
const { data } = await getAuthenticatedHttpClient().get(url);
@@ -157,7 +159,7 @@ export const getScopes = async (params: GetScopesParams): Promise<GetScopesRespo
157159

158160
export const getOrganizations = async (contextType?: string): Promise<OrganizationItem[]> => {
159161
const url = new URL(getApiUrl('/api/authz/v1/organizations/'));
160-
if (contextType) { url.searchParams.set('context_type', contextType); }
162+
// if (contextType) { url.searchParams.set('context_type', contextType); }
161163
const { data } = await getAuthenticatedHttpClient().get(url);
162164
return camelCaseObject(data.results ?? data);
163165
};

src/authz-module/data/hooks.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,20 @@ export const useRevokeUserRoles = () => {
161161
},
162162
});
163163
};
164+
165+
/**
166+
* Fetches all scopes the current user has management permissions over and returns
167+
* the set of orgs derived from those scopes. Used to determine which org-level and
168+
* platform-wide "All..." aggregate options to show in the scope selector.
169+
*
170+
* @param contextType - 'course' | 'library'
171+
*/
172+
export const useManagedScopeOrgs = (contextType?: string) => useQuery({
173+
queryKey: [...authzQueryKeys.all, 'managedScopeOrgs', contextType],
174+
queryFn: async () => {
175+
const data = await getScopes({ contextType, managementPermissionOnly: true, pageSize: 100 });
176+
return new Set(data.results.map((s) => s.org).filter(Boolean));
177+
},
178+
enabled: !!contextType,
179+
staleTime: 1000 * 60 * 30,
180+
});

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { useEffect, useMemo, useState } from 'react';
22
import { useNavigate, useParams } from 'react-router-dom';
33
import { useIntl } from '@edx/frontend-platform/i18n';
4-
import { Container, Skeleton } from '@openedx/paragon';
4+
import { Button, Container, Skeleton } from '@openedx/paragon';
5+
import { Plus } from '@openedx/paragon/icons';
56
import { ROUTES } from '@src/authz-module/constants';
67
import { Role } from 'types';
78
import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
89
import AuthZLayout from '../components/AuthZLayout';
910
import { useLibraryAuthZ } from './context';
1011
import RoleCard from '../components/RoleCard';
11-
import { AssignNewRoleTrigger } from './components/AssignNewRoleModal';
1212
import ConfirmDeletionModal from './components/ConfirmDeletionModal';
1313
import { useLibrary, useRevokeUserRoles, useTeamMembers } from '../data/hooks';
1414
import { buildPermissionMatrixByRole } from './utils';
@@ -154,11 +154,16 @@ const LibrariesUserManager = () => {
154154
pageTitle={user?.username || ''}
155155
pageSubtitle={user?.email || ''}
156156
actions={user && canManageTeam
157-
? [<AssignNewRoleTrigger
158-
username={user.username}
159-
libraryId={libraryId}
160-
currentUserRoles={userRoles.map(role => role.role)}
161-
/>]
157+
? [
158+
<Button
159+
key="assign-role-wizard-trigger"
160+
iconBefore={Plus}
161+
variant="primary"
162+
onClick={() => navigate(`/authz/assign-role?scope=${libraryId}&users=${encodeURIComponent(user.username)}`)}
163+
>
164+
{intl.formatMessage(messages['library.authz.manage.add.role.button'])}
165+
</Button>,
166+
]
162167
: []}
163168
>
164169
<Container className="bg-light-200 p-5">

src/authz-module/wizard/DefineApplicationScopeStep.tsx

Lines changed: 93 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import {
22
useState, useEffect, useRef, useMemo,
33
} from 'react';
4+
import { useIntl } from '@edx/frontend-platform/i18n';
45
import {
56
Form, Spinner, Dropdown, Icon, Badge,
7+
Stack,
68
} from '@openedx/paragon';
79
import {
810
Search, FilterList, ExpandLess, ExpandMore,
911
} from '@openedx/paragon/icons';
10-
import { useScopes, useOrganizations } from '../data/hooks';
12+
import { useScopes, useOrganizations, useManagedScopeOrgs } from '../data/hooks';
1113
import { courseRolesMetadata, libraryRolesMetadata } from '../constants';
1214
import { ScopeItem } from '../data/api';
15+
import messages from './messages';
1316

1417
const allRolesMetadata = [...courseRolesMetadata, ...libraryRolesMetadata];
1518

@@ -51,15 +54,15 @@ const ScopeCheckboxItem = ({ scope, checked, onToggle }: ScopeCheckboxItemProps)
5154
);
5255

5356
interface OrgSectionProps {
54-
org: string;
5557
orgName: string;
5658
scopes: ScopeItem[];
5759
selectedScopes: Set<string>;
5860
onScopeToggle: (scopeId: string) => void;
61+
aggregateScopeItem?: ScopeItem;
5962
}
6063

6164
const OrgSection = ({
62-
orgName, scopes, selectedScopes, onScopeToggle,
65+
orgName, scopes, selectedScopes, onScopeToggle, aggregateScopeItem,
6366
}: OrgSectionProps) => {
6467
const [collapsed, setCollapsed] = useState(false);
6568

@@ -77,6 +80,13 @@ const OrgSection = ({
7780

7881
{!collapsed && (
7982
<div className="pl-2">
83+
{aggregateScopeItem && (
84+
<ScopeCheckboxItem
85+
scope={aggregateScopeItem}
86+
checked={selectedScopes.has(aggregateScopeItem.id)}
87+
onToggle={onScopeToggle}
88+
/>
89+
)}
8090
{scopes.map((scope) => (
8191
<ScopeCheckboxItem
8292
key={scope.id}
@@ -102,6 +112,7 @@ const DefineApplicationScopeStep = ({
102112
selectedScopes,
103113
onScopeToggle,
104114
}: DefineApplicationScopeStepProps) => {
115+
const intl = useIntl();
105116
const [search, setSearch] = useState('');
106117
const [debouncedSearch, setDebouncedSearch] = useState('');
107118
const [selectedOrg, setSelectedOrg] = useState<string>('');
@@ -121,13 +132,21 @@ const DefineApplicationScopeStep = ({
121132
hasNextPage,
122133
isFetchingNextPage,
123134
isLoading,
135+
isError,
124136
} = useScopes({
125137
contextType,
126138
search: debouncedSearch || undefined,
127139
org: selectedOrg || undefined,
128140
});
129141

130142
const { data: organizations } = useOrganizations(contextType);
143+
const { data: managedOrgs } = useManagedScopeOrgs(contextType);
144+
145+
const allowedOrgAggregates = managedOrgs ?? new Set<string>();
146+
147+
const hasPlatformPermission = !!organizations?.length
148+
&& !!managedOrgs
149+
&& organizations.every((o) => managedOrgs.has(o.org));
131150

132151
const allScopes = useMemo(
133152
() => scopesData?.pages.flatMap((page) => page.results) ?? [],
@@ -143,11 +162,20 @@ const DefineApplicationScopeStep = ({
143162

144163
const scopesByOrg = useMemo(() => {
145164
const orgScopes = allScopes.filter((s) => !!s.org);
146-
return orgScopes.reduce<Record<string, ScopeItem[]>>((acc, scope) => {
165+
const grouped = orgScopes.reduce<Record<string, ScopeItem[]>>((acc, scope) => {
147166
if (!acc[scope.org]) { acc[scope.org] = []; }
148167
acc[scope.org].push(scope);
149168
return acc;
150169
}, {});
170+
171+
Object.keys(grouped).forEach((org) => {
172+
grouped[org].sort((a, b) => {
173+
const aIsAll = a.name.startsWith('All ') ? 0 : 1;
174+
const bIsAll = b.name.startsWith('All ') ? 0 : 1;
175+
return aIsAll - bIsAll;
176+
});
177+
});
178+
return grouped;
151179
}, [allScopes]);
152180

153181
const orderedOrgs = useMemo(() => Object.keys(scopesByOrg), [scopesByOrg]);
@@ -157,23 +185,45 @@ const DefineApplicationScopeStep = ({
157185
if (!el) { return undefined; }
158186
const observer = new IntersectionObserver(
159187
(entries) => {
160-
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
188+
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage && !isError) {
161189
fetchNextPage();
162190
}
163191
},
164192
{ threshold: 0.1 },
165193
);
166194
observer.observe(el);
167195
return () => observer.disconnect();
168-
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
196+
}, [hasNextPage, isFetchingNextPage, isError, fetchNextPage]);
169197

170198
const selectedOrgLabel = organizations?.find((o) => o.org === selectedOrg)?.name
171199
|| organizations?.find((o) => o.org === selectedOrg)?.org
172200
|| 'Organization';
173201

202+
const aggregateLabel = contextType === 'course'
203+
? 'All courses in this organization'
204+
: 'All libraries in this organization';
205+
206+
const aggregateDescription = contextType === 'course'
207+
? 'Includes current and future courses'
208+
: 'Includes current and future libraries';
209+
210+
const platformAggregateLabel = contextType === 'course'
211+
? 'All courses in Platform'
212+
: 'All libraries in Platform';
213+
214+
const platformAggregateScopeItem: ScopeItem | null = (hasPlatformPermission && contextType)
215+
? {
216+
id: '*',
217+
name: platformAggregateLabel,
218+
description: aggregateDescription,
219+
org: '',
220+
contextType: contextType as 'course' | 'library',
221+
}
222+
: null;
223+
174224
return (
175225
<div className="define-application-scope-step">
176-
<h3 className="mb-4">Applies to</h3>
226+
<h3 className="mb-4">{intl.formatMessage(messages['wizard.step.defineScope.title'])}</h3>
177227

178228
{/* Search + Organization filter + count */}
179229
<div className="d-flex align-items-center justify-content-between gap-3 mb-2 flex-wrap">
@@ -219,14 +269,16 @@ const DefineApplicationScopeStep = ({
219269

220270
{/* Active filter chip */}
221271
{contextType && (
222-
<div className="mb-3 d-flex align-items-center gap-2">
272+
<Stack direction="horizontal" gap={2} className="align-items-center">
223273
<span className="text-muted small">Filter applied:</span>
224-
<Badge className="py-1 px-2" style={{ background: '#e8e8e8', color: '#333', fontWeight: 'normal' }}>
274+
<Badge className="py-1 px-2" variant="light">
225275
{contextLabel}
226276
</Badge>
227-
</div>
277+
</Stack>
228278
)}
229279

280+
<hr className="my-4" />
281+
230282
{/* Scopes list */}
231283
<div
232284
className="scope-list border rounded p-3"
@@ -238,6 +290,15 @@ const DefineApplicationScopeStep = ({
238290
</div>
239291
) : (
240292
<>
293+
{/* Platform-wide aggregate option (permission-gated) */}
294+
{platformAggregateScopeItem && (
295+
<ScopeCheckboxItem
296+
scope={platformAggregateScopeItem}
297+
checked={selectedScopes.has(platformAggregateScopeItem.id)}
298+
onToggle={onScopeToggle}
299+
/>
300+
)}
301+
241302
{platformScopes.map((scope) => (
242303
<ScopeCheckboxItem
243304
key={scope.id}
@@ -247,16 +308,28 @@ const DefineApplicationScopeStep = ({
247308
/>
248309
))}
249310

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-
))}
311+
{orderedOrgs.map((org) => {
312+
const aggregateScopeItem: ScopeItem | undefined = (allowedOrgAggregates.has(org) && contextType)
313+
? {
314+
id: `org:${org}`,
315+
name: aggregateLabel,
316+
description: aggregateDescription,
317+
org,
318+
contextType: contextType as 'course' | 'library',
319+
}
320+
: undefined;
321+
322+
return (
323+
<OrgSection
324+
key={org}
325+
orgName={organizations?.find((o) => o.org === org)?.name || org}
326+
scopes={scopesByOrg[org]}
327+
selectedScopes={selectedScopes}
328+
onScopeToggle={onScopeToggle}
329+
aggregateScopeItem={aggregateScopeItem}
330+
/>
331+
);
332+
})}
260333

261334
{allScopes.length === 0 && (
262335
<p className="text-muted text-center py-3">No scopes found.</p>

src/authz-module/wizard/messages.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const messages = defineMessages({
2121
},
2222
'wizard.step.defineScope.title': {
2323
id: 'wizard.step.defineScope.title',
24-
defaultMessage: 'Where it applies',
24+
defaultMessage: 'Where It Applies',
2525
description: 'Step 2 title in the assign role wizard',
2626
},
2727

0 commit comments

Comments
 (0)