Skip to content

Commit 2c6a2c5

Browse files
committed
feat: add granular group-based permissions with category restrictions
Groups can now have their permissions scoped to specific categories. When no category restrictions are set, rights apply globally (backward compatible). This enables controlled external contributions where users can be restricted to specific categories via group membership. New table faqgroup_right_category links group rights to categories. Added API endpoints for managing category restrictions, admin UI panel for configuring restrictions per permission, and hasPermissionForCategory() for category-scoped permission checks. This feature implements and closes #3780.
1 parent 6ee3d6f commit 2c6a2c5

15 files changed

Lines changed: 1182 additions & 7 deletions

File tree

phpmyfaq/admin/assets/src/api/group.test.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
2-
import { fetchAllGroups, fetchAllUsersForGroups, fetchAllMembers, fetchGroup, fetchGroupRights } from './group';
2+
import {
3+
fetchAllGroups,
4+
fetchAllUsersForGroups,
5+
fetchAllMembers,
6+
fetchGroup,
7+
fetchGroupRights,
8+
fetchGroupCategoryRestrictions,
9+
saveGroupCategoryRestrictions,
10+
fetchCategoriesForRestrictions,
11+
} from './group';
312
import * as fetchWrapperModule from './fetch-wrapper';
413

514
vi.mock('./fetch-wrapper', () => ({
615
fetchJson: vi.fn(),
16+
fetchWrapper: vi.fn(),
717
}));
818

919
describe('fetchAllGroups', () => {
@@ -164,3 +174,92 @@ describe('fetchGroupRights', () => {
164174
await expect(fetchGroupRights(groupId)).rejects.toThrow('Network response was not ok.');
165175
});
166176
});
177+
178+
describe('fetchGroupCategoryRestrictions', () => {
179+
beforeEach(() => {
180+
vi.clearAllMocks();
181+
});
182+
183+
it('should fetch category restrictions for a group', async () => {
184+
const mockResponse = { '1': [10, 20], '3': [30] };
185+
vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse);
186+
187+
const groupId = '5';
188+
const result = await fetchGroupCategoryRestrictions(groupId);
189+
190+
expect(result).toEqual(mockResponse);
191+
expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith(`./api/group/category-restrictions/${groupId}`, {
192+
method: 'GET',
193+
cache: 'no-cache',
194+
headers: {
195+
'Content-Type': 'application/json',
196+
},
197+
redirect: 'follow',
198+
referrerPolicy: 'no-referrer',
199+
});
200+
});
201+
202+
it('should throw an error if the network response is not ok', async () => {
203+
vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.'));
204+
205+
await expect(fetchGroupCategoryRestrictions('5')).rejects.toThrow('Network response was not ok.');
206+
});
207+
});
208+
209+
describe('saveGroupCategoryRestrictions', () => {
210+
beforeEach(() => {
211+
vi.clearAllMocks();
212+
});
213+
214+
it('should save category restrictions for a group right', async () => {
215+
const mockResponse = { ok: true, status: 200 } as Response;
216+
vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponse);
217+
218+
const result = await saveGroupCategoryRestrictions('5', '1', [10, 20]);
219+
220+
expect(result).toEqual(mockResponse);
221+
expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith('./api/group/category-restrictions', {
222+
method: 'POST',
223+
cache: 'no-cache',
224+
headers: {
225+
'Content-Type': 'application/json',
226+
},
227+
body: JSON.stringify({ groupId: 5, rightId: 1, categoryIds: [10, 20] }),
228+
redirect: 'follow',
229+
referrerPolicy: 'no-referrer',
230+
});
231+
});
232+
});
233+
234+
describe('fetchCategoriesForRestrictions', () => {
235+
beforeEach(() => {
236+
vi.clearAllMocks();
237+
});
238+
239+
it('should fetch all categories for restriction picker', async () => {
240+
const mockResponse = [
241+
{ id: 1, name: 'General', parent_id: 0 },
242+
{ id: 2, name: 'Technical', parent_id: 0 },
243+
];
244+
vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse);
245+
246+
const result = await fetchCategoriesForRestrictions();
247+
248+
expect(result).toEqual(mockResponse);
249+
expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/group/categories', {
250+
method: 'GET',
251+
cache: 'no-cache',
252+
headers: {
253+
'Content-Type': 'application/json',
254+
},
255+
redirect: 'follow',
256+
referrerPolicy: 'no-referrer',
257+
});
258+
});
259+
260+
it('should throw an error if the network response is not ok', async () => {
261+
vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.'));
262+
263+
await expect(fetchCategoriesForRestrictions()).rejects.toThrow('Network response was not ok.');
264+
});
265+
});

