Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions docs/administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,27 @@ Groups can be restricted per right to a set of categories (Admin → Groups →
- A blocked action returns HTTP 403 with a message naming the missing right and the category.
- FAQs without any category assignment can only be modified by users whose rights are not category-restricted.

### 5.1.4 Language restrictions

Both individual users and groups can be restricted per right to a set of languages (Admin → Users/Groups → Language
restrictions). The rules are deliberately different from category restrictions:

- **No restriction selected = the right applies to all languages.**
- Unlike category restrictions, **language restrictions apply to direct user rights too** — a user's own right grant can
itself be scoped to specific language(s), not only rights granted through group membership.
- A user restricted to `de` cannot add, edit, translate, approve, or delete FAQ content in `fr` or any other language,
and the admin UI only offers the allowed languages.
- **Basic permission mode has no groups but still enforces per-user language restrictions** directly (unlike category
restrictions, which are a no-op in Basic mode).
- When a user has both a direct grant and a group grant for the same right, the right applies to the **union** of both
grants' allowed languages; either grant being unrestricted makes the right globally usable for that user.
- Restrictions match exact language codes; there is no fallback or inheritance between language variants.
- Translating a FAQ into a new language is checked against the **target** language (the language being created), not
the source language being read.
- A blocked action returns HTTP 403 with a message naming the missing right and the language.
- Category and language restrictions combine with **AND** semantics: an action must be permitted by both the category
and the language rules to succeed.

## 5.2 Content

### 5.2.1 Category Administration
Expand Down
92 changes: 92 additions & 0 deletions phpmyfaq/admin/assets/src/api/group.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
fetchGroupCategoryRestrictions,
saveGroupCategoryRestrictions,
fetchCategoriesForRestrictions,
fetchGroupLanguageRestrictions,
saveGroupLanguageRestrictions,
fetchLanguagesForRestrictions,
updateGroup,
updateGroupMembers,
updateGroupPermissions,
Expand Down Expand Up @@ -268,6 +271,95 @@ describe('fetchCategoriesForRestrictions', () => {
});
});

describe('fetchGroupLanguageRestrictions', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should fetch language restrictions for a group', async () => {
const mockResponse = { '1': ['en', 'de'], '3': ['fr'] };
vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse);

const groupId = '5';
const result = await fetchGroupLanguageRestrictions(groupId);

expect(result).toEqual(mockResponse);
expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith(`./api/group/language-restrictions/${groupId}`, {
method: 'GET',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
});
});

it('should throw an error if the network response is not ok', async () => {
vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.'));

await expect(fetchGroupLanguageRestrictions('5')).rejects.toThrow('Network response was not ok.');
});
});

describe('saveGroupLanguageRestrictions', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should save language restrictions for a group right', async () => {
const mockResponse = { ok: true, status: 200 } as Response;
vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponse);

const result = await saveGroupLanguageRestrictions('5', '1', ['en', 'de'], 'test-csrf-token');

expect(result).toEqual(mockResponse);
expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith('./api/group/language-restrictions', {
method: 'POST',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ groupId: 5, rightId: 1, languages: ['en', 'de'], csrfToken: 'test-csrf-token' }),
redirect: 'follow',
referrerPolicy: 'no-referrer',
});
});
});

describe('fetchLanguagesForRestrictions', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should fetch all languages for restriction picker', async () => {
const mockResponse = [
{ code: 'en', label: 'English' },
{ code: 'de', label: 'Deutsch' },
];
vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse);

const result = await fetchLanguagesForRestrictions();

expect(result).toEqual(mockResponse);
expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('./api/group/languages', {
method: 'GET',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
});
});

it('should throw an error if the network response is not ok', async () => {
vi.spyOn(fetchWrapperModule, 'fetchJson').mockRejectedValue(new Error('Network response was not ok.'));

await expect(fetchLanguagesForRestrictions()).rejects.toThrow('Network response was not ok.');
});
});

describe('updateGroup', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
53 changes: 52 additions & 1 deletion phpmyfaq/admin/assets/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@
* @since 2023-01-02
*/

import { ApiResponse, CategoryItem, CategoryRestrictions, Group, Member, User } from '../interfaces';
import {
ApiResponse,
CategoryItem,
CategoryRestrictions,
Group,
LanguageItem,
LanguageRestrictions,
Member,
User,
} from '../interfaces';
import { fetchJson, fetchWrapper } from './fetch-wrapper';

export const fetchAllGroups = async (): Promise<Group[]> => {
Expand Down Expand Up @@ -118,6 +127,48 @@ export const fetchCategoriesForRestrictions = async (): Promise<CategoryItem[]>
})) as CategoryItem[];
};

