Until now every account could do the same, and managing accounts meant running make-user.php on the server. Accounts now carry a role, and admins get a tab in the editor to create, rename, re-role, reset and delete accounts. Both issues share the same server files, so they land in one commit. #180 Roles: - Every account is admin or user. A missing or unknown role counts as user, on the server and in the client. - auth/session and auth/login return {user, role}. - Auth::requireAdmin() answers everyone else with 403. - make-user.php takes the role as a third argument. Without it an existing account keeps its role, so a password reset from the CLI never demotes an admin. - Admins see and save other users' projects like their own. Saving keeps the original owner. #179 User management: - The account menu gets "User management" (admins only) and "Plugins" (everyone). Both open the same closable tab, accounts on top and plugins below. The fixed Plugins tab is gone. - Per account: Rename, Role, Password (no old password needed) and Delete with a confirmation. "New user..." creates one. - Each account lists its projects with an Open button. - Delete is refused while the account still owns projects. - Renaming moves the projects along and ends every session of the account. transfer takes the same lock as save. - On their own row an admin can only set the password. The last admin cannot be demoted or deleted. The server enforces both. - The account rules live in AccountRules.php and accountRules.ts as pure functions, so they are testable without a server. Findings: - At 1280 px the Delete button reached past the column edge and was not clickable. The name column is flexible now, and module 49 checks the layout. - The server lets an admin delete other users' projects, but the editor has no button for it yet. Left open in the issue. Tests: - ./check.sh: 148 files, 2214 tests, exit 0 (was 147 / 2204). - New tests/php/accounts.test.php, 51 checks, plain PHP. CI runs it as its own step in the gate job, the host has no PHP. - New e2e module 49 with 18 checks. CI creates the e2e-admin account in serve.sh. Modules 14, 38, 46 and the scaffold follow the new tab. - e2e 1-16,37,38,45,46,49: all passed, 581 checks. - Counter-checks: owner on save, admin view, default role and self delete in PHP fail 7, 5, 5 and 1 checks. Unchecked requireAdmin, a wider column and the caller as owner each fail exactly their e2e check. - Check sum 1314 -> 1333 (+1 in 38, +18 in 49), computed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
98 lines
4.2 KiB
TypeScript
98 lines
4.2 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { HttpUsersStore } from '../../src/core/store/HttpUsersStore';
|
||
import { accountActions, nameProblem, newPasswordProblem } from '../../src/editor/users/accountRules';
|
||
import { MIN_PASSWORD_LENGTH } from '../../src/editor/auth/loginRules';
|
||
|
||
function stubFetch(body: BodyInit | null, init: ResponseInit) {
|
||
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
|
||
vi.stubGlobal('fetch', mock);
|
||
return mock;
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
describe('HttpUsersStore (#179)', () => {
|
||
it('lists the accounts with their projects, an unknown role read as user', async () => {
|
||
const mock = stubFetch(
|
||
JSON.stringify([
|
||
{ username: 'admin', role: 'admin', createdAt: '', projects: [] },
|
||
{ username: 'odd', role: 'root', createdAt: '2026-09-23T10:00:00+00:00', projects: [{ id: 'p1' }] },
|
||
]),
|
||
{ status: 200 },
|
||
);
|
||
const accounts = await new HttpUsersStore('/api').list();
|
||
expect(accounts.map((a) => [a.username, a.role])).toEqual([
|
||
['admin', 'admin'],
|
||
['odd', 'user'],
|
||
]);
|
||
expect(accounts[1]?.projects).toHaveLength(1);
|
||
expect(mock.mock.calls[0]?.[0] as string).toContain('r=users');
|
||
});
|
||
|
||
it('creates, changes and deletes through their own routes and methods', async () => {
|
||
const mock = stubFetch(JSON.stringify({}), { status: 200 });
|
||
const store = new HttpUsersStore('/api');
|
||
await store.create('anna', 'a-password', 'admin');
|
||
await store.update('anna', { username: 'annabel' });
|
||
stubFetch(null, { status: 204 });
|
||
await store.remove('annabel');
|
||
|
||
const [createUrl, create] = mock.mock.calls[0]!;
|
||
expect(createUrl as string).toMatch(/r=users$/);
|
||
expect(create?.method).toBe('POST');
|
||
expect(JSON.parse(create?.body as string)).toEqual({
|
||
username: 'anna',
|
||
password: 'a-password',
|
||
role: 'admin',
|
||
});
|
||
const [patchUrl, patch] = mock.mock.calls[1]!;
|
||
expect(patchUrl as string).toContain('r=users%2Fanna');
|
||
expect(patch?.method).toBe('PATCH');
|
||
// Only what changes travels: a field left out is one the server leaves alone.
|
||
expect(JSON.parse(patch?.body as string)).toEqual({ username: 'annabel' });
|
||
});
|
||
|
||
// The dialogs show the error as it stands, so it has to be the server's sentence and nothing else.
|
||
it('turns a refusal into the server’s own sentence', async () => {
|
||
stubFetch(JSON.stringify({ error: 'anna still owns 2 projects' }), { status: 409 });
|
||
await expect(new HttpUsersStore('/api').remove('anna')).rejects.toThrow(/^anna still owns 2 projects$/);
|
||
|
||
stubFetch(JSON.stringify({ error: 'Administrators only' }), { status: 403 });
|
||
await expect(new HttpUsersStore('/api').list()).rejects.toThrow(/^Administrators only$/);
|
||
});
|
||
|
||
it('keeps route and status for a failure that is no refusal', async () => {
|
||
stubFetch(JSON.stringify({ error: 'Internal server error' }), { status: 500 });
|
||
await expect(new HttpUsersStore('/api').list()).rejects.toThrow(/Internal server error \(500\)/);
|
||
});
|
||
});
|
||
|
||
describe('the rules of the user management forms (#179)', () => {
|
||
it('asks for a name the server takes', () => {
|
||
expect(nameProblem('')).toBe('Enter a user name');
|
||
expect(nameProblem('a')).not.toBeNull();
|
||
expect(nameProblem('a b')).not.toBeNull();
|
||
expect(nameProblem('-dash-first')).not.toBeNull();
|
||
expect(nameProblem('x'.repeat(33))).not.toBeNull();
|
||
expect(nameProblem('anna.b_2-x')).toBeNull();
|
||
expect(nameProblem('x'.repeat(32))).toBeNull();
|
||
});
|
||
|
||
it('asks for a password long enough, typed twice the same', () => {
|
||
const long = 'a'.repeat(MIN_PASSWORD_LENGTH);
|
||
expect(newPasswordProblem('', '')).toBe('Enter a password');
|
||
expect(newPasswordProblem(long.slice(1), long.slice(1))).toBe(
|
||
`The password needs at least ${MIN_PASSWORD_LENGTH} characters`,
|
||
);
|
||
expect(newPasswordProblem(long, `${long}x`)).toBe('The two passwords are different');
|
||
expect(newPasswordProblem(long, long)).toBeNull();
|
||
});
|
||
|
||
it('offers nothing but a new password on the administrator’s own row', () => {
|
||
expect(accountActions('admin', 'Admin')).toEqual({ rename: false, role: false, remove: false });
|
||
expect(accountActions('admin', 'anna')).toEqual({ rename: true, role: true, remove: true });
|
||
});
|
||
});
|