phpmyfaq/admin/assets/src/api/group.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
* @since 2023-01-02
1414
*/
1515

16-
import { Group, Member, User } from '../interfaces';
17-
import { fetchJson } from './fetch-wrapper';
16+
import { CategoryItem, CategoryRestrictions, Group, Member, User } from '../interfaces';
17+
import { fetchJson, fetchWrapper } from './fetch-wrapper';
1818

1919
export const fetchAllGroups = async (): Promise<Group[]> => {
2020
return (await fetchJson('./api/group/groups', {
@@ -75,3 +75,44 @@ export const fetchGroupRights = async (groupId: string): Promise<string[]> => {
7575
referrerPolicy: 'no-referrer',
7676
})) as string[];
7777
};
78+
79+
export const fetchGroupCategoryRestrictions = async (groupId: string): Promise<CategoryRestrictions> => {
80+
return (await fetchJson(`./api/group/category-restrictions/${groupId}`, {
81+
method: 'GET',
82+
cache: 'no-cache',
83+
headers: {
84+
'Content-Type': 'application/json',
85+
},
86+
redirect: 'follow',
87+
referrerPolicy: 'no-referrer',
88+
})) as CategoryRestrictions;
89+
};
90+
91+
export const saveGroupCategoryRestrictions = async (
92+
groupId: string,
93+
rightId: string,
94+
categoryIds: number[]
95+
): Promise<Response> => {
96+
return await fetchWrapper('./api/group/category-restrictions', {
97+
method: 'POST',
98+
cache: 'no-cache',
99+
headers: {
100+
'Content-Type': 'application/json',
101+
},
102+
body: JSON.stringify({ groupId: parseInt(groupId), rightId: parseInt(rightId), categoryIds }),
103+
redirect: 'follow',
104+
referrerPolicy: 'no-referrer',
105+
});
106+
};
107+
108+
export const fetchCategoriesForRestrictions = async (): Promise<CategoryItem[]> => {
109+
return (await fetchJson('./api/group/categories', {
110+
method: 'GET',
111+
cache: 'no-cache',
112+
headers: {
113+
'Content-Type': 'application/json',
114+
},
115+
redirect: 'follow',
116+
referrerPolicy: 'no-referrer',
117+
})) as CategoryItem[];
118+
};

phpmyfaq/admin/assets/src/group/groups.ts

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,18 @@
1515
* @since 2023-01-04
1616
*/
1717

18-
import { fetchAllGroups, fetchAllMembers, fetchAllUsersForGroups, fetchGroup, fetchGroupRights } from '../api';
18+
import {
19+
fetchAllGroups,
20+
fetchAllMembers,
21+
fetchAllUsersForGroups,
22+
fetchCategoriesForRestrictions,
23+
fetchGroup,
24+
fetchGroupCategoryRestrictions,
25+
fetchGroupRights,
26+
saveGroupCategoryRestrictions,
27+
} from '../api';
1928
import { selectAll, unSelectAll } from '../utils';
20-
import { Group, Member, User } from '../interfaces';
29+
import { CategoryItem, CategoryRestrictions, Group, Member, User } from '../interfaces';
2130

2231
export const handleGroups = async (): Promise<void> => {
2332
clearGroupList();
@@ -74,6 +83,26 @@ export const handleGroups = async (): Promise<void> => {
7483
unSelectAllMembers.addEventListener('click', (): void => {
7584
unSelectAll('group_member_list');
7685
});
86+
87+
// Category restrictions save button
88+
const saveCategoryRestrictions = document.getElementById('saveCategoryRestrictions') as HTMLButtonElement;
89+
if (saveCategoryRestrictions) {
90+
saveCategoryRestrictions.addEventListener('click', async (event: Event): Promise<void> => {
91+
event.preventDefault();
92+
await handleCategoryRestrictionsSave();
93+
});
94+
}
95+
96+
// Update category restrictions panel when rights checkboxes change
97+
document.getElementById('groupRights')?.addEventListener('change', (event: Event): void => {
98+
const target = event.target as HTMLInputElement;
99+
if (target.type === 'checkbox' && target.classList.contains('permission')) {
100+
const container = document.getElementById('categoryRestrictionsBody');
101+
if (container) {
102+
renderCategoryRestrictions(container);
103+
}
104+
}
105+
});
77106
};
78107

79108
const handleGroupSelect = async (event: Event): Promise<void> => {
@@ -88,6 +117,7 @@ const handleGroupSelect = async (event: Event): Promise<void> => {
88117
await getUserList();
89118
clearMemberList();
90119
await getMemberList(groupId);
120+
await loadCategoryRestrictions(groupId);
91121

92122
// Activate user inputs
93123
const saveGroupDetails = document.getElementById('saveGroupDetails') as HTMLButtonElement;
@@ -96,13 +126,17 @@ const handleGroupSelect = async (event: Event): Promise<void> => {
96126
const deleteGroup = document.getElementById('deleteGroup') as HTMLButtonElement;
97127
const groupAddMember = document.getElementById('groupAddMember') as HTMLButtonElement;
98128
const groupRemoveMember = document.getElementById('groupRemoveMember') as HTMLButtonElement;
129+
const saveCategoryRestrictions = document.getElementById('saveCategoryRestrictions') as HTMLButtonElement;
99130

100131
saveGroupDetails.disabled = false;
101132
saveMembersList.disabled = false;
102133
saveGroupRights.disabled = false;
103134
deleteGroup.disabled = false;
104135
groupAddMember.disabled = false;
105136
groupRemoveMember.disabled = false;
137+
if (saveCategoryRestrictions) {
138+
saveCategoryRestrictions.disabled = false;
139+
}
106140

107141
document.querySelectorAll<HTMLInputElement>('.permission').forEach((item: HTMLInputElement): void => {
108142
item.disabled = false;
@@ -279,3 +313,101 @@ const removeGroupMembers = (): void => {
279313
}
280314
}
281315
};
316+
317+
let cachedCategories: CategoryItem[] = [];
318+
let currentRestrictions: CategoryRestrictions = {};
319+
320+
const loadCategoryRestrictions = async (groupId: string): Promise<void> => {
321+
const container = document.getElementById('categoryRestrictionsBody');
322+
if (!container) {
323+
return;
324+
}
325+
326+
if (cachedCategories.length === 0) {
327+
cachedCategories = await fetchCategoriesForRestrictions();
328+
}
329+
330+
currentRestrictions = await fetchGroupCategoryRestrictions(groupId);
331+
332+
renderCategoryRestrictions(container);
333+
};
334+
335+
const renderCategoryRestrictions = (container: HTMLElement): void => {
336+
const checkedRights = document.querySelectorAll<HTMLInputElement>('#groupRights input[type=checkbox]:checked');
337+
338+
container.innerHTML = '';
339+
340+
if (checkedRights.length === 0) {
341+
container.innerHTML = '<p class="text-muted">No permissions assigned to this group.</p>';
342+
return;
343+
}
344+
345+
checkedRights.forEach((checkbox: HTMLInputElement): void => {
346+
const rightId = checkbox.value;
347+
const label = checkbox.closest('.form-check')?.querySelector('label')?.textContent?.trim() || `Right ${rightId}`;
348+
const restrictedCategoryIds = currentRestrictions[rightId] || [];
349+
350+
const wrapper = document.createElement('div');
351+
wrapper.className = 'mb-3';
352+
353+
const labelElement = document.createElement('label');
354+
labelElement.className = 'form-label fw-semibold';
355+
labelElement.textContent = label;
356+
wrapper.appendChild(labelElement);
357+
358+
const select = document.createElement('select');
359+
select.className = 'form-select form-select-sm';
360+
select.multiple = true;
361+
select.size = 4;
362+
select.dataset.rightId = rightId;
363+
364+
cachedCategories.forEach((cat: CategoryItem): void => {
365+
const option = document.createElement('option');
366+
option.value = String(cat.id);
367+
option.textContent = cat.name;
368+
option.selected = restrictedCategoryIds.includes(cat.id);
369+
select.appendChild(option);
370+
});
371+
372+
wrapper.appendChild(select);
373+
374+
const helpText = document.createElement('div');
375+
helpText.className = 'form-text';
376+
helpText.textContent = 'Select categories to restrict this permission. Leave empty for unrestricted access.';
377+
wrapper.appendChild(helpText);
378+
379+
container.appendChild(wrapper);
380+
});
381+
};
382+
383+
export const handleCategoryRestrictionsSave = async (): Promise<void> => {
384+
const groupListSelect = document.getElementById('group_list_select') as HTMLSelectElement;
385+
if (!groupListSelect) {
386+
return;
387+
}
388+
389+
const groupId = groupListSelect.value;
390+
if (!groupId) {
391+
return;
392+
}
393+
394+
const container = document.getElementById('categoryRestrictionsBody');
395+
if (!container) {
396+
return;
397+
}
398+
399+
const selects = container.querySelectorAll<HTMLSelectElement>('select[data-right-id]');
400+
401+
for (const select of selects) {
402+
const rightId = select.dataset.rightId;
403+
if (!rightId) {
404+
continue;
405+
}
406+
407+
const selectedCategoryIds = [...select.options]
408+
.filter((option: HTMLOptionElement): boolean => option.selected)
409+
.map((option: HTMLOptionElement): number => parseInt(option.value));
410+
411+
await saveGroupCategoryRestrictions(groupId, rightId, selectedCategoryIds);
412+
}
413+
};

phpmyfaq/admin/assets/src/interfaces/Group.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,13 @@ export interface Group {
44
description?: string;
55
auto_join?: string;
66
}
7+
8+
export interface CategoryItem {
9+
id: number;
10+
name: string;
11+
parent_id: number;
12+
}
13+
14+
export interface CategoryRestrictions {
15+
[rightId: string]: number[];
16+
}

phpmyfaq/assets/templates/admin/user/group.twig

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,22 @@
204204
</div>
205205
</form>
206206
</div>
207+
208+
<div id="groupCategoryRestrictions" class="card shadow mb-4">
209+
<h5 class="card-header py-3">
210+
<i aria-hidden="true" class="bi bi-folder-check"></i> {{ 'ad_group_category_restrictions' | translate }}
211+
</h5>
212+
<div class="card-body" id="categoryRestrictionsBody">
213+
<p class="text-muted">{{ 'ad_group_category_restrictions_help' | translate }}</p>
214+
</div>
215+
<div class="card-footer">
216+
<div class="card-button text-end">
217+
<button id="saveCategoryRestrictions" class="btn btn-primary" type="button" disabled>
218+
{{ 'ad_gen_save' | translate }}
219+
</button>
220+
</div>
221+
</div>
222+
</div>
207223
</div>
208224
</div>
209225
{% endblock %}

0 commit comments

Comments
 (0)