Describe roles and the user management tab (#180, #179)

- Accounts: roles, the User management tab, the account table and the
  role argument of make-user.php.
- API: the users routes and how admins reach other users' projects.
- Concepts, Plugins, Architecture: the shared closable tab.
- Development: check sum 1333.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
adminandClaude Opus 5.5 committed 2026-09-23 13:59:23 +02:00
1 parent 314c09ef50
commit 3af598d40e
6 files changed
+113 -36

No files matched your search

+9 -2
@@ -2,8 +2,8 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| Method | Route | Body → Result |
|---|---|---|
| GET | `auth/session` | `{user}` — the name of whoever is signed in, `null` when nobody is. Always 200, and the only route that needs no session |
| POST | `auth/login` | `{username,password}` → `{user}` and the session cookie; **401** `{error}` for a wrong pair |
| GET | `auth/session` | `{user, role}` — the name and role (`admin` or `user`) of whoever is signed in, both `null` when nobody is. Always 200, and the only route that needs no session |
| POST | `auth/login` | `{username,password}` → `{user, role}` and the session cookie; **401** `{error}` for a wrong pair |
| POST | `auth/logout` | 204, and the cookie is cleared |
| POST | `auth/password` | `{currentPassword,newPassword}` → 204. Changes the password of whoever is signed in — the only `auth` route that needs a session. **401** for a wrong current password, **422** when the new one is shorter than 8 characters. Every other session of the account ends; the one asking keeps its own |
| GET | `projects` | `[{id,name,revision,updatedAt}]` |
@@ -23,10 +23,17 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| POST | `pixels` | **raw cel bytes** → `{file,bytes}` (201) — the pixels of one layer on one frame of [pixel art](Pixel-Art); **422** for anything that is not a cel. No listing and no delete: a cel is found by the name in the project |
| GET | `plugins` | `[{file,bytes,updatedAt}]` — every `.js` in `data/plugins/`, sorted by file name |
| GET | `plugins/{file}` | the module source as `text/javascript` (`nosniff`, `no-cache`); 404 for anything but a bare file name there — see [Plugins](Plugins) |
| GET | `users` | `[{username,role,createdAt,projects:[{id,name,revision,updatedAt}]}]` — every account with its projects, never a password. Administrators only |
| POST | `users` | `{username,password,role}` → `{username,role,createdAt}` (201). **409** for a taken name, **422** for a bad name, a password under 8 characters or an unknown role. Administrators only |
| PATCH | `users/{name}` | `{username?,role?,password?}` → `{username,role,createdAt}`. Only what is sent changes, and everything is checked before anything is written. A new name moves the account's projects and signs it out everywhere; a new password ends its sessions (the caller's own stays). **404** unknown account, **409** taken name, **422** for the caller's own name or role and for demoting the last administrator. Administrators only |
| DELETE | `users/{name}` | 204, and the account is signed out everywhere. **409** while it owns projects, **422** for the caller's own account and the last administrator. Administrators only |
**Everything but `auth/*` needs a session** and answers **401** `{"error":"Sign in first"}` without
one. Projects belong to an account: `projects` lists only the caller's own, and somebody else's
project answers **404**, not 403 — that an id exists is already more than a stranger should learn.
An **administrator** (#180, #179) reads, saves and deletes every project with the same routes, and
a save keeps the project's `owner`; their `projects` list stays their own. The `users` routes
answer anybody but an administrator **403** `{"error":"Administrators only"}`.
Images, fonts, sounds, cels and plugins are shared between accounts; being signed in is still the price
of admission. See [Accounts](Accounts).
+73 -18
@@ -45,37 +45,92 @@ without asking for the password again, and so does closing the browser overnight
PHP's own session handler would not do that: it throws sessions away after
`session.gc_maxlifetime`, 24 minutes by default.
## Roles
Every account has one of two roles (#180): **admin** or **user**. An account without a role in
`data/users.json` is a user — the weaker reading is the one a missing field gets, so a file
written by hand never hands out rights by accident. The same goes for a role the server does not
know.
What an administrator may do on top is decided **on the server**: every such route asks
`Auth::requireAdmin()` and answers anybody else **403**. The editor only leaves out what it may
not offer; it shows the role nowhere else, there is no badge and no word in the title.
An administrator
- manages the accounts in the **User management** tab (below), and
- opens, edits and deletes **every** project as if it were their own (#179). A save keeps the
project's owner: an administrator who fixes somebody's project does not take it over. Their own
project list (*Open project…*) still shows only their own; the others are reached through the
user management.
## User management
*Menu → User management* (administrators only) opens a tab with every account and, below it, the
[plugins](Plugins). *Menu → Plugins* opens the same tab — for a user it holds the plugins alone and
is called **Plugins**. It closes like a document and is not part of the fixed tabs.
Each account is one row: name, role, creation date (empty for the accounts from before #179) and
how many projects it owns, the projects listed under it with **Open**. On the right:
| Button | What it does |
|---|---|
| **Rename…** | A new name. The account's projects move with it — the name is their `owner` — and the account is signed out everywhere, since its sessions carry the old name |
| **Role** | `user` or `admin`, picked from a menu |
| **Password…** | Sets a new password without the old one. The account's sessions end; on the administrator's own row their own session stays |
| **Delete** | Asks first, then removes the account and signs it out. **Refused while it owns projects** — they would belong to nobody and vanish from every list; delete them first |
| **New user…** | In the tab's header: name, password (twice) and role |
On their **own row** an administrator may only set a password: renaming themselves would end the
session they are working in, and demoting or deleting themselves would lock them out of the page
they are on. The server refuses the same, and it refuses to demote or delete the **last**
administrator.
A refused change comes back in the server's words: in the open form (*The name e2e is taken*),
or on the status line for Role and Delete (*anna still owns 2 projects*).
## The accounts
There are three, and for now they are made on the server, not through the editor:
| Account | What it holds |
|---|---|
| `admin` | no projects; the account that will get the admin role |
| `e2e` | the four projects the check tools write: fixture, Jump and Run, Tetris, Actor demo |
| `user` | every other project |
| Account | Role | What it holds |
|---|---|---|
| `admin` | admin | no projects |
| `e2e` | user | the four projects the check tools write: fixture, Jump and Run, Tetris, Actor demo |
| `e2e-admin` | admin | nothing; the account the user management step signs in as |
| `user` | user | every other project |
A project file carries its owner (`owner` in `data/projects/{id}.json`). A file without one
belongs to nobody and is invisible to everybody — the safe way round, and the one that shows up
straight away when a new project file is put there by hand without an owner.
belongs to nobody and shows up in no list, not even the user management's — the safe way round,
and the one that shows up straight away when a new project file is put there by hand without an
owner. Only an administrator who knows its id can still open it (`?project=<id>`).
## Making an account
## Making an account on the server
php tools/make-user.php <username> <password> # creates it, or sets the password
php tools/make-user.php --list
php tools/make-user.php <username> <password> [admin|user] # creates it, or sets the password
php tools/make-user.php --list # name and role per line
This is how the first administrator comes about; after that, accounts are managed in the editor.
Without a role a new account is a user, and an existing one keeps the role it has — resetting a
password on the command line never demotes an administrator. The CLI does not check the password's
length.
Passwords are stored as hashes (`password_hash`) in `data/users.json`, which is git-ignored, as
is `data/sessions/`. The CLI is the only way to *create* an account and the only way to set a
password without knowing the old one; changing one's own works in the editor (#182). Registration
(#178), administering users (#179), roles (#180) and shared projects (#181) are follow-ups of
their own.
is `data/sessions/`. Registration (#178) and shared projects (#181) are follow-ups of their own.
## What the check tools do
Every seed, smoke and e2e run signs in as `e2e` first and sends the same session cookie a browser
would (`tools/lib/auth.mjs`). The password comes from `GAME_MAKER_E2E_PASSWORD` or from
`data/e2e-password.txt`, which is git-ignored — CI writes its own on a fresh checkout and makes
the account with it (`tools/ci/serve.sh`).
the account with it (`tools/ci/serve.sh`). `e2e-admin` works the same way, with
`GAME_MAKER_E2E_ADMIN_PASSWORD` or `data/e2e-admin-password.txt`.
The browser in an e2e run is handed that session as a cookie, so it starts on the editor. The
sign-in screen itself is driven by hand in step 45, the one step that sees it.
sign-in screen itself is driven by hand in step 45, the one step that sees it. Step 49 signs the
browser in as `e2e-admin`, manages a throwaway account `e2e-tmp` through the tab and hands the
browser back to `e2e` at the end; the account and its project are gone again even when a check on
the way throws.
The API's own rules — roles, who reaches which project, what may not be done to an account — are
tested without a server in `tests/php/accounts.test.php`, a plain PHP file CI runs in the gate job
(`php tests/php/accounts.test.php`). The machine the editor is built on has no PHP; there it runs
in the web server's container.
+12 -3
@@ -150,7 +150,7 @@ src/editor/ App → Editor (layout + ticker), PlaySession (the run),
#129, #195),
fileImport (drop/paste) → assetImport (one upload pipeline, an ImportKind per sort) ← textureImport / fontImport / soundImport,
FilePicker (the shared "+ → Texture / Font / Sound" dialog; ImagePicker/FontPicker/SoundPicker only describe their row),
framing (fit/marquee/snap maths), FormDialog (the small modal forms: label column, Cancel + confirm, one open at a time; `dialogButtons` is the closing row every dialog shares, the pickers' included), AssetTabEditor (the shared shell of the asset tabs), TabHeader (the bar across the top of a tab — asset tabs, Plugins tab, NodeBoard; drawn by `drawHeaderBar` like the toolbar and the breadcrumb),
framing (fit/marquee/snap maths), FormDialog (the small modal forms: label column, Cancel + confirm, one open at a time; `dialogButtons` is the closing row every dialog shares, the pickers' included), AssetTabEditor (the shared shell of the asset tabs), TabHeader (the bar across the top of a tab — asset tabs, the plugins and accounts lists, NodeBoard; drawn by `drawHeaderBar` like the toolbar and the breadcrumb),
TabStrip + tabRow (the tab row and what it does when the tabs outgrow it; plugin tabs are inserted at run time),
assetTabs (which asset kind opens into which tab, and so which kind has an editor at all),
TabButton (how a tab is drawn: the card of the one showing, plain weight, no pressed fill, #176),
@@ -160,7 +160,16 @@ src/editor/ App → Editor (layout + ticker), PlaySession (the run),
screen (`AssetTabEditor.showAsset`), and it keeps each document's view through `saveView`/`restoreView`
src/editor/plugins/ startPlugins (list, import, host; runs before the first project) → loader (PluginManager: syncTo makes the
running plugins match what the project asks for, order, checks, what became of each file), pluginApi
(API v1, the per-plugin scope), PluginsPane (the Plugins tab), NodeBoard (api.ui)
(API v1, the per-plugin scope), PluginsPane (the list of plugins), NodeBoard (api.ui)
src/editor/users/ the user management (#179): ManagementPane (the tab behind Menu → User management / Plugins,
a document named `management` rather than a fixed tab; accounts above, PluginsPane below),
UsersPane (one row per account, its projects with Open, the actions; reads the list again
after every change), AccountDialog (the one form class for new user, rename and password),
accountRules (what the forms say before sending, which buttons a row offers — pure).
The store is `core/store/UsersStore` over `?r=users`; a refusal arrives as the
server's own sentence. The server decides everything: `api/lib/AccountRules.php`
(not the own account, not the last administrator, not an account with projects),
`Auth::requireAdmin()` (403), `Store` with `seesAll` for an administrator
src/editor/viewport/ WorldViewport base → FreeViewport (a camera of its own: fit, pan, wheel zoom) → SceneViewport, PreviewCanvas;
GameViewport follows the game's camera instead — editing tools, hence not in src/viewport/.
A pan only moves the world and redraws the screen-space overlays (`moveCamera`); a full
@@ -178,7 +187,7 @@ src/editor/inspector/ Inspector panel, host (what a section builds into), field
objectQueries/vectorRows/parentRow
src/editor/panels/ AssetPanel, assetGroups (groups by type and their order, #176), assetIcons (one SVG per
asset type in icons/, shared by the panel, the + menu, the tabs and Scene objects,
plus the List, Game and Plugins tabs' own three, which stand for no asset),
plus the List, Game, Plugins and User management tabs' own four, which stand for no asset),
assetMenu (the two groups of the + menu), HierarchyPanel,
Toolbar + toolbarIcons (five icon buttons, no captions: menu, undo, redo, play, pause, #183),
headerIcons (the pictures of the tab headers' switches: the magnet, #194),
+7 -5
@@ -74,17 +74,19 @@ The asset panel and **Scene objects** share the left column; the divider between
the height from one to the other, and dragged all the way either keeps its header. The split is a
share of the column, so a taller or shorter window keeps it.
**The tab row.** A tab is an **open document**, as in an IDE (#171). **Scene**, **Game** and
**Plugins** are fixed and cannot be closed; a plugin's own tab follows them for as long as the
plugin is on. Everything else opens as a document: double-click (or `Enter`) an asset in the
**The tab row.** A tab is an **open document**, as in an IDE (#171). **Scene** and **Game** are
fixed and cannot be closed; a plugin's own tab follows them for as long as the plugin is on. The
plugins themselves, and for an administrator the accounts, are in a tab that opens from the menu
and closes like a document (*Menu → Plugins* or *User management*, #179, see [Accounts](Accounts)). Everything else opens as a document: double-click (or `Enter`) an asset in the
asset panel, create one, or open a list from its row. An open document only comes up again, a new
one gets its tab right of the document on screen (or last, from a fixed tab). Several assets of
one kind can be open at once — two textures, three behaviors — each with its own tab.
- The tab shows the **icon** of its kind, then the name, so a behavior and a texture both called
`Player` stay apart; the `»` menu lists hidden documents the same way. **Scene** and **Game**
carry an icon too (#176) — the scene one from the asset panel, a play sign for the Game tab,
a puzzle piece for **Plugins** (#119). Only a plugin's own tab goes without one, as it brings
carry an icon too (#176) — the scene one from the asset panel, a play sign for the Game tab.
The plugins' tab shows a puzzle piece (#119), or two people when it holds the user management
(#179). Only a plugin's own tab goes without one, as it brings
no picture of its own. A long name is cut with an
ellipsis. Renaming an asset renames its tab.
- A document tab always shows its own asset. Selecting something elsewhere never changes it; there
+5 -3
@@ -12,8 +12,9 @@ npm run check # typecheck + lint + format:check + tests, the gate CI runs
# code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html
./build.sh # the same under `set -o pipefail`, for callers that pipe the output
php tools/make-user.php <name> <password> # an account; every tool signs in (see Accounts)
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1314 editor checks, screenshots in /tmp/gm-e2e-*.png
php tools/make-user.php <name> <password> [admin|user] # an account; every tool signs in (see Accounts)
php tests/php/accounts.test.php # the API's account rules, no server needed (CI's gate job)
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1333 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
@@ -271,7 +272,8 @@ Counter-check: dropping generated assets from resolvePicture fails 5.
## Continuous integration
Every push runs the whole proof on the Gitea runner: `.gitea/workflows/ci.yml` installs, runs
`./check.sh`, builds, then `tools/ci/run-e2e.sh` (the editor suite) and `tools/ci/run-smokes.sh`
`./check.sh` and the PHP tests (`tests/php/`, #179 — this machine has no PHP, so they are not in
`check.sh`; here they run in the web server's container), builds, then `tools/ci/run-e2e.sh` (the editor suite) and `tools/ci/run-smokes.sh`
(the three games). Both scripts source `tools/ci/serve.sh`, which puts the checkout behind
`http://127.0.0.1:8080/game-maker/` with `php -S` — the build wires its assets absolutely under
`/game-maker/dist/`, so the path segment is not optional — and writes the empty project files the
+7 -5
@@ -3,8 +3,8 @@ server, and the editor loads the ones the open project asks for. v1 is deliberat
the demo plugin needs (read the assets, add a tab, show nodes on a board) and nothing it does not.
**Where plugins come from.** A file is put into `data/plugins/` by hand; there is no upload yet
(#116). A file put there is **off** in every project until someone switches it on in the **Plugins**
tab (#114, #119). The plugins that ship with the repository
(#116). A file put there is **off** in every project until someone switches it on in the list of
plugins (#114, #119), which *Menu → Plugins* opens. The plugins that ship with the repository
live in `plugins/` and are copied to `data/plugins/` by `npm run build` and by `tools/seed-e2e.mjs`;
a file of the same name is replaced, anything else in the folder is left alone.
`data/plugins/` is not in the repository. Which plugins run is decided **by the project** (#119):
@@ -61,8 +61,10 @@ no tab behind.
## Switching a plugin on and off
The **Plugins** tab lists every file in `data/plugins/` with its name, version, file and state, each
with a switch. The switch belongs to the **open project**: it is kept in the project file as
*Menu → Plugins* opens a tab that lists every file in `data/plugins/` with its name, version, file
and state, each with a switch. It closes like a document. For an administrator it is the lower half
of the **User management** tab, which *Menu → User management* opens as well (#179, see
[Accounts](Accounts)). The switch belongs to the **open project**: it is kept in the project file as
`"plugins": ["demo-plugin.js"]`, so it travels with the project and says nothing about any other
one. A new project switches on nothing.
@@ -91,7 +93,7 @@ of no consequence while nothing secret lives in a plugin; #115 and #116 have to
| `api.version` | `1` |
| `api.project.assets()` | The assets of the open project in list order, as `{ id, name, type }` — copies, so writing to them changes nothing. |
| `api.project.on('change', fn)` | Calls `fn` whenever the asset list changes: an asset created, renamed or deleted, by hand, by undo or by loading another project. A change that leaves ids, names and types alone (moving an object, editing a property) does not call it. Returns a function that unsubscribes. A listener that throws is reported and stays subscribed. Any event but `'change'` throws. |
| `api.tabs.add({ id, label, create })` | Adds a tab after the fixed ones (Scene, Game, Plugins), ahead of the open documents; it stays for as long as the plugin is on. `create()` is called once and must return a widget from `api.ui`. Returns `false`, with a console warning, when the id is taken — by a built-in tab (`scene`, `graph`, …) or a plugin that came earlier; `create` is not called then. A missing `id` or `label`, or a `create` that is not a function, throws. |
| `api.tabs.add({ id, label, create })` | Adds a tab after the fixed ones (Scene, Game), ahead of the open documents; it stays for as long as the plugin is on. `create()` is called once and must return a widget from `api.ui`. Returns `false`, with a console warning, when the id is taken — by a built-in tab (`scene`, `graph`, …) or a plugin that came earlier; `create` is not called then. A missing `id` or `label`, or a `create` that is not a function, throws. |
| `new api.ui.NodeBoard()` | A read-only node graph: `setNodes([{ id, title, x, y }])` replaces what it shows, `fit()` frames it. It has a **Fit** button and a node count above the canvas; a drag with any button pans, the wheel zooms about the pointer. The nodes are drawn by the graph editor's own node view, empty but for their title — no ports, no editing, no selection. The first `setNodes` with anything in it frames the nodes once the tab has a size; after that the view stays where the user put it. A title that is not text is empty, a position that is not a number is 0, and something that is not a list throws. |
## The demo plugin