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>
274 lines
11 KiB
JavaScript
274 lines
11 KiB
JavaScript
import { openTab, pickAssetType } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Plugins (#113, #114, #119). The demo plugin in `plugins/` is copied to `data/plugins/` by the
|
||
* build and the seeder; the fixture project switches it on, and the editor loads it before the
|
||
* project itself. What only a running editor can show: that the module really loads and activates,
|
||
* that its tab joins the row, that its node board draws one node per asset and follows the asset
|
||
* list – created through the + menu, renamed, and taken away again by undo – and that the switch in
|
||
* the Plugins tab stops and starts it while the editor runs, as an undo step of the project.
|
||
*/
|
||
export async function plugins(t) {
|
||
const { check, sleep, json, evaluate, mutate, clickText, drag, wheel, press, shot, showTab, settle, undo } =
|
||
t;
|
||
|
||
await press('Escape');
|
||
await evaluate('__gm.state.select(null)');
|
||
|
||
// 38a. Loading runs after the project has booted, so on a fresh page it may still be under way.
|
||
let records = [];
|
||
for (let i = 0; i < 50 && records.length === 0; i++) {
|
||
records = await json('__gm.plugins.records()');
|
||
if (records.length === 0) await sleep(100);
|
||
}
|
||
const demo = records.find((r) => r.file === 'demo-plugin.js');
|
||
check(
|
||
'the demo plugin is loaded from data/plugins and active',
|
||
demo?.status === 'active' && demo.meta?.id === 'demo' && demo.meta.api === 1,
|
||
JSON.stringify(records),
|
||
);
|
||
check(
|
||
'and it runs because the project switches it on, not the server',
|
||
JSON.stringify(await json('__gm.state.project.plugins')) === JSON.stringify(['demo-plugin.js']),
|
||
JSON.stringify(await json('__gm.state.project.plugins')),
|
||
);
|
||
check(
|
||
'no plugin failed',
|
||
records.every((r) => r.status === 'active'),
|
||
JSON.stringify(records.filter((r) => r.status !== 'active')),
|
||
);
|
||
|
||
// 38b. Its tab sits after the fixed ones and ahead of the open documents (#171) – in the row or
|
||
// behind », depending on the width.
|
||
const { order } = await json('__gm.tabRow()');
|
||
const at = order.indexOf('plugin:assets');
|
||
check('the plugin tab is in the tab row', at >= 0, JSON.stringify(order));
|
||
check(
|
||
'and it comes after the fixed tabs, ahead of any document',
|
||
JSON.stringify(order.slice(0, 2)) === JSON.stringify(['scene', 'game']) &&
|
||
order.slice(at + 1).every((tab) => tab === 'list' || tab.startsWith('asset:')),
|
||
JSON.stringify(order),
|
||
);
|
||
|
||
// 38c. One node per asset, titled by its name, in the order of the asset list.
|
||
await openTab(t, 'plugin:assets');
|
||
await settle();
|
||
const names = () => json('__gm.state.project.assets.map((a) => a.name)');
|
||
const board = () => json("__gm.plugins.board('assets')");
|
||
const titles = (b) => b.nodes.map((n) => n.title);
|
||
let shown = await board();
|
||
check(
|
||
'clicking the tab opens the node board',
|
||
(await json('__gm.tab()')) === 'plugin:assets' && shown?.visible === true,
|
||
JSON.stringify({ tab: await json('__gm.tab()'), visible: shown?.visible }),
|
||
);
|
||
const expected = await names();
|
||
check(
|
||
'the board shows every asset as a node titled with its name',
|
||
JSON.stringify(titles(shown)) === JSON.stringify(expected),
|
||
JSON.stringify({ nodes: titles(shown), assets: expected }),
|
||
);
|
||
|
||
// 38d. Pan, zoom and Fit. The framing to come back to is taken from Fit itself, not from the first
|
||
// show: in a chained run step 14 opened this tab in a narrowed column, and the board framed
|
||
// its nodes for that width then.
|
||
await t.click(shown.fit.x, shown.fit.y);
|
||
await settle();
|
||
shown = await board();
|
||
const fitted = { zoom: shown.zoom, pan: shown.pan };
|
||
// Any hit inside the board: the same name is also in the asset panel and the scene picker.
|
||
const inside = (p) =>
|
||
p.x > shown.rect.x &&
|
||
p.x < shown.rect.x + shown.rect.w &&
|
||
p.y > shown.rect.y &&
|
||
p.y < shown.rect.y + shown.rect.h;
|
||
const drawn = await json(`__gm.locateAll(${JSON.stringify(expected[0])})`);
|
||
check(
|
||
'after Fit the node titles are drawn inside the board',
|
||
drawn.some(inside),
|
||
JSON.stringify({ drawn, rect: shown.rect }),
|
||
);
|
||
await shot('38-plugin-tab');
|
||
const centre = { x: shown.rect.x + shown.rect.w / 2, y: shown.rect.y + shown.rect.h - 40 };
|
||
await drag(centre.x, centre.y, centre.x + 80, centre.y - 30);
|
||
shown = await board();
|
||
check(
|
||
'dragging on the board pans it',
|
||
Math.round(shown.pan.x - fitted.pan.x) === 80 && Math.round(shown.pan.y - fitted.pan.y) === -30,
|
||
JSON.stringify({ fitted, now: shown.pan }),
|
||
);
|
||
await wheel(centre.x, centre.y, -300);
|
||
await settle();
|
||
shown = await board();
|
||
check(
|
||
'the wheel zooms it',
|
||
shown.zoom > fitted.zoom,
|
||
JSON.stringify({ fitted: fitted.zoom, now: shown.zoom }),
|
||
);
|
||
await t.click(shown.fit.x, shown.fit.y);
|
||
await settle();
|
||
shown = await board();
|
||
check(
|
||
'Fit frames the nodes as before the pan and zoom',
|
||
Math.abs(shown.zoom - fitted.zoom) < 1e-6 &&
|
||
Math.abs(shown.pan.x - fitted.pan.x) < 1e-6 &&
|
||
Math.abs(shown.pan.y - fitted.pan.y) < 1e-6,
|
||
JSON.stringify({ fitted, now: { zoom: shown.zoom, pan: shown.pan } }),
|
||
);
|
||
|
||
// 38e. The board follows the asset list: a sprite created through the + menu, while the tab
|
||
// stays open, is a node at once.
|
||
// Through the menu's own entry: in a chained run an asset named "Sprite" is in the list already.
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Sprite');
|
||
await settle();
|
||
const withNew = await names();
|
||
shown = await board();
|
||
check(
|
||
'creating an asset adds its node',
|
||
withNew.length === expected.length + 1 && JSON.stringify(titles(shown)) === JSON.stringify(withNew),
|
||
JSON.stringify({ nodes: titles(shown), assets: withNew }),
|
||
);
|
||
check('and the tab stays open', (await json('__gm.tab()')) === 'plugin:assets', await json('__gm.tab()'));
|
||
|
||
// 38f. Renaming shows under the new name.
|
||
const created = await json('(({ id, name }) => ({ id, name }))(__gm.state.project.assets.at(-1))');
|
||
const newId = created.id;
|
||
await mutate(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(newId)}).name = 'Renamed by e2e'`,
|
||
);
|
||
shown = await board();
|
||
check(
|
||
'renaming an asset renames its node',
|
||
shown.nodes.find((n) => n.id === newId)?.title === 'Renamed by e2e',
|
||
JSON.stringify(shown.nodes.at(-1)),
|
||
);
|
||
await mutate(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(newId)}).name = ${JSON.stringify(created.name)}`,
|
||
);
|
||
|
||
// 38g. Undo takes the created asset away again, and its node with it.
|
||
await undo();
|
||
await settle();
|
||
shown = await board();
|
||
check(
|
||
'deleting the asset (undo of the create) removes its node',
|
||
(await names()).length === expected.length &&
|
||
JSON.stringify(titles(shown)) === JSON.stringify(expected) &&
|
||
!shown.nodes.some((n) => n.id === newId),
|
||
JSON.stringify({ nodes: titles(shown), assets: await names() }),
|
||
);
|
||
|
||
// 38h. Another tab hides the board; the plugin's pane is a tab like any other.
|
||
await showTab('scene');
|
||
check('switching to another tab hides the board', (await board()).visible === false);
|
||
|
||
// 38i. The plugins (#114, #119): the switch belongs to the project, and it works while the
|
||
// editor runs. Since #179 they are no fixed tab but reached through the menu, in a tab that
|
||
// closes like a document.
|
||
await t.clickToolbar('menu');
|
||
const entry = await t.locate('Plugins', true);
|
||
await t.click(entry.x, entry.y);
|
||
await settle();
|
||
const pane = () => json('__gm.plugins.pane()');
|
||
let rows = await pane();
|
||
const rowOf = (shown) => shown.rows.find((r) => r.file === 'demo-plugin.js');
|
||
check(
|
||
'Menu → Plugins opens the list, with the demo plugin active, its name and its switch on',
|
||
rows.visible === true &&
|
||
rowOf(rows)?.status === 'active' &&
|
||
rowOf(rows)?.name === 'Demo plugin' &&
|
||
rowOf(rows)?.checked === true,
|
||
JSON.stringify(rows),
|
||
);
|
||
|
||
const settled = async (was) => {
|
||
// Loading a module is a fetch, so the switch is applied a moment after the click.
|
||
for (let i = 0; i < 50; i++) {
|
||
const now = await pane();
|
||
if (rowOf(now)?.status !== rowOf(was)?.status) return now;
|
||
await sleep(100);
|
||
}
|
||
return pane();
|
||
};
|
||
const toggle = async () => {
|
||
const at = await json('__gm.plugins.switchAt("demo-plugin.js")');
|
||
await t.click(at.x, at.y);
|
||
await settle();
|
||
return settled(rows);
|
||
};
|
||
|
||
rows = await toggle();
|
||
const rowAfterOff = await json('__gm.tabRow()');
|
||
check(
|
||
'clicking the switch off stops the plugin',
|
||
rowOf(rows)?.status === 'inactive' && rowOf(rows)?.checked === false,
|
||
JSON.stringify(rows),
|
||
);
|
||
check(
|
||
'and its tab is gone from the row, with nothing left of its board',
|
||
![...rowAfterOff.shown.map((p) => p.tab), ...rowAfterOff.hidden].includes('plugin:assets') &&
|
||
(await board()) === null,
|
||
JSON.stringify(rowAfterOff),
|
||
);
|
||
// The switch is the project's (#119): it stands in the project file and travels with it. The
|
||
// server's own list knows nothing about it any more – it only says which files there are.
|
||
const listed = await evaluate(
|
||
`fetch('/game-maker/api/index.php?r=plugins').then(async (r) => JSON.stringify(await r.json()))`,
|
||
);
|
||
const file = JSON.parse(listed).find((e) => e.file === 'demo-plugin.js');
|
||
check(
|
||
'the project says it is off, while the server still lists the file itself',
|
||
JSON.stringify(await json('__gm.state.project.plugins')) === '[]' &&
|
||
file !== undefined &&
|
||
file.active === undefined,
|
||
JSON.stringify({ plugins: await json('__gm.state.project.plugins'), file }),
|
||
);
|
||
|
||
// And because it is a change to the project, undo takes it back – plugin, tab and all.
|
||
await undo();
|
||
rows = await settled(rows);
|
||
const afterUndo = await json('__gm.tabRow()');
|
||
check(
|
||
'undo switches it on again, as a step of the project',
|
||
JSON.stringify(await json('__gm.state.project.plugins')) === JSON.stringify(['demo-plugin.js']) &&
|
||
rowOf(rows)?.status === 'active' &&
|
||
[...afterUndo.shown.map((p) => p.tab), ...afterUndo.hidden].includes('plugin:assets'),
|
||
JSON.stringify({ plugins: await json('__gm.state.project.plugins'), row: rowOf(rows) }),
|
||
);
|
||
rows = await toggle();
|
||
check(
|
||
'and clicking the switch off again stops it once more',
|
||
rowOf(rows)?.status === 'inactive' && JSON.stringify(await json('__gm.state.project.plugins')) === '[]',
|
||
JSON.stringify({ row: rowOf(rows), plugins: await json('__gm.state.project.plugins') }),
|
||
);
|
||
|
||
rows = await toggle();
|
||
await settle();
|
||
const rowAfterOn = await json('__gm.tabRow()');
|
||
shown = await board();
|
||
check(
|
||
'switching it on again activates it there and then',
|
||
rowOf(rows)?.status === 'active' &&
|
||
[...rowAfterOn.shown.map((p) => p.tab), ...rowAfterOn.hidden].includes('plugin:assets'),
|
||
JSON.stringify({ row: rowOf(rows), tabs: rowAfterOn }),
|
||
);
|
||
check(
|
||
'and its board is back with a node per asset',
|
||
JSON.stringify(titles(shown)) === JSON.stringify(await names()),
|
||
JSON.stringify({ nodes: titles(shown), assets: await names() }),
|
||
);
|
||
await shot('39-plugins-tab');
|
||
await evaluate("__gm.closeDocument('management')");
|
||
await settle();
|
||
check(
|
||
'the tab closes like a document and leaves the row',
|
||
!(await json('__gm.tabRow()')).order.includes('management') &&
|
||
(await json('__gm.tab()')) !== 'management',
|
||
JSON.stringify({ tab: await json('__gm.tab()'), order: (await json('__gm.tabRow()')).order }),
|
||
);
|
||
// The switch is a change to the project; a step that navigates next would otherwise meet the
|
||
// autosave's "leave the page?" and wait on it.
|
||
await t.saved();
|
||
}
|