From 3af598d40e20c2e1cf6fa08061dfea72f4727e33 Mon Sep 17 00:00:00 2001 From: meleki Date: Wed, 23 Sep 2026 13:59:23 +0200 Subject: [PATCH] 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 --- API.md | 11 ++++-- Accounts.md | 91 +++++++++++++++++++++++++++++++++++++++---------- Architecture.md | 15 ++++++-- Concepts.md | 12 ++++--- Development.md | 8 +++-- Plugins.md | 12 ++++--- 6 files changed, 113 insertions(+), 36 deletions(-) diff --git a/API.md b/API.md index 4b99a98..aab31d5 100644 --- a/API.md +++ b/API.md @@ -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). diff --git a/Accounts.md b/Accounts.md index 170defb..4144fd2 100644 --- a/Accounts.md +++ b/Accounts.md @@ -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=`). -## Making an account +## Making an account on the server - php tools/make-user.php # creates it, or sets the password - php tools/make-user.php --list + php tools/make-user.php [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. diff --git a/Architecture.md b/Architecture.md index 1755ee0..c0aad4d 100644 --- a/Architecture.md +++ b/Architecture.md @@ -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), diff --git a/Concepts.md b/Concepts.md index f6da5d6..b086390 100644 --- a/Concepts.md +++ b/Concepts.md @@ -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 diff --git a/Development.md b/Development.md index b9b0cf0..93c189f 100644 --- a/Development.md +++ b/Development.md @@ -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 # 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 [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 diff --git a/Plugins.md b/Plugins.md index 59e1c2b..a4c8b52 100644 --- a/Plugins.md +++ b/Plugins.md @@ -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