export const fetchGroupLanguageRestrictions = async (groupId: string): Promise<LanguageRestrictions> => {
return (await fetchJson(`./api/group/language-restrictions/${groupId}`, {
method: 'GET',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
})) as LanguageRestrictions;
};

export const saveGroupLanguageRestrictions = async (
groupId: string,
rightId: string,
languages: string[],
csrfToken: string
): Promise<Response> => {
return await fetchWrapper('./api/group/language-restrictions', {
method: 'POST',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ groupId: parseInt(groupId), rightId: parseInt(rightId), languages, csrfToken }),
redirect: 'follow',
referrerPolicy: 'no-referrer',
});
};

export const fetchLanguagesForRestrictions = async (): Promise<LanguageItem[]> => {
return (await fetchJson('./api/group/languages', {
method: 'GET',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
})) as LanguageItem[];
};

export const updateGroup = async (
groupId: string,
name: string,
Expand Down
43 changes: 43 additions & 0 deletions phpmyfaq/admin/assets/src/api/user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {
addUser,
fetchUsers,
fetchUserData,
fetchUserLanguageRestrictions,
fetchUserRights,
fetchAllUsers,
overwritePassword,
saveUserLanguageRestrictions,
updateUserData,
updateUserRights,
deleteUser,
Expand Down Expand Up @@ -267,6 +269,47 @@ describe('User API', () => {
});
});

describe('fetchUserLanguageRestrictions', () => {
it('should fetch language restrictions for a user', async () => {
const mockResponse = { '1': ['en', 'de'] };
global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockResponse) } as Response));

const result = await fetchUserLanguageRestrictions('42');

expect(result).toEqual(mockResponse);
expect(global.fetch).toHaveBeenCalledWith('./api/user/language-restrictions/42', {
method: 'GET',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
});
});
});

describe('saveUserLanguageRestrictions', () => {
it('should PUT the language restrictions payload to user/language-restrictions', async () => {
const mockResponse = { success: 'saved' };
global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockResponse) } as Response));

const result = await saveUserLanguageRestrictions('42', '1', ['en', 'de'], 'token');

expect(result).toEqual(mockResponse);
expect(global.fetch).toHaveBeenCalledWith('./api/user/language-restrictions', {
method: 'PUT',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
body: JSON.stringify({ csrfToken: 'token', userId: '42', rightId: 1, languages: ['en', 'de'] }),
});
});
});

