Skip to content

Commit f27222d

Browse files
committed
feat: enhance Assign Role Wizard with scope management and user validation improvements
1 parent 34afd39 commit f27222d

7 files changed

Lines changed: 249 additions & 94 deletions

File tree

src/authz-module/constants.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,16 @@ export const CONTENT_LIBRARY_PERMISSIONS = {
3434
// but for the MVP we decided to manage it in the frontend
3535
export const libraryRolesMetadata = [
3636
{
37-
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.', contextType: 'library',
37+
role: 'library_admin', name: 'Library Admin', description: 'Can create and manage content libraries, including access and structure.', contextType: 'library',
3838
},
3939
{
40-
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.', contextType: 'library',
40+
role: 'library_author', name: 'Library Author', description: 'Can create and edit library content, but cannot manage access.', contextType: 'library',
4141
},
4242
{
43-
role: 'library_contributor', name: 'Library Contributor', description: 'The Library Contributor 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.', contextType: 'library',
43+
role: 'library_contributor', name: 'Library Contributor', description: 'Can contribute and update library content shared with them.', contextType: 'library',
4444
},
4545
{
46-
role: 'library_user', name: 'Library User', description: 'The Library User can view and reuse content but cannot edit or delete any resource.', contextType: 'library',
46+
role: 'library_user', name: 'Library User', description: 'Can view and use library content, but cannot edit it.', contextType: 'library',
4747
},
4848
];
4949

src/authz-module/data/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export interface AssignTeamMembersRoleRequest {
5050
scope: string;
5151
}
5252

53-
// Validate Users API
53+
// TODO: Validate Users API
5454
export type ValidateUsersRequest = {
5555
users: string[];
5656
};

src/authz-module/data/hooks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ export const useAssignTeamMembersRole = () => {
102102
*/
103103
export const useValidateUsers = () => useMutation({
104104
mutationFn: async ({ data }: {
105-
data: ValidateUsersRequest
106-
}) => validateUsers(data),
105+
data: ValidateUsersRequest
106+
}) => validateUsers(data),
107107
});
108108

