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>
This commit is contained in:
1 parent
30fb3a2316
commit
ceaa44e5b8
38 files changed
+1803
-138
No files matched your search
@@ -21,6 +21,12 @@ game_maker_serve() {
|
||||
echo >>"$root/data/e2e-password.txt"
|
||||
fi
|
||||
php "$root/tools/make-user.php" e2e "$(cat "$root/data/e2e-password.txt")"
|
||||
# The user management step (#179) needs an administrator of its own; it owns no project.
|
||||
if [ ! -f "$root/data/e2e-admin-password.txt" ]; then
|
||||
head -c 24 /dev/urandom | base64 | tr -d '\n' >"$root/data/e2e-admin-password.txt"
|
||||
echo >>"$root/data/e2e-admin-password.txt"
|
||||
fi
|
||||
php "$root/tools/make-user.php" e2e-admin "$(cat "$root/data/e2e-admin-password.txt")" admin
|
||||
|
||||
# A server from an earlier step of the same job is fine to keep. Asked of the one route that
|
||||
# answers without a session, so "is it up" does not depend on being signed in.
|
||||
|
||||
@@ -82,15 +82,14 @@ export async function overflow(t) {
|
||||
return n;
|
||||
})()`);
|
||||
const fixedIcons = styled.shown
|
||||
.filter((b) => ['scene', 'game', 'plugins'].includes(b.tab))
|
||||
.filter((b) => ['scene', 'game'].includes(b.tab))
|
||||
.map((b) => [b.tab, b.icon]);
|
||||
check(
|
||||
'Scene, Game and Plugins show an icon before their name',
|
||||
'Scene and Game show an icon before their name',
|
||||
JSON.stringify(fixedIcons) ===
|
||||
JSON.stringify([
|
||||
['scene', true],
|
||||
['game', true],
|
||||
['plugins', true],
|
||||
]) && sceneInk >= 20,
|
||||
JSON.stringify({ fixedIcons, sceneInk }),
|
||||
);
|
||||
|
||||
+23
-10
@@ -45,7 +45,7 @@ export async function plugins(t) {
|
||||
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, 3)) === JSON.stringify(['scene', 'game', 'plugins']) &&
|
||||
JSON.stringify(order.slice(0, 2)) === JSON.stringify(['scene', 'game']) &&
|
||||
order.slice(at + 1).every((tab) => tab === 'list' || tab.startsWith('asset:')),
|
||||
JSON.stringify(order),
|
||||
);
|
||||
@@ -163,15 +163,18 @@ export async function plugins(t) {
|
||||
await showTab('scene');
|
||||
check('switching to another tab hides the board', (await board()).visible === false);
|
||||
|
||||
// 38i. The Plugins tab (#114, #119): the switch belongs to the project, and it works while the
|
||||
// editor runs.
|
||||
await openTab(t, 'plugins');
|
||||
// 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(
|
||||
'the Plugins tab lists the demo plugin as active, with its name and its switch on',
|
||||
'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' &&
|
||||
@@ -213,13 +216,13 @@ export async function plugins(t) {
|
||||
const listed = await evaluate(
|
||||
`fetch('/game-maker/api/index.php?r=plugins').then(async (r) => JSON.stringify(await r.json()))`,
|
||||
);
|
||||
const entry = JSON.parse(listed).find((e) => e.file === 'demo-plugin.js');
|
||||
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')) === '[]' &&
|
||||
entry !== undefined &&
|
||||
entry.active === undefined,
|
||||
JSON.stringify({ plugins: await json('__gm.state.project.plugins'), entry }),
|
||||
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.
|
||||
@@ -256,5 +259,15 @@ export async function plugins(t) {
|
||||
JSON.stringify({ nodes: titles(shown), assets: await names() }),
|
||||
);
|
||||
await shot('39-plugins-tab');
|
||||
await showTab('scene');
|
||||
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();
|
||||
}
|
||||
+10
-2
@@ -51,9 +51,17 @@ export async function topbar(t) {
|
||||
// that is open. Clearing the saved data is checked in 32.
|
||||
await clickToolbar('menu');
|
||||
const rows = await json('__gm.menuRows()');
|
||||
const expected = ['New project', 'Open project…', null, 'Project settings…', 'Clear saved data'];
|
||||
const expected = [
|
||||
'New project',
|
||||
'Open project…',
|
||||
null,
|
||||
'Project settings…',
|
||||
'Clear saved data',
|
||||
null,
|
||||
'Plugins',
|
||||
];
|
||||
check(
|
||||
'the menu button opens new project, the project picker, the settings and clearing saves',
|
||||
'the menu button opens new project, the project picker, the settings, clearing saves and the plugins',
|
||||
JSON.stringify(rows) === JSON.stringify(expected),
|
||||
JSON.stringify(rows),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
E2E_ADMIN,
|
||||
E2E_USER,
|
||||
e2eAdminPassword,
|
||||
e2ePassword,
|
||||
forgetSession,
|
||||
logIn,
|
||||
SESSION_COOKIE,
|
||||
} from '../lib/auth.mjs';
|
||||
|
||||
/** The accounts this step makes and takes away again; a run that died halfway leaves them for the next. */
|
||||
const TEMP = 'e2e-tmp';
|
||||
const RENAMED = 'e2e-tmp2';
|
||||
const TEMP_PASSWORD = 'temporary-pass-49';
|
||||
|
||||
/**
|
||||
* Roles and the user management (#180, #179). The suite's own account is a user: it sees the
|
||||
* plugins in the menu and nothing else, and the API tells it no. Then the browser is signed in as
|
||||
* `e2e-admin`, which manages a throwaway account through the tab – create, rename, role, refused
|
||||
* delete – and opens and edits that account's project as if it were its own.
|
||||
*
|
||||
* Runs last: it signs the browser in as somebody else and opens a project that is not the
|
||||
* fixture. The last thing it does is hand the browser back to `e2e` with the fixture open, and the
|
||||
* accounts and the project it made are gone again, also when a check on the way throws.
|
||||
*/
|
||||
export async function users(t) {
|
||||
const {
|
||||
check,
|
||||
cdp,
|
||||
json,
|
||||
evaluate,
|
||||
settle,
|
||||
click,
|
||||
doubleClick,
|
||||
press,
|
||||
typeText,
|
||||
locate,
|
||||
sleep,
|
||||
waitUntil,
|
||||
} = t;
|
||||
const { clickToolbar, clickText, shot, saved, baseUrl, projectId, navigate } = t;
|
||||
|
||||
const adminCookie = await logIn(baseUrl, E2E_ADMIN, await e2eAdminPassword());
|
||||
const call = (cookie, method, route, body) =>
|
||||
fetch(`${baseUrl}api/index.php?r=${route}`, {
|
||||
method,
|
||||
headers: { Cookie: cookie, 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
const asAdmin = (method, route, body) => call(adminCookie, method, route, body);
|
||||
// Projects first: an account that still owns one is not deleted (#179).
|
||||
const sweep = async () => {
|
||||
const accounts = await (await asAdmin('GET', 'users')).json();
|
||||
for (const account of accounts.filter((a) => a.username === TEMP || a.username === RENAMED)) {
|
||||
for (const project of account.projects) await asAdmin('DELETE', `projects/${project.id}`);
|
||||
await asAdmin('DELETE', `users/${account.username}`);
|
||||
}
|
||||
};
|
||||
const pane = () => json('__gm.users.pane()');
|
||||
const rowOf = async (username) => (await pane())?.rows.find((r) => r.username === username) ?? null;
|
||||
const pressButton = async (username, action) => {
|
||||
// A list read again is new rows, laid out on the next frame – asked before, they sit at 0,0.
|
||||
await settle();
|
||||
const at = await json(`__gm.users.buttonAt(${JSON.stringify(username)}, ${JSON.stringify(action)})`);
|
||||
await click(at.x, at.y);
|
||||
};
|
||||
/** A form of the tab, filled field by field; Enter moves on and, on the last field, sends. */
|
||||
const fillForm = async (values) => {
|
||||
for (const value of values) {
|
||||
await press('a', { ctrl: true });
|
||||
await typeText(value);
|
||||
await press('Enter');
|
||||
}
|
||||
};
|
||||
/** The last-drawn `text` – the open menu's or dialog's, over a row that says the same. */
|
||||
const clickLast = async (text) => {
|
||||
const at = await locate(text, true);
|
||||
await click(at.x, at.y);
|
||||
};
|
||||
const openManagement = async () => {
|
||||
await clickToolbar('menu');
|
||||
await clickLast('User management');
|
||||
await waitUntil('__gm.users.pane()?.rows.length > 0', { label: 'the accounts were listed' });
|
||||
};
|
||||
|
||||
await sweep();
|
||||
try {
|
||||
// 49a. The suite's own account is a user (#180): the menu offers the plugins and no user
|
||||
// management, and the API turns it away at the door – the check that the decision is not
|
||||
// the editor's.
|
||||
await press('Escape');
|
||||
await clickToolbar('menu');
|
||||
const userMenu = await json('__gm.menuRows()');
|
||||
await press('Escape');
|
||||
check(
|
||||
'a user finds the plugins in the menu, but no user management',
|
||||
userMenu.at(-1) === 'Plugins' && !userMenu.includes('User management') && (await pane()) === null,
|
||||
JSON.stringify(userMenu),
|
||||
);
|
||||
// A session of its own: step 45 changes the password of `e2e`, which ends the tools' cached one.
|
||||
const refused = await call(await logIn(baseUrl, E2E_USER, await e2ePassword()), 'GET', 'users');
|
||||
check(
|
||||
`the API answers ${E2E_USER} 403 on the accounts`,
|
||||
refused.status === 403 && (await refused.json()).error === 'Administrators only',
|
||||
String(refused.status),
|
||||
);
|
||||
|
||||
// 49b. Signed in as the administrator, the fixture – which belongs to `e2e` – opens all the
|
||||
// same, and the menu has the user management before the plugins.
|
||||
// Cleared first: the sign-in screen of step 45 left a cookie of its own, on a path more specific
|
||||
// than the one CDP gives, and the browser would send that one first.
|
||||
await cdp.send('Network.clearBrowserCookies');
|
||||
await cdp.send('Network.setCookie', {
|
||||
name: SESSION_COOKIE,
|
||||
value: adminCookie.slice(SESSION_COOKIE.length + 1),
|
||||
url: baseUrl,
|
||||
});
|
||||
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
|
||||
await sleep(4000);
|
||||
await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(projectId)}`, {
|
||||
timeout: 15000,
|
||||
label: 'the administrator has the fixture open',
|
||||
});
|
||||
const signedIn = { title: await json('document.title'), id: await json('__gm.state.project.id') };
|
||||
check(
|
||||
`${E2E_ADMIN} opens a project of ${E2E_USER} through its URL`,
|
||||
signedIn.title.endsWith(E2E_ADMIN) &&
|
||||
signedIn.id === projectId &&
|
||||
(await json('__gm.state.notice')) === '',
|
||||
JSON.stringify({ ...signedIn, notice: await json('__gm.state.notice') }),
|
||||
);
|
||||
await clickToolbar('menu');
|
||||
const adminMenu = await json('__gm.menuRows()');
|
||||
await press('Escape');
|
||||
check(
|
||||
'an administrator finds the user management before the plugins',
|
||||
JSON.stringify(adminMenu.slice(-3)) === JSON.stringify([null, 'User management', 'Plugins']),
|
||||
JSON.stringify(adminMenu),
|
||||
);
|
||||
|
||||
// 49c. The tab lists every account; on their own row an administrator may set a password
|
||||
// and nothing else.
|
||||
await openManagement();
|
||||
const shown = await pane();
|
||||
const own = shown.rows.find((r) => r.username === E2E_ADMIN);
|
||||
check(
|
||||
'User management opens its tab with every account, the plugins below',
|
||||
(await json('__gm.tab()')) === 'management' &&
|
||||
shown.visible === true &&
|
||||
[E2E_USER, E2E_ADMIN].every((name) => shown.rows.some((r) => r.username === name)) &&
|
||||
shown.rows.find((r) => r.username === E2E_USER)?.projects.length > 0 &&
|
||||
(await json('__gm.plugins.pane()')).rows.length > 0,
|
||||
JSON.stringify(shown),
|
||||
);
|
||||
check(
|
||||
'the own row offers a new password only',
|
||||
JSON.stringify(own?.enabled) ===
|
||||
JSON.stringify({ rename: false, role: false, password: true, delete: false }),
|
||||
JSON.stringify(own),
|
||||
);
|
||||
// Every row's last button ends inside the list: in a tab the width of the editor's centre, the
|
||||
// columns once pushed Delete under the edge, where no click reached it.
|
||||
const listRect = await json('__gm.users.rect()');
|
||||
const ends = [];
|
||||
for (const row of shown.rows)
|
||||
ends.push((await json(`__gm.users.buttonAt(${JSON.stringify(row.username)}, 'delete')`)).right);
|
||||
check(
|
||||
'every row fits into the list, its Delete button included',
|
||||
ends.every((right) => right <= listRect.x + listRect.w),
|
||||
JSON.stringify({ ends, listRight: listRect.x + listRect.w }),
|
||||
);
|
||||
await shot('49-user-management');
|
||||
|
||||
// 49d. A new account through the form. A short password is said before anything is sent; a
|
||||
// name that is taken comes back from the server, in its words.
|
||||
await clickText('New user…');
|
||||
await fillForm([TEMP, 'short', 'short']);
|
||||
check(
|
||||
'a short password is refused in the form, which stays open',
|
||||
(await json('__gm.users.dialog()'))?.message === 'The password needs at least 8 characters',
|
||||
JSON.stringify(await json('__gm.users.dialog()')),
|
||||
);
|
||||
await press('Escape');
|
||||
await clickText('New user…');
|
||||
await fillForm([E2E_USER, TEMP_PASSWORD, TEMP_PASSWORD]);
|
||||
await waitUntil('__gm.users.dialog()?.busy === false && __gm.users.dialog()?.message !== ""', {
|
||||
label: 'the server answered',
|
||||
});
|
||||
check(
|
||||
'a taken name comes back from the server and is shown as it says it',
|
||||
(await json('__gm.users.dialog()'))?.message === `The name ${E2E_USER} is taken`,
|
||||
JSON.stringify(await json('__gm.users.dialog()')),
|
||||
);
|
||||
await press('Escape');
|
||||
await clickText('New user…');
|
||||
await fillForm([TEMP, TEMP_PASSWORD, TEMP_PASSWORD]);
|
||||
await waitUntil(`__gm.users.pane()?.rows.some((r) => r.username === ${JSON.stringify(TEMP)})`, {
|
||||
label: 'the new account is listed',
|
||||
});
|
||||
check(
|
||||
'Create closes the form and lists the account as a user without projects',
|
||||
(await json('__gm.users.dialog()')) === null &&
|
||||
JSON.stringify(await rowOf(TEMP)) ===
|
||||
JSON.stringify({
|
||||
username: TEMP,
|
||||
role: 'user',
|
||||
projects: [],
|
||||
enabled: { rename: true, role: true, password: true, delete: true },
|
||||
}),
|
||||
JSON.stringify(await rowOf(TEMP)),
|
||||
);
|
||||
|
||||
// The new account signs in and makes a project of its own, the way its owner would.
|
||||
const tempCookie = await logIn(baseUrl, TEMP, TEMP_PASSWORD);
|
||||
const made = await (await call(tempCookie, 'POST', 'projects', { name: 'Temp game' })).json();
|
||||
await evaluate('__gm.users.reload()');
|
||||
|
||||
// 49e. Renaming takes the project along and signs the account out.
|
||||
await pressButton(TEMP, 'rename');
|
||||
await fillForm([RENAMED]);
|
||||
await waitUntil(`__gm.users.pane()?.rows.some((r) => r.username === ${JSON.stringify(RENAMED)})`, {
|
||||
label: 'the renamed account is listed',
|
||||
});
|
||||
const session = await (await call(tempCookie, 'GET', 'auth/session')).json();
|
||||
check(
|
||||
'Rename moves the account and its project to the new name',
|
||||
(await rowOf(TEMP)) === null && JSON.stringify((await rowOf(RENAMED))?.projects) === '["Temp game"]',
|
||||
JSON.stringify(await pane()),
|
||||
);
|
||||
check('and the session under the old name is over', session.user === null, JSON.stringify(session));
|
||||
|
||||
// 49f. The role, picked from the row's menu.
|
||||
await pressButton(RENAMED, 'role');
|
||||
await clickLast('admin');
|
||||
await waitUntil(
|
||||
`__gm.users.pane()?.rows.find((r) => r.username === ${JSON.stringify(RENAMED)})?.role === 'admin'`,
|
||||
{
|
||||
label: 'the role changed',
|
||||
},
|
||||
);
|
||||
check('Role → admin makes the account an administrator', (await rowOf(RENAMED))?.role === 'admin');
|
||||
|
||||
// 49g. An account that still owns a project is not deleted.
|
||||
await pressButton(RENAMED, 'delete');
|
||||
await clickLast('Delete');
|
||||
await waitUntil('__gm.state.notice !== ""', { label: 'the refusal was said' });
|
||||
check(
|
||||
'Delete is refused while the account owns a project, and says why',
|
||||
(await json('__gm.state.notice')) === `${RENAMED} still owns 1 project` &&
|
||||
(await rowOf(RENAMED)) !== null,
|
||||
JSON.stringify({ notice: await json('__gm.state.notice'), row: await rowOf(RENAMED) }),
|
||||
);
|
||||
|
||||
// 49h. The administrator opens that project from the list and edits it like their own; the
|
||||
// save keeps its owner.
|
||||
await pressButton(RENAMED, `open:${made.id}`);
|
||||
await waitUntil(`__gm.state.project.id === ${JSON.stringify(made.id)}`, { label: 'the project opened' });
|
||||
await clickToolbar('menu');
|
||||
await clickText('Project settings…');
|
||||
const nameField = await locate('Temp game');
|
||||
await doubleClick(nameField.x, nameField.y);
|
||||
await press('a', { ctrl: true });
|
||||
await typeText('Edited by admin');
|
||||
await press('Enter');
|
||||
await clickText('Apply');
|
||||
await saved();
|
||||
const stored = await (await asAdmin('GET', `projects/${made.id}`)).json();
|
||||
check(
|
||||
'Open loads somebody else’s project, and an edit to it is saved under its owner',
|
||||
stored.name === 'Edited by admin' && stored.owner === RENAMED,
|
||||
JSON.stringify({ name: stored.name, owner: stored.owner }),
|
||||
);
|
||||
|
||||
// 49i. With the project gone, the account goes too.
|
||||
await asAdmin('DELETE', `projects/${made.id}`);
|
||||
await openManagement();
|
||||
await pressButton(RENAMED, 'delete');
|
||||
await clickLast('Delete');
|
||||
await waitUntil(`!__gm.users.pane()?.rows.some((r) => r.username === ${JSON.stringify(RENAMED)})`, {
|
||||
label: 'the account is gone',
|
||||
});
|
||||
check('Delete removes an account without projects', (await rowOf(RENAMED)) === null);
|
||||
await settle();
|
||||
} finally {
|
||||
await sweep();
|
||||
await cdp.send('Network.clearBrowserCookies');
|
||||
// Step 45 changed the password of `e2e`, which ended the session `navigate` would hand over.
|
||||
forgetSession(baseUrl);
|
||||
await navigate();
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import { login } from './45-login.mjs';
|
||||
import { topbar } from './46-topbar.mjs';
|
||||
import { vectorArt } from './47-vectorart.mjs';
|
||||
import { pixelArt } from './48-pixelart.mjs';
|
||||
import { users } from './49-users.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -101,4 +102,5 @@ export const STEPS = [
|
||||
topbar,
|
||||
vectorArt,
|
||||
pixelArt,
|
||||
users,
|
||||
];
|
||||
@@ -122,7 +122,6 @@ export const TAB_LABELS = {
|
||||
container: 'Container',
|
||||
list: 'List',
|
||||
particles: 'Particles',
|
||||
plugins: 'Plugins',
|
||||
// Added at start-up by the demo plugin (plugins/demo-plugin.js), after every built-in tab.
|
||||
'plugin:assets': 'Assets',
|
||||
};
|
||||
@@ -170,6 +169,7 @@ const DOCUMENT_KINDS = [
|
||||
'tile',
|
||||
'container',
|
||||
'list',
|
||||
'management',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+33
-8
@@ -10,8 +10,12 @@ import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const E2E_USER = 'e2e';
|
||||
/** The administrator the user management step signs in as (#179); it owns nothing. */
|
||||
export const E2E_ADMIN = 'e2e-admin';
|
||||
|
||||
const PASSWORD_FILE = resolve(dirname(fileURLToPath(import.meta.url)), '../../data/e2e-password.txt');
|
||||
const DATA = resolve(dirname(fileURLToPath(import.meta.url)), '../../data');
|
||||
const PASSWORD_FILE = resolve(DATA, 'e2e-password.txt');
|
||||
const ADMIN_PASSWORD_FILE = resolve(DATA, 'e2e-admin-password.txt');
|
||||
const COOKIE = 'game_maker_session';
|
||||
|
||||
/** One session per server for the whole process; logging in per request would be pointless load. */
|
||||
@@ -30,24 +34,45 @@ export async function e2ePassword() {
|
||||
}
|
||||
}
|
||||
|
||||
/** `game_maker_session=…` for `Cookie` headers, logging in on first use. */
|
||||
export async function sessionCookie(baseUrl) {
|
||||
const cached = cookies.get(baseUrl);
|
||||
if (cached) return cached;
|
||||
/** The password of `e2e-admin`, from `GAME_MAKER_E2E_ADMIN_PASSWORD` or `data/e2e-admin-password.txt`. */
|
||||
export async function e2eAdminPassword() {
|
||||
const fromEnv = process.env.GAME_MAKER_E2E_ADMIN_PASSWORD;
|
||||
if (fromEnv) return fromEnv;
|
||||
try {
|
||||
return (await readFile(ADMIN_PASSWORD_FILE, 'utf8')).trim();
|
||||
} catch {
|
||||
throw new Error(
|
||||
`no password for the "${E2E_ADMIN}" account: set GAME_MAKER_E2E_ADMIN_PASSWORD or put it in ${ADMIN_PASSWORD_FILE}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs in as anyone and answers with a fresh `game_maker_session=…`, uncached – for a step that
|
||||
* needs a second account beside the tools' own.
|
||||
*/
|
||||
export async function logIn(baseUrl, username, password) {
|
||||
const res = await fetch(`${baseUrl}api/index.php?r=auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: E2E_USER, password: await e2ePassword() }),
|
||||
body: JSON.stringify({ username, password }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`could not sign in as "${E2E_USER}" at ${baseUrl}: HTTP ${res.status}`);
|
||||
throw new Error(`could not sign in as "${username}" at ${baseUrl}: HTTP ${res.status}`);
|
||||
}
|
||||
// `getSetCookie` keeps the headers apart; a joined string cannot be split on commas, because a
|
||||
// cookie's own Expires date carries one.
|
||||
const set = res.headers.getSetCookie().find((c) => c.startsWith(`${COOKIE}=`));
|
||||
if (!set) throw new Error(`signing in at ${baseUrl} returned no ${COOKIE} cookie`);
|
||||
const cookie = set.split(';')[0];
|
||||
return set.split(';')[0];
|
||||
}
|
||||
|
||||
/** `game_maker_session=…` for `Cookie` headers, logging in on first use. */
|
||||
export async function sessionCookie(baseUrl) {
|
||||
const cached = cookies.get(baseUrl);
|
||||
if (cached) return cached;
|
||||
const cookie = await logIn(baseUrl, E2E_USER, await e2ePassword());
|
||||
cookies.set(baseUrl, cookie);
|
||||
return cookie;
|
||||
}
|
||||
|
||||
+16
-9
@@ -2,10 +2,12 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Creates an account or sets the password of one that exists. The API has no route for it:
|
||||
* signing up is a follow-up issue, so until then accounts are made here, on the server.
|
||||
* Creates an account or sets the password of one that exists – the way to the first administrator,
|
||||
* who can then manage the others in the editor (#179).
|
||||
*
|
||||
* Usage: php tools/make-user.php <username> <password>
|
||||
* Without a role a new account is a `user` and an existing one keeps its role (#180).
|
||||
*
|
||||
* Usage: php tools/make-user.php <username> <password> [admin|user]
|
||||
* php tools/make-user.php --list
|
||||
*/
|
||||
|
||||
@@ -16,21 +18,26 @@ $store = new UserStore($file);
|
||||
|
||||
$args = array_slice($argv, 1);
|
||||
if (($args[0] ?? '') === '--list') {
|
||||
foreach ($store->names() as $name) {
|
||||
echo $name, "\n";
|
||||
foreach ($store->accounts() as $account) {
|
||||
echo $account['username'], ' ', $account['role'], "\n";
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
if (count($args) !== 2 || $args[0] === '' || $args[1] === '') {
|
||||
fwrite(STDERR, "Usage: php tools/make-user.php <username> <password>\n");
|
||||
if (count($args) < 2 || count($args) > 3 || $args[0] === '' || $args[1] === '') {
|
||||
fwrite(STDERR, "Usage: php tools/make-user.php <username> <password> [admin|user]\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
[$username, $password] = $args;
|
||||
$role = $args[2] ?? null;
|
||||
if (!UserStore::isValidName($username)) {
|
||||
fwrite(STDERR, "Invalid user name: {$username}\n");
|
||||
exit(2);
|
||||
}
|
||||
if ($role !== null && !UserStore::isValidRole($role)) {
|
||||
fwrite(STDERR, "Unknown role: {$role} (admin or user)\n");
|
||||
exit(2);
|
||||
}
|
||||
$existed = $store->exists($username);
|
||||
$store->put($username, $password);
|
||||
echo ($existed ? 'password set for ' : 'created '), $username, "\n";
|
||||
$store->put($username, $password, $role);
|
||||
echo ($existed ? 'password set for ' : 'created '), $username, ' (', $store->role($username), ")\n";
|
||||
Reference in new issue
Block a user