describe('addUser', () => {
it('should POST the new user payload to user/add', async () => {
const mockResponse = { success: 'added' };
Expand Down
40 changes: 39 additions & 1 deletion phpmyfaq/admin/assets/src/api/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@
*/

import { fetchJson } from './fetch-wrapper';
import { AddUserPayload, ApiResponse, UserAutocomplete, UserData, UserEditPayload, UserOverview } from '../interfaces';
import {
AddUserPayload,
ApiResponse,
LanguageRestrictions,
UserAutocomplete,
UserData,
UserEditPayload,
UserOverview,
} from '../interfaces';

// Dual-path endpoint: when a non-empty `filter` query param is sent (as here),
// the server returns autocomplete pairs ({ label, value }); when no filter is
Expand Down Expand Up @@ -118,6 +126,36 @@ export const updateUserRights = async (
});
};

export const fetchUserLanguageRestrictions = async (userId: string): Promise<LanguageRestrictions> => {
return await fetchJson<LanguageRestrictions>(`./api/user/language-restrictions/${userId}`, {
method: 'GET',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
});
};

export const saveUserLanguageRestrictions = async (
userId: string,
rightId: string,
languages: string[],
csrfToken: string
): Promise<ApiResponse> => {
return await fetchJson<ApiResponse>('./api/user/language-restrictions', {
method: 'PUT',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
redirect: 'follow',
referrerPolicy: 'no-referrer',
body: JSON.stringify({ csrfToken, userId, rightId: parseInt(rightId), languages }),
});
};

export const addUser = async (payload: AddUserPayload): Promise<ApiResponse | string[]> => {
return await fetchJson<ApiResponse | string[]>('./api/user/add', {
method: 'POST',
Expand Down
35 changes: 32 additions & 3 deletions phpmyfaq/admin/assets/src/group/groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
fetchCategoriesForRestrictions,
fetchGroup,
fetchGroupCategoryRestrictions,
fetchGroupLanguageRestrictions,
fetchGroupRights,
fetchLanguagesForRestrictions,
saveGroupLanguageRestrictions,
updateGroup,
updateGroupMembers,
updateGroupPermissions,
Expand Down Expand Up @@ -76,6 +79,9 @@ const setupFullDom = (): void => {
<div id="categoryRestrictionsBody" data-msg-empty="No permissions." data-msg-help="Help."
data-msg-saved="Restrictions saved." data-csrf-token="csrf-restrictions"></div>
<button id="saveCategoryRestrictions" type="button"></button>
<div id="languageRestrictionsBody" data-msg-empty="No permissions." data-msg-help="Help."
data-msg-saved="Language restrictions saved." data-csrf-token="csrf-language-restrictions"></div>
<button id="saveLanguageRestrictions" type="button"></button>
</div>
<div id="pmf-group-delete-modal">
<strong id="pmf-group-delete-name"></strong>
Expand Down Expand Up @@ -103,6 +109,9 @@ const mockDefaultApis = (): void => {
(fetchGroupRights as Mock).mockResolvedValue(['1', '3']);
(fetchCategoriesForRestrictions as Mock).mockResolvedValue([]);
(fetchGroupCategoryRestrictions as Mock).mockResolvedValue({});
(fetchLanguagesForRestrictions as Mock).mockResolvedValue([]);
(fetchGroupLanguageRestrictions as Mock).mockResolvedValue({});
(saveGroupLanguageRestrictions as Mock).mockResolvedValue({ ok: true } as Response);
(updateGroup as Mock).mockResolvedValue({ success: 'saved' });
(updateGroupMembers as Mock).mockResolvedValue({ success: 'saved' });
(updateGroupPermissions as Mock).mockResolvedValue({ success: 'saved' });
Expand Down Expand Up @@ -345,15 +354,21 @@ describe('handleGroups', () => {
expect(document.getElementById('pmf-group-empty-state')?.classList.contains('d-none')).toBe(false);
});

// Keep this test last: cachedCategories is module-level and, once populated
// with a non-empty list, is reused by any test that runs after it.
it('should indent subcategories in the category restrictions options', async () => {
// Keep this test last: cachedCategories and cachedLanguages are module-level
// and, once populated with a non-empty list, are reused by any test that
// runs after it.
it('should indent subcategories in the category restrictions options and render language restriction options', async () => {
setupFullDom();
mockDefaultApis();
(fetchCategoriesForRestrictions as Mock).mockResolvedValue([
{ id: 1, name: 'Guides', parent_id: 0, level: 0 },
{ id: 2, name: 'Setup', parent_id: 1, level: 1 },
]);
(fetchLanguagesForRestrictions as Mock).mockResolvedValue([
{ code: 'en', label: 'English' },
{ code: 'de', label: 'Deutsch' },
]);
(fetchGroupLanguageRestrictions as Mock).mockResolvedValue({ '1': ['de'] });

await handleGroups();
await selectFirstGroup();
Expand All @@ -365,5 +380,19 @@ describe('handleGroups', () => {
expect(options.length).toBe(2);
expect(options[0].textContent).toBe('Guides');
expect(options[1].textContent).toBe('\u00A0\u00A0\u00A0Setup');

const languageOptions = document.querySelectorAll<HTMLOptionElement>(
'#languageRestrictionsBody select[data-right-id="1"] option'
);
expect(languageOptions.length).toBe(2);
expect(languageOptions[0].textContent).toBe('English');
expect(languageOptions[1].selected).toBe(true);

(document.getElementById('saveLanguageRestrictions') as HTMLButtonElement).click();
await flushPromises();

expect(saveGroupLanguageRestrictions).toHaveBeenCalledWith('1', '1', ['de'], 'csrf-language-restrictions');
expect(saveGroupLanguageRestrictions).toHaveBeenCalledWith('1', '3', [], 'csrf-language-restrictions');
expect(pushNotification).toHaveBeenCalledWith('Language restrictions saved.');
});
});
Loading
Loading