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>
165 lines
7.6 KiB
TypeScript
165 lines
7.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { ASSET_TYPES } from '../../src/core/model/AssetTypes';
|
||
import { DECLARED_PORT_KINDS, FIELD_KINDS, kindMark, VARIABLE_KINDS } from '../../src/core/properties/kinds';
|
||
import {
|
||
ASSET_ICON_SVG,
|
||
GAME_ICON_SVG,
|
||
LIST_ICON_SVG,
|
||
PLUGINS_ICON_SVG,
|
||
USERS_ICON_SVG,
|
||
} from '../../src/editor/panels/assetIcons';
|
||
import { HEADER_ICON_SVG } from '../../src/editor/panels/headerIcons';
|
||
import { ARROW_ICON_SVG, KIND_ICON_SVG, ROW_ACTION_ICON_SVG } from '../../src/editor/panels/markIcons';
|
||
import { TOOLBAR_ICON_SVG } from '../../src/editor/panels/toolbarIcons';
|
||
import { UI_ICON_SVG } from '../../src/ui/icons';
|
||
import { tintSvg } from '../../src/ui/svg';
|
||
import { SYMBOL_SVG } from '../../src/viewport/symbolIcons';
|
||
|
||
/** Opening tags as name + attributes; the files are flat, so no real XML parser is needed. */
|
||
function tags(svg: string): { name: string; attrs: Record<string, string> }[] {
|
||
return [...svg.matchAll(/<([a-z]+)([^>]*?)\/?>/g)].map(([, name, rest]) => ({
|
||
name: name!,
|
||
attrs: Object.fromEntries([...rest!.matchAll(/([a-zA-Z-]+)="([^"]*)"/g)].map(([, k, v]) => [k!, v!])),
|
||
}));
|
||
}
|
||
|
||
describe('icon files', () => {
|
||
it('has a scene view symbol for every symbol (#175)', () => {
|
||
expect(Object.keys(SYMBOL_SVG).sort()).toEqual([
|
||
'behavior',
|
||
'camera',
|
||
'container',
|
||
'particles',
|
||
'rigidbody',
|
||
]);
|
||
});
|
||
|
||
it('has an asset panel icon for every asset type, each its own picture (#176)', () => {
|
||
expect(Object.keys(ASSET_ICON_SVG).sort()).toEqual([...ASSET_TYPES].sort());
|
||
expect(new Set(Object.values(ASSET_ICON_SVG)).size).toBe(ASSET_TYPES.length);
|
||
});
|
||
|
||
const icons = [
|
||
...Object.entries(SYMBOL_SVG).map(([name, svg]) => [`symbol ${name}`, svg]),
|
||
...Object.entries(ASSET_ICON_SVG).map(([name, svg]) => [`asset ${name}`, svg]),
|
||
['list tab', LIST_ICON_SVG],
|
||
['game tab', GAME_ICON_SVG],
|
||
['plugins tab', PLUGINS_ICON_SVG],
|
||
['user management tab', USERS_ICON_SVG],
|
||
...Object.entries(TOOLBAR_ICON_SVG).map(([name, svg]) => [`toolbar ${name}`, svg]),
|
||
...Object.entries(HEADER_ICON_SVG).map(([name, svg]) => [`header ${name}`, svg]),
|
||
...Object.entries(UI_ICON_SVG).map(([name, svg]) => [`ui ${name}`, svg]),
|
||
...Object.entries(KIND_ICON_SVG).map(([name, svg]) => [`kind ${name}`, svg]),
|
||
...Object.entries(ROW_ACTION_ICON_SVG).map(([name, svg]) => [`row action ${name}`, svg]),
|
||
...Object.entries(ARROW_ICON_SVG).map(([name, svg]) => [`arrow ${name}`, svg]),
|
||
];
|
||
|
||
// The symbols that were characters until #214: the font the editor ships has no such glyphs.
|
||
it('marks every kind a user can pick once – by a picture, or by letters where it has none (#214)', () => {
|
||
const picked = [...new Set([...VARIABLE_KINDS, ...FIELD_KINDS, ...DECLARED_PORT_KINDS])];
|
||
const marks = picked.map((kind) => {
|
||
const icon = KIND_ICON_SVG[kind];
|
||
const letters = kindMark(kind);
|
||
expect(Boolean(icon) !== Boolean(letters), `${kind}: a picture or letters, not both`).toBe(true);
|
||
return icon ?? letters;
|
||
});
|
||
expect(new Set(marks).size).toBe(picked.length);
|
||
});
|
||
|
||
// A test finds a picture by the id of its file (`locateIcon`), so every id names one file.
|
||
it('names each picture a test looks for after its own file (#214)', () => {
|
||
const named = icons.map(([, svg]) => tags(svg!)[0]!.attrs.id).filter((id) => id !== undefined);
|
||
expect(new Set(named).size).toBe(named.length);
|
||
for (const svg of [
|
||
...Object.values(UI_ICON_SVG),
|
||
...Object.values(KIND_ICON_SVG),
|
||
...Object.values(ROW_ACTION_ICON_SVG),
|
||
...Object.values(ARROW_ICON_SVG),
|
||
])
|
||
expect(tags(svg)[0]!.attrs.id).toBeDefined();
|
||
});
|
||
|
||
// The magnet stands alone in the header, without the "Snap" it replaced (#194): a picture another
|
||
// button already wears would say that button's thing.
|
||
it('draws the snap switch as a magnet of its own, bent without arcs (#194)', () => {
|
||
const others = [
|
||
...Object.values(ASSET_ICON_SVG),
|
||
...Object.values(TOOLBAR_ICON_SVG),
|
||
LIST_ICON_SVG,
|
||
GAME_ICON_SVG,
|
||
PLUGINS_ICON_SVG,
|
||
];
|
||
expect(others).not.toContain(HEADER_ICON_SVG.snap);
|
||
expect(HEADER_ICON_SVG.snap).toContain('C');
|
||
for (const { attrs } of tags(HEADER_ICON_SVG.snap).slice(1)) expect(attrs.d ?? '').not.toMatch(/[Aa]/);
|
||
});
|
||
|
||
it('has one icon per button of the top bar, each its own picture (#183)', () => {
|
||
expect(Object.keys(TOOLBAR_ICON_SVG).sort()).toEqual(['menu', 'pause', 'play', 'redo', 'stop', 'undo']);
|
||
expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(6);
|
||
});
|
||
|
||
// The bar carries no captions any more, so a picture shared with a tab or an asset type would be
|
||
// the only thing left telling the two apart – and it would not.
|
||
it('draws the top bar in pictures of its own, none of them an asset icon (#183)', () => {
|
||
const others = [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG];
|
||
for (const svg of Object.values(TOOLBAR_ICON_SVG)) expect(others).not.toContain(svg);
|
||
});
|
||
|
||
// Pixi's parser draws an A in the middle of a path as a spike (the puzzle piece of #119); undo
|
||
// and redo are the arcs of this set, so they are the ones that have to keep clear of it.
|
||
it('bends the top bar arrows with cubic curves rather than arcs (#183)', () => {
|
||
for (const svg of [TOOLBAR_ICON_SVG.undo, TOOLBAR_ICON_SVG.redo]) {
|
||
expect(svg).toContain('C');
|
||
for (const { attrs } of tags(svg).slice(1)) expect(attrs.d ?? '').not.toMatch(/[Aa]/);
|
||
}
|
||
});
|
||
|
||
// Pixi draws the file's coordinates as they are and ignores the viewBox, so the viewBox only
|
||
// shows the file right elsewhere – centred on the origin, it matches what the editor draws.
|
||
it.each(icons)('%s is centred on its origin', (_, svg) => {
|
||
const root = tags(svg)[0]!;
|
||
expect(root.name).toBe('svg');
|
||
const [x, y, w, h] = root.attrs.viewBox!.split(' ').map(Number);
|
||
expect([x, y]).toEqual([-w! / 2, -h! / 2]);
|
||
expect([Number(root.attrs.width), Number(root.attrs.height)]).toEqual([w, h]);
|
||
});
|
||
|
||
it('draws the asset icons in the 16 units a list row and a button reserve', () => {
|
||
for (const svg of [
|
||
...Object.values(ASSET_ICON_SVG),
|
||
LIST_ICON_SVG,
|
||
GAME_ICON_SVG,
|
||
PLUGINS_ICON_SVG,
|
||
USERS_ICON_SVG,
|
||
])
|
||
expect(tags(svg)[0]!.attrs.viewBox).toBe('-8 -8 16 16');
|
||
});
|
||
|
||
it('gives the List, Game, Plugins and User management tabs a picture no asset type has (#176, #119, #179)', () => {
|
||
const tabs = [LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG, USERS_ICON_SVG];
|
||
for (const svg of tabs) expect(Object.values(ASSET_ICON_SVG)).not.toContain(svg);
|
||
expect(new Set(tabs).size).toBe(tabs.length);
|
||
});
|
||
|
||
it.each(icons)('%s uses only what the Pixi parser draws like a browser', (_, svg) => {
|
||
for (const { name, attrs } of tags(svg).slice(1)) {
|
||
// polygon and polyline read their points as whole numbers; groups would carry transforms,
|
||
// which the parser ignores.
|
||
expect(['path', 'circle', 'rect', 'ellipse', 'line']).toContain(name);
|
||
expect(attrs.transform).toBeUndefined();
|
||
// Without a fill a browser fills black, Pixi does not fill at all once there is a stroke.
|
||
expect(attrs.fill, `${name} states its fill`).toBeDefined();
|
||
for (const paint of [attrs.fill, attrs.stroke].filter((p) => p !== undefined))
|
||
expect(paint).toMatch(/^(none|currentColor|#[0-9a-f]{6})$/);
|
||
}
|
||
});
|
||
|
||
it('puts the theme colour where the file says currentColor', () => {
|
||
expect(tintSvg('<path fill="currentColor" stroke="currentColor"/>', 0x0000ff)).toBe(
|
||
'<path fill="#0000ff" stroke="#0000ff"/>',
|
||
);
|
||
for (const [, svg] of icons) expect(tintSvg(svg!, 0xb066ff)).not.toContain('currentColor');
|
||
});
|
||
});
|