Add roles and a user management tab for admins (#180, #179)
game-maker / gate (push) Successful in 2m50s
game-maker / suite (push) Successful in 8m57s

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:
adminandClaude Opus 5.5 committed 2026-09-23 13:59:04 +02:00
1 parent 30fb3a2316
commit ceaa44e5b8
38 files changed
+1803 -138

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
import type { Role } from '../../core/store/AuthStore';
import { Button, Dropdown, Label, Menu, TextField, ui } from '../../ui';
import { FormStatus, refocus } from '../auth/FormStatus';
import { dialogButtons, FormDialog } from '../FormDialog';
const FIELD_WIDTH = 200;
export interface AccountField {
label: string;
value?: string;
secret?: boolean;
}
/** What a form hands to its `send`: the fields in order, and the role when the form has one. */
export interface AccountForm {
values: string[];
role: Role;
}
export interface AccountDialogOptions {
title: string;
note?: string;
fields: AccountField[];
/** A role picker below the fields, starting on this role; none when left out. */
role?: Role;
confirm: string;
/** A problem to show instead of sending, and the index of the field it is about. */
check: (form: AccountForm) => { message: string; field: number } | null;
/** Rejects with the server's sentence when it says no; the dialog stays and shows it. */
send: (form: AccountForm) => Promise<void>;
onDone: () => void;
}
/**
* The forms of the user management (#179) – a new account, a new name, a new password. One class,
* because they differ only in their fields. Like the password dialog it stays open when the server
* refuses, so the refusal can be read next to what caused it.
*/
export class AccountDialog extends FormDialog {
readonly fields: TextField[];
private role: Role;
private readonly rolePicker: Dropdown | null = null;
private readonly confirm: Button;
private readonly status = new FormStatus((busy) => {
this.confirm.setEnabled(!busy);
this.refreshLayout();
});
constructor(private readonly options: AccountDialogOptions) {
super(360, options.title);
if (options.note) this.content.add(new Label(options.note, { style: 'muted' }));
this.fields = options.fields.map(
(field, i) =>
new TextField(field.value ?? '', {
width: FIELD_WIDTH,
secret: field.secret,
// Enter moves on, and on the last field sends – a keyboard alone gets through the form.
onSubmit: () => {
const next = this.fields[i + 1];
if (next) ui().focus.focus(next);
else void this.submit();
},
}),
);
options.fields.forEach((field, i) =>
this.content.add(this.formRow(this.fieldLabel(field.label), this.fields[i]!)),
);
this.role = options.role ?? 'user';
if (options.role) {
const picker = new Dropdown(this.role, { width: FIELD_WIDTH, onClick: () => this.pickRole(picker) });
this.rolePicker = picker;
this.content.add(this.formRow(this.fieldLabel('Role'), picker));
}
this.content.add(this.status.message);
this.confirm = new Button(options.confirm, { variant: 'primary', onClick: () => void this.submit() });
this.content.add(dialogButtons(this.closeThen(), this.confirm));
}
focusFirstField(): void {
const first = this.fields[0];
if (first) ui().focus.focus(first);
}
/** What the check scripts read. */
info(): { title: string; message: string; busy: boolean; role: Role | null } {
return { title: this.options.title, ...this.status.info(), role: this.rolePicker ? this.role : null };
}
private pickRole(anchor: Dropdown): void {
new Menu(
(['user', 'admin'] as const).map((role) => ({
label: role,
current: role === this.role,
onSelect: () => {
this.role = role;
anchor.setText(role);
},
})),
).openBelow(anchor);
}
private async submit(): Promise<void> {
if (this.status.busy) return;
const form = { values: this.fields.map((f) => f.value), role: this.role };
const problem = this.options.check(form);
if (problem) {
this.status.say(problem.message);
const field = this.fields[problem.field];
if (field) refocus(field);
return;
}
this.status.setBusy(true);
try {
await this.options.send(form);
this.close();
this.options.onDone();
} catch (error) {
this.status.say(error instanceof Error ? error.message : String(error));
this.status.setBusy(false);
const first = this.fields[0];
if (first) refocus(first);
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import { clamp, Splitter, VStack } from '../../ui';
import type { PluginsPane } from '../plugins/PluginsPane';
import type { UsersPane } from './UsersPane';
const MIN_SHARE = 0.15;
/**
* The tab behind "User management" and "Plugins" in the top bar's menu (#179): the accounts above,
* for administrators, and the plugins below. It is closed like a document, so it does not take a
* place in the tab row for good. An account that is no administrator sees the plugins alone.
*/
export class ManagementPane extends VStack {
private usersShare = 0.6;
constructor(
readonly users: UsersPane | null,
plugins: PluginsPane,
) {
super();
if (!users) {
this.add(plugins, 1);
return;
}
const splitter = new Splitter('y');
this.add(users, this.usersShare);
this.add(splitter);
this.add(plugins, 1 - this.usersShare);
splitter.onDrag = (delta) => {
const room = this.h - Splitter.THICKNESS;
if (room <= 0) return;
this.usersShare = clamp(this.usersShare + delta / room, MIN_SHARE, 1 - MIN_SHARE);
users.flex = this.usersShare;
plugins.flex = 1 - this.usersShare;
this.relayout();
};
}
}
+289
View File
@@ -0,0 +1,289 @@
import type { Role } from '../../core/store/AuthStore';
import type { Account, UsersStore } from '../../core/store/UsersStore';
import {
Button,
Dialog,
Dropdown,
HStack,
Label,
Menu,
ScrollContainer,
Spacer,
Theme,
VStack,
} from '../../ui';
import { dialogSlot } from '../FormDialog';
import { TabHeader } from '../TabHeader';
import { AccountDialog } from './AccountDialog';
import { accountActions, nameProblem, newPasswordProblem } from './accountRules';
const ROLE_WIDTH = 56;
const DATE_WIDTH = 84;
const COUNT_WIDTH = 76;
const PROJECT_INDENT = 24;
/** The row actions a check script can press, by name. */
export type AccountAction = 'rename' | 'role' | 'password' | 'delete';
/** What the pane needs from the editor around it. */
export interface UsersPaneHost {
/** Who is signed in: their own row offers nothing but a new password. */
self: string;
openProject(id: string): void;
notice(message: string, level?: 'error'): void;
}
class AccountRow extends VStack {
readonly buttons: Record<AccountAction, Button>;
readonly openButtons = new Map<string, Button>();
constructor(
readonly account: Account,
self: string,
act: (action: AccountAction, anchor: Button) => void,
openProject: (id: string) => void,
) {
super({ padding: Theme.padding, gap: 2 });
const line = new HStack({ gap: Theme.gap, align: 'center' });
line.minHeight = Theme.rowHeight;
const own = account.username.toLowerCase() === self.toLowerCase();
// The name takes what the fixed columns and the buttons leave, and is cut with "…" in a
// narrow window: the buttons at the end are what must stay reachable.
line.add(new Label(own ? `${account.username} (you)` : account.username, { style: 'bodyBold' }), 1);
line.add(new Label(account.role, { width: ROLE_WIDTH, style: 'muted' }));
line.add(new Label(account.createdAt.slice(0, 10) || '—', { width: DATE_WIDTH, style: 'muted' }));
const count = account.projects.length;
line.add(
new Label(`${count} ${count === 1 ? 'project' : 'projects'}`, { width: COUNT_WIDTH, style: 'muted' }),
);
const allowed = accountActions(self, account.username);
const button = (action: AccountAction, text: string, enabled: boolean, danger = false): Button => {
const made: Button =
action === 'role'
? new Dropdown('Role', { onClick: () => act(action, made) })
: new Button(text, { variant: danger ? 'danger' : 'default', onClick: () => act(action, made) });
made.setEnabled(enabled);
line.add(made);
return made;
};
this.buttons = {
rename: button('rename', 'Rename…', allowed.rename),
role: button('role', 'Role', allowed.role),
password: button('password', 'Password…', true),
delete: button('delete', 'Delete', allowed.remove, true),
};
this.add(line);
// Every project is opened like one of the administrator's own: fully editable, saved in place,
// still belonging to the account it is listed under.
for (const project of account.projects) {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(new Spacer(PROJECT_INDENT));
row.add(new Label(project.name), 1);
row.add(new Label(project.updatedAt.slice(0, 10), { width: DATE_WIDTH, style: 'muted' }));
const open = new Button('Open', { variant: 'ghost', onClick: () => openProject(project.id) });
row.add(open);
this.openButtons.set(project.id, open);
this.add(row);
}
}
}
/**
* The accounts, for administrators (#179): one row each with name, role, creation date and the
* number of projects, the projects listed under it with Open, and the actions on the right.
* Everything goes to the server, which decides; the list is read again after every change.
*/
export class UsersPane extends VStack {
private readonly rows = new VStack();
private readonly scroll = new ScrollContainer();
private readonly empty = new Label('Loading…', { style: 'muted', padding: Theme.padding });
private readonly dialog = dialogSlot<AccountDialog>();
constructor(
private readonly store: UsersStore,
private readonly host: UsersPaneHost,
) {
super();
const header = new TabHeader();
header.add(new Label('Users', { style: 'bodyBold' }));
header.add(new Spacer(), 1);
header.add(new Button('New user…', { onClick: () => this.createAccount() }));
this.add(header);
this.rows.add(this.empty);
this.scroll.setContent(this.rows);
this.add(this.scroll, 1);
}
/** Reads the accounts again; the pane shows what the server says, not what it asked for. */
async reload(): Promise<void> {
try {
this.show(await this.store.list());
} catch (error) {
this.show([]);
this.empty.setText(
`Could not load the accounts: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/** The open form, for the check scripts. */
currentDialog(): AccountDialog | null {
return this.dialog.current();
}
/** The list as drawn, for the check scripts. */
debugInfo(): {
rows: { username: string; role: Role; projects: string[]; enabled: Record<AccountAction, boolean> }[];
} {
return {
rows: this.rowWidgets().map((row) => ({
username: row.account.username,
role: row.account.role,
projects: row.account.projects.map((p) => p.name),
enabled: {
rename: row.buttons.rename.enabled,
role: row.buttons.role.enabled,
password: row.buttons.password.enabled,
delete: row.buttons.delete.enabled,
},
})),
};
}
/**
* A row's button, for the check scripts: an action by name, or `open:<project id>`. The list is
* scrolled until the button's line is in view – a click on one scrolled away lands on whatever is
* drawn there instead.
*/
buttonOf(username: string, action: AccountAction | `open:${string}`): Button | null {
const row = this.rowWidgets().find((r) => r.account.username === username);
if (!row) return null;
const button = action.startsWith('open:')
? row.openButtons.get(action.slice('open:'.length))
: row.buttons[action as AccountAction];
const line = button?.parent;
if (!button || !(line instanceof HStack)) return button ?? null;
this.scroll.ensureVisible(line.getGlobalPosition().y - this.rows.getGlobalPosition().y, line.h);
return button;
}
private rowWidgets(): AccountRow[] {
return this.rows.children.filter((child): child is AccountRow => child instanceof AccountRow);
}
private show(accounts: Account[]): void {
for (const row of this.rowWidgets()) row.destroy({ children: true });
for (const account of accounts)
this.rows.add(
new AccountRow(
account,
this.host.self,
(action, anchor) => this.act(account, action, anchor),
(id) => this.host.openProject(id),
),
);
this.empty.visible = accounts.length === 0;
this.scroll.relayoutContent();
this.relayout();
}
private act(account: Account, action: AccountAction, anchor: Button): void {
if (action === 'rename') this.renameAccount(account);
else if (action === 'role') this.pickRole(account, anchor);
else if (action === 'password') this.resetPassword(account);
else this.confirmDelete(account);
}
private open(dialog: AccountDialog): void {
this.dialog.open(dialog).focusFirstField();
}
private createAccount(): void {
this.open(
new AccountDialog({
title: 'New user',
fields: [{ label: 'Name' }, { label: 'Password', secret: true }, { label: 'Repeat', secret: true }],
role: 'user',
confirm: 'Create',
check: ({ values: [name = '', password = '', repeat = ''] }) => {
const badName = nameProblem(name);
if (badName) return { message: badName, field: 0 };
const badPassword = newPasswordProblem(password, repeat);
return badPassword ? { message: badPassword, field: password === '' ? 1 : 2 } : null;
},
send: ({ values: [name = '', password = ''], role }) => this.store.create(name, password, role),
onDone: () => void this.reload(),
}),
);
}
private renameAccount(account: Account): void {
this.open(
new AccountDialog({
title: `Rename ${account.username}`,
note: 'Their projects move with the name. They are signed out everywhere.',
fields: [{ label: 'Name', value: account.username }],
confirm: 'Rename',
check: ({ values: [name = ''] }) => {
const problem = nameProblem(name);
return problem ? { message: problem, field: 0 } : null;
},
send: ({ values: [name = ''] }) => this.store.update(account.username, { username: name }),
onDone: () => void this.reload(),
}),
);
}
private resetPassword(account: Account): void {
const own = account.username.toLowerCase() === this.host.self.toLowerCase();
this.open(
new AccountDialog({
title: `New password for ${account.username}`,
note: own ? 'Your other sessions are signed out.' : 'Their open sessions are signed out.',
fields: [
{ label: 'Password', secret: true },
{ label: 'Repeat', secret: true },
],
confirm: 'Set password',
check: ({ values: [password = '', repeat = ''] }) => {
const problem = newPasswordProblem(password, repeat);
return problem ? { message: problem, field: password === '' ? 0 : 1 } : null;
},
send: ({ values: [password = ''] }) => this.store.update(account.username, { password }),
onDone: () => this.host.notice(`Password set for ${account.username}`),
}),
);
}
private pickRole(account: Account, anchor: Button): void {
new Menu(
(['user', 'admin'] as const).map((role) => ({
label: role,
current: role === account.role,
onSelect: () => {
if (role !== account.role) void this.run(this.store.update(account.username, { role }));
},
})),
).openBelow(anchor);
}
private confirmDelete(account: Account): void {
new Dialog(`Delete user "${account.username}"?`, 'The account is removed and signed out everywhere.', [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => void this.run(this.store.remove(account.username)) },
]).openCentered();
}
/** A change without a form of its own: a refusal goes to the status line, the list is read again either way. */
private async run(work: Promise<void>): Promise<void> {
try {
await work;
} catch (error) {
this.host.notice(error instanceof Error ? error.message : String(error), 'error');
}
await this.reload();
}
}
+36
View File
@@ -0,0 +1,36 @@
import { MIN_PASSWORD_LENGTH } from '../auth/loginRules';
/**
* What the user management's forms may complain about before anything is sent (#179). The server
* checks the same and more – a taken name, the last administrator – and its answer is what counts;
* this is so an obvious slip is said without a round trip.
*/
/** `UserStore::isValidName` in the API: 2 to 32 characters, a letter or digit first. */
const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{1,31}$/i;
export function nameProblem(name: string): string | null {
if (name.trim() === '') return 'Enter a user name';
if (!NAME_PATTERN.test(name)) return 'A user name is 2–32 letters, digits, dots, dashes or underscores';
return null;
}
export function newPasswordProblem(password: string, repeat: string): string | null {
if (password === '') return 'Enter a password';
if (password.length < MIN_PASSWORD_LENGTH)
return `The password needs at least ${MIN_PASSWORD_LENGTH} characters`;
if (repeat !== password) return 'The two passwords are different';
return null;
}
/**
* What an administrator may do to a row of the list. Their own name, role and account are off
* limits – the server refuses them too, the buttons are just not offered.
*/
export function accountActions(
self: string,
username: string,
): { rename: boolean; role: boolean; remove: boolean } {
const own = self.toLowerCase() === username.toLowerCase();
return { rename: !own, role: !own, remove: !own };
}