109109
/**

src/authz-module/wizard/AssignRoleWizard.tsx

Lines changed: 95 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
1-
import { useState } from 'react';
21
import {
3-
Stepper, Button, Container, StatefulButton,
4-
Icon,
2+
useState, useCallback, useEffect, useMemo,
3+
} from 'react';
4+
import {
5+
Stepper, Button, Container, StatefulButton, Icon,
56
} from '@openedx/paragon';
67
import { SpinnerSimple } from '@openedx/paragon/icons';
78
import SelectUsersAndRoleStep from './SelectUsersAndRoleStep';
89
import DefineApplicationScopeStep from './DefineApplicationScopeStep';
9-
import { useValidateUsers } from '../data/hooks';
10+
import { useValidateUsers, useAssignTeamMembersRole } from '../data/hooks';
1011
import { courseRolesMetadata, libraryRolesMetadata } from '../constants';
12+
import { useToastManager } from '../libraries-manager/ToastManagerContext';
13+
import { useValidateUserPermissions } from '../../data/hooks';
1114

1215
const allRolesMetadata = [...courseRolesMetadata, ...libraryRolesMetadata];
1316

17+
const CONTEXT_BY_ACTION: Record<string, string> = {
18+
'content_libraries.manage_library_team': 'library',
19+
'courses.manage_course_team': 'course',
20+
};
21+
1422
const STEPS = {
1523
SELECT_USERS_AND_ROLE: 'select-users-and-role',
1624
DEFINE_APPLICATION_SCOPE: 'define-application-scope',
@@ -20,68 +28,114 @@ type StepKey = typeof STEPS[keyof typeof STEPS];
2028

2129
interface AssignRoleWizardProps {
2230
onClose: () => void;
31+
scope: string;
2332
}
2433

25-
const AssignRoleWizard = ({ onClose }: AssignRoleWizardProps) => {
34+
const AssignRoleWizard = ({ onClose, scope }: AssignRoleWizardProps) => {
2635
const [activeStep, setActiveStep] = useState<StepKey>(STEPS.SELECT_USERS_AND_ROLE);
2736
const [users, setUsers] = useState('');
2837
const [selectedRole, setSelectedRole] = useState<string | null>(null);
38+
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(new Set());
2939

3040
const [validationError, setValidationError] = useState<string | null>(null);
3141
const [invalidUsers, setInvalidUsers] = useState<string[]>([]);
3242

3343
const validateUsersMutation = useValidateUsers();
44+
const assignRoleMutation = useAssignTeamMembersRole();
45+
const { showToast, showErrorToast } = useToastManager();
46+
47+
// Filter role groups based on what the current user is allowed to manage
48+
const permissionChecks = useMemo(() => [
49+
{ action: 'content_libraries.manage_library_team', scope },
50+
{ action: 'courses.manage_course_team', scope },
51+
], [scope]);
52+
53+
const { data: permissionsData } = useValidateUserPermissions(permissionChecks);
54+
55+
// Clear highlights as soon as the user edits the input
56+
useEffect(() => {
57+
if (invalidUsers.length > 0) {
58+
setInvalidUsers([]);
59+
}
60+
}, [users]); // eslint-disable-line react-hooks/exhaustive-deps
61+
62+
const filteredRoles = useMemo(() => {
63+
const allowedContextTypes = new Set(
64+
permissionsData
65+
.filter((p) => p.allowed)
66+
.map((p) => CONTEXT_BY_ACTION[p.action])
67+
.filter(Boolean),
68+
);
69+
return allRolesMetadata.filter((r) => allowedContextTypes.has(r.contextType || ''));
70+
}, [permissionsData]);
3471

3572
const handleClose = () => {
3673
setActiveStep(STEPS.SELECT_USERS_AND_ROLE);
3774
setUsers('');
3875
setSelectedRole(null);
76+
setSelectedScopes(new Set());
3977
setValidationError(null);
4078
setInvalidUsers([]);
4179
onClose();
4280
};
4381

4482
const parseUsers = (input: string): string[] => input
4583
.split(',')
46-
.map(u => u.trim())
84+
.map((u) => u.trim())
4785
.filter(Boolean);
4886

4987
const validateUsersAndProceed = async () => {
5088
const usersList = parseUsers(users);
51-
if (usersList.length === 0 || !selectedRole) {
52-
return;
53-
}
89+
if (usersList.length === 0 || !selectedRole) { return; }
5490

5591
setValidationError(null);
5692
setInvalidUsers([]);
5793

5894
try {
59-
await validateUsersMutation.mutateAsync({
60-
data: {
61-
users: usersList,
62-
},
63-
});
64-
65-
setActiveStep(STEPS.DEFINE_APPLICATION_SCOPE);
66-
} catch (error: any) {
67-
const errorData = error?.response?.data;
68-
if (errorData?.invalidUsers) {
69-
setInvalidUsers(errorData.invalidUsers);
70-
setValidationError('Some users were not found. Please review the highlighted names below.');
95+
const result = await validateUsersMutation.mutateAsync({ data: { users: usersList } });
96+
if (result.invalidUsers?.length > 0) {
97+
setInvalidUsers(result.invalidUsers);
7198
} else {
72-
setValidationError('An error occurred while validating users. Please try again.');
99+
setActiveStep(STEPS.DEFINE_APPLICATION_SCOPE);
73100
}
101+
} catch {
102+
setValidationError('An error occurred while validating users. Please try again.');
103+
}
104+
};
105+
106+
const handleScopeToggle = useCallback((scopeId: string) => {
107+
setSelectedScopes((prev) => {
108+
const next = new Set(prev);
109+
if (next.has(scopeId)) { next.delete(scopeId); } else { next.add(scopeId); }
110+
return next;
111+
});
112+
}, []);
113+
114+
const handleSave = async () => {
115+
if (!selectedRole || selectedScopes.size === 0) { return; }
116+
const usersList = parseUsers(users);
117+
118+
try {
119+
await Promise.all(
120+
Array.from(selectedScopes).map((selectedScope) => assignRoleMutation.mutateAsync({
121+
data: { users: usersList, role: selectedRole, scope: selectedScope },
122+
})),
123+
);
124+
showToast({ message: 'Role assigned successfully.', type: 'success', delay: 5000 });
125+
handleClose();
126+
} catch (error: any) {
127+
showErrorToast(error, handleSave);
74128
}
75129
};
76130

77131
const canProceed = users.trim() && selectedRole && !validateUsersMutation.isPending;
132+
const canSave = selectedScopes.size > 0 && !assignRoleMutation.isPending;
78133

79134
return (
80135
<Stepper activeKey={activeStep}>
81136
<Stepper.Header className="bg-info-100" />
82137

83138
<Container className="p-5">
84-
{/* Step 1: Who and Role */}
85139
<Stepper.Step
86140
onClick={() => setActiveStep(STEPS.SELECT_USERS_AND_ROLE)}
87141
eventKey={STEPS.SELECT_USERS_AND_ROLE}
@@ -92,21 +146,21 @@ const AssignRoleWizard = ({ onClose }: AssignRoleWizardProps) => {
92146
setUsers={setUsers}
93147
selectedRole={selectedRole}
94148
setSelectedRole={setSelectedRole}
95-
roles={allRolesMetadata}
149+
roles={filteredRoles}
96150
validationError={validationError}
97151
invalidUsers={invalidUsers}
98152
/>
99153
</Stepper.Step>
100154

101-
{/* Step 2: Where it applies */}
102155
<Stepper.Step
103156
onClick={() => setActiveStep(STEPS.DEFINE_APPLICATION_SCOPE)}
104157
eventKey={STEPS.DEFINE_APPLICATION_SCOPE}
105158
title="Where it applies"
106159
>
107160
<DefineApplicationScopeStep
108-
users={users}
109161
selectedRole={selectedRole}
162+
selectedScopes={selectedScopes}
163+
onScopeToggle={handleScopeToggle}
110164
/>
111165
</Stepper.Step>
112166
</Container>
@@ -118,27 +172,29 @@ const AssignRoleWizard = ({ onClose }: AssignRoleWizardProps) => {
118172
</Button>
119173
<Stepper.ActionRow.Spacer />
120174
<StatefulButton
121-
labels={{
122-
default: 'Next',
123-
pending: 'Validating...',
124-
}}
125-
icons={{
126-
pending: <Icon src={SpinnerSimple} />,
127-
}}
175+
labels={{ default: 'Next', pending: 'Validating...' }}
176+
icons={{ pending: <Icon src={SpinnerSimple} /> }}
128177
state={validateUsersMutation.isPending ? 'pending' : 'default'}
129178
onClick={validateUsersAndProceed}
130-
disabled={!canProceed | validateUsersMutation.isPending}
179+
disabled={!canProceed || validateUsersMutation.isPending}
131180
/>
132181
</Stepper.ActionRow>
133182

134183
<Stepper.ActionRow eventKey={STEPS.DEFINE_APPLICATION_SCOPE}>
135-
<Button variant="outline-primary" onClick={() => setActiveStep(STEPS.SELECT_USERS_AND_ROLE)}>
136-
Previous
184+
<Button variant="outline-primary" onClick={handleClose}>
185+
Cancel
137186
</Button>
138-
<Stepper.ActionRow.Spacer />
139-
<Button onClick={handleClose}>
140-
Apply
187+
<Button variant="tertiary" onClick={() => setActiveStep(STEPS.SELECT_USERS_AND_ROLE)}>
188+
Back
141189
</Button>
190+
<Stepper.ActionRow.Spacer />
191+
<StatefulButton
192+
labels={{ default: 'Save', pending: 'Saving...' }}
193+
icons={{ pending: <Icon src={SpinnerSimple} /> }}
194+
state={assignRoleMutation.isPending ? 'pending' : 'default'}
195+
onClick={handleSave}
196+
disabled={!canSave}
197+
/>
142198
</Stepper.ActionRow>
143199
</div>
144200
</Stepper>

src/authz-module/wizard/AssignRoleWizardPage.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,7 @@ const AssignRoleWizardPage = () => {
1313
const { data: library, isLoading, error } = useLibrary(scope);
1414

1515
if (isLoading) {
16-
return (
17-
<div className="assign-role-wizard-page p-4">
18-
<p>Loading...</p>
19-
</div>
20-
);
16+
return null;
2117
}
2218

2319
if (error || !library) {
@@ -48,6 +44,7 @@ const AssignRoleWizardPage = () => {
4844
>
4945
<AssignRoleWizard
5046
onClose={handleCancel}
47+
scope={scope}
5148
/>
5249
</AuthZLayout>
5350
);
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { useMemo } from 'react';
2+
3+
// Shared styles between overlay and textarea to keep text positions in sync
4+
const INPUT_STYLE: React.CSSProperties = {
5+
fontFamily: 'inherit',
6+
fontSize: '1rem',
7+
lineHeight: '1.5',
8+
padding: '0.375rem 0.75rem',
9+
whiteSpace: 'pre-wrap',
10+
wordBreak: 'break-word',
11+
overflowWrap: 'break-word',
12+
boxSizing: 'border-box',
13+
width: '100%',
14+
};
15+
16+
interface HighlightedUsersInputProps {
17+
value: string;
18+
onChange: (val: string) => void;
19+
invalidUsers: string[];
20+
placeholder?: string;
21+
hasNetworkError?: boolean;
22+
}
23+
24+
const HighlightedUsersInput = ({
25+
value, onChange, invalidUsers, placeholder, hasNetworkError = false,
26+
}: HighlightedUsersInputProps) => {
27+
const invalidSet = useMemo(
28+
() => new Set(invalidUsers.map((u) => u.trim())),
29+
[invalidUsers],
30+
);
31+
const hasHighlights = invalidSet.size > 0;
32+
33+
const renderedParts = useMemo(() => {
34+
if (!hasHighlights) { return null; }
35+
let offset = 0;
36+
return value.split(/(,)/).map((part) => {
37+
const key = offset;
38+
offset += part.length;
39+
if (part === ',') { return <span key={`comma-${key}`}>,</span>; }
40+
const trimmed = part.trim();
41+
const isInvalid = trimmed.length > 0 && invalidSet.has(trimmed);
42+
return (
43+
<span key={`part-${key}`} style={{ color: isInvalid ? '#c62828' : '#212529' }}>
44+
{part}
45+
</span>
46+
);
47+
});
48+
}, [value, invalidSet, hasHighlights]);
49+
50+
return (
51+
<div style={{ position: 'relative' }}>
52+
{/* Highlight layer — sits behind the transparent textarea */}
53+
{hasHighlights && (
54+
<div
55+
aria-hidden="true"
56+
style={{
57+
...INPUT_STYLE,
58+
position: 'absolute',
59+
inset: 0,
60+
background: '#fff',
61+
border: '1px solid transparent',
62+
borderRadius: '0.25rem',
63+
overflow: 'hidden',
64+
pointerEvents: 'none',
65+
zIndex: 0,
66+
}}
67+
>
68+
{renderedParts}
69+
</div>
70+
)}
71+
72+
{/* Actual textarea — text is transparent when overlay is active */}
73+
<textarea
74+
id="users-input"
75+
rows={4}
76+
value={value}
77+
onChange={(e) => onChange(e.target.value)}
78+
placeholder={hasHighlights ? undefined : placeholder}
79+
className={`form-control${hasNetworkError ? ' is-invalid' : ''}`}
80+
style={{
81+
...INPUT_STYLE,
82+
position: 'relative',
83+
zIndex: 1,
84+
color: hasHighlights ? 'transparent' : undefined,
85+
caretColor: '#212529',
86+
background: hasHighlights ? 'transparent' : undefined,
87+
resize: 'vertical',
88+
display: 'block',
89+
minHeight: '100px',
90+
}}
91+
/>
92+
</div>
93+
);
94+
};
95+
96+
export default HighlightedUsersInput;

0 commit comments

Comments
 (0)