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

+5
View File
@@ -33,6 +33,11 @@ jobs:
env:
GM_TEST_WORKERS: 2
# The API's own rules (#180, #179). Not part of check.sh: the host the editor is built on
# has no PHP, the image does.
- name: PHP tests
run: php tests/php/accounts.test.php
suite:
runs-on: game-maker-ci
timeout-minutes: 45
+1
View File
@@ -1,6 +1,7 @@
/data/projects/*.json
/data/projects/*.tmp
/data/e2e-password.txt
/data/e2e-admin-password.txt
/data/sessions/
/data/users.json
/data/textures/
+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1314 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1333 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+139 -12
View File
@@ -7,15 +7,25 @@ declare(strict_types=1);
* Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the
* site's nginx configuration does not pass PATH_INFO to PHP.
*
* GET ?r=auth/session → {user} (null while nobody is signed in)
* POST ?r=auth/login {username,password} → {user} | 401
* GET ?r=auth/session → {user, role} (both null while nobody is signed in)
* POST ?r=auth/login {username,password} → {user, role} | 401
* POST ?r=auth/logout → 204
* POST ?r=auth/password {currentPassword,newPassword} → 204 | 401 | 422
* (the only auth route that needs a session; ends the account's
* other logins, keeps the one asking)
*
* Everything below needs a signed-in user (401 otherwise), and projects belong to one: a project
* of somebody else answers like one that is not there.
* of somebody else answers like one that is not there – unless the one asking is an administrator
* (#179), who reads, saves and deletes it like their own; a save keeps its owner.
*
* Administrators only (403 for everybody else, #180):
*
* GET ?r=users → [{username,role,createdAt,projects:[{id,name,revision,updatedAt}]}]
* POST ?r=users {username,password,role} → 201 {username,role,createdAt} | 409 | 422
* PATCH ?r=users/{name} {username?,role?,password?} → {username,role,createdAt} | 404 | 409 | 422
* DELETE ?r=users/{name} → 204 | 404 | 409 (still owns projects) | 422
* (renaming or deleting signs the account out everywhere; a new
* password ends its other sessions)
*
* GET ?r=projects → [{id,name,revision,updatedAt}]
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
@@ -47,6 +57,7 @@ declare(strict_types=1);
* `data/sounds/` and `data/pixels/` as static files; the API only writes them.
*/
require __DIR__ . '/lib/AccountRules.php';
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/FontStore.php';
require __DIR__ . '/lib/OriginalName.php';
@@ -86,7 +97,8 @@ function routeAuth(array $parts, string $method, Auth $auth): never
}
// Always 200, never 401: "nobody is signed in" is an answer, not a failure, and the
// login screen asks this before it can know either way.
Response::json(['user' => $auth->user()]);
$user = $auth->user();
Response::json(['user' => $user, 'role' => $user === null ? null : $auth->role($user)]);
}
if ($what === 'login') {
@@ -99,7 +111,7 @@ function routeAuth(array $parts, string $method, Auth $auth): never
// One message for both halves: which of the two was wrong is the attacker's question.
Response::error('Wrong user name or password', 401);
}
Response::json(['user' => $user]);
Response::json(['user' => $user, 'role' => $auth->role($user)]);
}
if ($what === 'password') {
@@ -132,9 +144,9 @@ function routeAuth(array $parts, string $method, Auth $auth): never
}
/** @param list<string> $parts */
function routeProjects(array $parts, string $method, string $owner): never
function routeProjects(array $parts, string $method, string $owner, bool $seesAll): never
{
$store = new Store(dirname(__DIR__) . '/data/projects', $owner);
$store = new Store(dirname(__DIR__) . '/data/projects', $owner, $seesAll);
if (count($parts) === 1) {
if ($method === 'GET') {
@@ -190,6 +202,120 @@ function routeProjects(array $parts, string $method, string $owner): never
Response::error('Method not allowed', 405);
}
/** @param list<string> $parts */
function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never
{
$actor = $auth->requireAdmin();
$projects = new Store(dirname(__DIR__) . '/data/projects', $actor, true);
$shown = static function (string $name) use ($users): array {
foreach ($users->accounts() as $account) {
if (strcasecmp($account['username'], $name) === 0) {
return $account;
}
}
throw new RuntimeException("Account {$name} vanished");
};
if (count($parts) === 1) {
if ($method === 'GET') {
Response::json(array_map(
static fn(array $a): array => $a + ['projects' => $projects->listOf($a['username'])],
$users->accounts(),
));
}
if ($method === 'POST') {
$body = Response::body();
$name = (string) ($body['username'] ?? '');
$password = (string) ($body['password'] ?? '');
$role = (string) ($body['role'] ?? 'user');
if (!UserStore::isValidName($name)) {
Response::error('A user name is 2–32 letters, digits, dots, dashes or underscores', 422);
}
if (!UserStore::isValidPassword($password)) {
Response::error('The password needs at least ' . UserStore::MIN_PASSWORD_LENGTH . ' characters', 422);
}
if (!UserStore::isValidRole($role)) {
Response::error('Unknown role', 422);
}
if ($users->exists($name)) {
Response::error("The name {$name} is taken", 409);
}
$users->put($name, $password, $role);
Response::json($shown($name), 201);
}
Response::error('Method not allowed', 405);
}
$target = $parts[1];
if (!$users->exists($target)) {
Response::error('User not found', 404);
}
$role = $users->role($target);
if ($method === 'PATCH') {
$body = Response::body();
$newName = array_key_exists('username', $body) ? (string) $body['username'] : null;
$newRole = array_key_exists('role', $body) ? (string) $body['role'] : null;
$password = array_key_exists('password', $body) ? (string) $body['password'] : null;
// Everything is checked before anything is written: half an edit is worse than none.
if ($newName !== null && $newName !== $target) {
$refusal = AccountRules::renameRefusal($actor, $target);
if ($refusal !== null) {
Response::error($refusal, 422);
}
if (!UserStore::isValidName($newName)) {
Response::error('A user name is 2–32 letters, digits, dots, dashes or underscores', 422);
}
if (strcasecmp($newName, $target) !== 0 && $users->exists($newName)) {
Response::error("The name {$newName} is taken", 409);
}
}
if ($newRole !== null) {
if (!UserStore::isValidRole($newRole)) {
Response::error('Unknown role', 422);
}
$refusal = AccountRules::roleRefusal($actor, $target, $role, $newRole, $users->adminCount());
if ($refusal !== null) {
Response::error($refusal, 422);
}
}
if ($password !== null && !UserStore::isValidPassword($password)) {
Response::error('The password needs at least ' . UserStore::MIN_PASSWORD_LENGTH . ' characters', 422);
}
if ($password !== null) {
$auth->resetPassword($target, $password);
}
if ($newRole !== null && $newRole !== $role) {
$users->setRole($target, $newRole);
}
$name = $shown($target)['username'];
if ($newName !== null && $newName !== $name) {
$users->rename($name, $newName);
$projects->transfer($name, $newName);
$auth->endSessionsOf($name);
$name = $newName;
}
Response::json($shown($name));
}
if ($method === 'DELETE') {
$refusal = AccountRules::deleteRefusal($actor, $target, $role, $users->adminCount());
if ($refusal !== null) {
Response::error($refusal, 422);
}
$refusal = AccountRules::ownerRefusal($target, count($projects->listOf($shown($target)['username'])));
if ($refusal !== null) {
Response::error($refusal, 409);
}
$users->remove($target);
$auth->endSessionsOf($target);
Response::noContent();
}
Response::error('Method not allowed', 405);
}
/** @param list<string> $parts */
function routeTextures(array $parts, string $method): never
{
@@ -350,19 +476,20 @@ try {
Response::error('Not found', 404);
}
$auth = new Auth(
new UserStore(dirname(__DIR__) . '/data/users.json'),
new SessionStore(dirname(__DIR__) . '/data/sessions'),
);
$users = new UserStore(dirname(__DIR__) . '/data/users.json');
$auth = new Auth($users, new SessionStore(dirname(__DIR__) . '/data/sessions'));
if ($parts[0] === 'auth') {
routeAuth($parts, $method, $auth);
}
if ($parts[0] === 'users') {
routeUsers($parts, $method, $auth, $users);
}
// Images, fonts, sounds, cels and plugins are shared between accounts; being signed in is still the
// price of admission, so an uploaded file cannot be dropped in by a stranger.
$user = $auth->require();
match ($parts[0]) {
'projects' => routeProjects($parts, $method, $user),
'projects' => routeProjects($parts, $method, $user, $auth->role($user) === 'admin'),
'textures' => routeTextures($parts, $method),
'fonts' => routeFonts($parts, $method),
'sounds' => routeSounds($parts, $method),
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* What an administrator may not do to an account (#179), as plain functions of the facts: each
* answers the refusal to send back, or null when the change may go ahead.
*
* An administrator's own name and role are off limits here – renaming yourself would end the
* session you are using, and demoting yourself would lock you out of the page you are on. Their
* own password may be set like anyone's.
*/
final class AccountRules
{
public static function renameRefusal(string $actor, string $target): ?string
{
return strcasecmp($actor, $target) === 0 ? 'You cannot rename your own account' : null;
}
public static function roleRefusal(string $actor, string $target, string $currentRole, string $newRole, int $admins): ?string
{
if (strcasecmp($actor, $target) === 0 && $newRole !== $currentRole) {
return 'You cannot change your own role';
}
if ($currentRole === 'admin' && $newRole !== 'admin' && $admins <= 1) {
return 'The last administrator cannot be demoted';
}
return null;
}
public static function deleteRefusal(string $actor, string $target, string $role, int $admins): ?string
{
if (strcasecmp($actor, $target) === 0) {
return 'You cannot delete your own account';
}
if ($role === 'admin' && $admins <= 1) {
return 'The last administrator cannot be deleted';
}
return null;
}
/**
* An account with projects is not deleted: they would belong to nobody and vanish from every
* list. The administrator deletes them first – there is no way to hand them on yet.
*/
public static function ownerRefusal(string $target, int $projects): ?string
{
if ($projects === 0) {
return null;
}
return "{$target} still owns {$projects} " . ($projects === 1 ? 'project' : 'projects');
}
}
+36
View File
@@ -29,6 +29,26 @@ final class Auth
return $user;
}
/**
* The signed-in user if they are an administrator (#180), else 401 or 403. Every route that
* may do more than a user goes through here: the editor only hides what it may not offer, the
* decision is made on this side.
*/
public function requireAdmin(): string
{
$user = $this->require();
if ($this->users->role($user) !== 'admin') {
Response::error('Administrators only', 403);
}
return $user;
}
/** `admin` or `user`; the session answer carries it so the editor knows what to offer. */
public function role(string $user): string
{
return $this->users->role($user);
}
/** Checks the pair, starts a session and sets the cookie. Null when the pair is wrong. */
public function logIn(string $username, string $password): ?string
{
@@ -68,6 +88,22 @@ final class Auth
return true;
}
/**
* An administrator sets somebody's password without knowing the old one (#179). That account's
* sessions end with it, except the one asking when it is their own.
*/
public function resetPassword(string $user, string $new): void
{
$this->users->put($user, $new);
$this->sessions->destroyAllFor($user, $this->token());
}
/** Signs an account out everywhere – it was renamed or deleted, and its sessions carry the old name. */
public function endSessionsOf(string $user): void
{
$this->sessions->destroyAllFor($user);
}
private function token(): string
{
return (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
+63 -17
View File
@@ -19,12 +19,17 @@ final class StaleRevisionException extends RuntimeException
* A store belongs to one signed-in user: it lists, reads and writes only the projects whose
* `owner` is that user. Someone else's project is answered as missing rather than as forbidden,
* because "this id exists" is already more than a stranger should learn.
*
* An administrator's store (`$seesAll`, #179) reads, writes and deletes every project as if it
* were their own, and a save keeps the owner it found. The list stays their own: the others are
* reached through the user management, one account at a time.
*/
final class Store
{
public function __construct(
private readonly string $dir,
private readonly string $owner,
private readonly bool $seesAll = false,
) {
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
throw new RuntimeException("Cannot create data directory {$this->dir}");
@@ -43,11 +48,17 @@ final class Store
/** @return list<array{id:string,name:string,revision:int,updatedAt:string}> */
public function list(): array
{
return $this->listOf($this->owner);
}
/** @return list<array{id:string,name:string,revision:int,updatedAt:string}> */
public function listOf(string $owner): array
{
$items = [];
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
$doc = $this->readFile($file);
if ($doc === null || $doc['owner'] !== $this->owner) {
if ($doc === null || $doc['owner'] !== $owner) {
continue;
}
$items[] = [
@@ -81,7 +92,7 @@ final class Store
'assets' => [],
'activeSceneId' => '',
];
$doc = $this->document($id, $name, 1, $project);
$doc = $this->document($id, $name, $this->owner, 1, $project);
$this->writeFile($this->path($id), $doc);
return $doc;
}
@@ -94,11 +105,7 @@ final class Store
public function save(string $id, int $expectedRevision, array $project): ?array
{
$path = $this->path($id);
$lock = fopen($path . '.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX)) {
throw new RuntimeException('Cannot acquire project lock');
}
try {
return $this->locked($path, function () use ($path, $id, $expectedRevision, $project): ?array {
$current = $this->own($this->readFile($path));
if ($current === null) {
return null;
@@ -108,14 +115,10 @@ final class Store
}
$name = is_string($project['name'] ?? null) && $project['name'] !== '' ? $project['name'] : $current['name'];
$project['id'] = $id;
$doc = $this->document($id, $name, $expectedRevision + 1, $project);
$doc = $this->document($id, $name, $current['owner'], $expectedRevision + 1, $project);
$this->writeFile($path, $doc);
return $doc;
} finally {
flock($lock, LOCK_UN);
fclose($lock);
@unlink($path . '.lock');
}
});
}
public function delete(string $id): bool
@@ -127,10 +130,53 @@ final class Store
return file_exists($path) && unlink($path);
}
/** The document, or null when it belongs to someone else. */
/**
* Hands every project of `$from` to `$to` – what renaming an account does, since the name is
* the owner. Revision and date stay: nothing in the project changed, and a tab that has one of
* them open must not be told it was modified elsewhere. Returns how many moved.
*/
public function transfer(string $from, string $to): int
{
$moved = 0;
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
// Under the save lock, or a save running at the same time writes the old owner back.
$moved += $this->locked($file, function () use ($file, $from, $to): int {
$doc = $this->readFile($file);
if ($doc === null || $doc['owner'] !== $from) {
return 0;
}
$doc['owner'] = $to;
$this->writeFile($file, $doc);
return 1;
});
}
return $moved;
}
/**
* @template T
* @param callable():T $work
* @return T
*/
private function locked(string $path, callable $work): mixed
{
$lock = fopen($path . '.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX)) {
throw new RuntimeException('Cannot acquire project lock');
}
try {
return $work();
} finally {
flock($lock, LOCK_UN);
fclose($lock);
@unlink($path . '.lock');
}
}
/** The document, or null when it belongs to someone else and this store does not see everything. */
private function own(?array $doc): ?array
{
return $doc !== null && $doc['owner'] === $this->owner ? $doc : null;
return $doc !== null && ($this->seesAll || $doc['owner'] === $this->owner) ? $doc : null;
}
private function path(string $id): string
@@ -138,12 +184,12 @@ final class Store
return $this->dir . '/' . $id . '.json';
}
private function document(string $id, string $name, int $revision, array $project): array
private function document(string $id, string $name, string $owner, int $revision, array $project): array
{
return [
'id' => $id,
'name' => $name,
'owner' => $this->owner,
'owner' => $owner,
'revision' => $revision,
'updatedAt' => gmdate('c'),
'project' => $project,
+128 -21
View File
@@ -2,11 +2,15 @@
declare(strict_types=1);
/**
* The accounts, as one JSON file: `data/users.json` holding `{username, password}` per user,
* where `password` is a hash from `password_hash` – the plain one is never stored.
* The accounts, as one JSON file: `data/users.json` holding `{username, password, role, createdAt}`
* per user, where `password` is a hash from `password_hash` – the plain one is never stored.
*
* Accounts are made from the command line (`tools/make-user.php`), not through the API: signing
* up is a follow-up of its own, and until it exists nobody may create an account over the wire.
* An entry without `role` is a `user` (#180): the weaker of the two is what a missing field means,
* so a hand-written file never hands out rights by accident. `createdAt` is missing on the accounts
* from before #179 and stays empty for them.
*
* Accounts are made from the command line (`tools/make-user.php`) or by an administrator through
* the `users` routes (#179); nobody can sign up over the wire.
*/
final class UserStore
{
@@ -17,6 +21,8 @@ final class UserStore
*/
public const MIN_PASSWORD_LENGTH = 8;
public const ROLES = ['admin', 'user'];
public function __construct(private readonly string $file) {}
public static function isValidName(string $name): bool
@@ -29,6 +35,11 @@ final class UserStore
return strlen($password) >= self::MIN_PASSWORD_LENGTH;
}
public static function isValidRole(string $role): bool
{
return in_array($role, self::ROLES, true);
}
/** The name as stored, or null when the pair does not match an account. */
public function verify(string $username, string $password): ?string
{
@@ -44,30 +55,119 @@ final class UserStore
return $this->find($username) !== null;
}
/** @return list<string> */
public function names(): array
/** `user` for an account that does not exist, the same as for one without a role. */
public function role(string $username): string
{
return array_map(static fn(array $u): string => $u['username'], $this->all());
return $this->find($username)['role'] ?? 'user';
}
/** Adds the account or replaces the password of one that is already there. */
public function put(string $username, string $password): void
/**
* Every account as the API may show it – never the hash.
*
* @return list<array{username:string,role:string,createdAt:string}>
*/
public function accounts(): array
{
return array_map(
static fn(array $u): array => [
'username' => $u['username'],
'role' => $u['role'],
'createdAt' => $u['createdAt'],
],
$this->all(),
);
}
public function adminCount(): int
{
return count(array_filter($this->all(), static fn(array $u): bool => $u['role'] === 'admin'));
}
/**
* Adds the account or replaces the password of one that is already there. Without `$role` a new
* account is a `user` and an existing one keeps what it has, so resetting a password from the
* command line never demotes an administrator.
*/
public function put(string $username, string $password, ?string $role = null): void
{
if (!self::isValidName($username)) {
throw new InvalidArgumentException("Invalid user name: {$username}");
}
$users = array_values(
array_filter(
$this->all(),
static fn(array $u): bool => strcasecmp($u['username'], $username) !== 0,
),
);
$users[] = ['username' => $username, 'password' => password_hash($password, PASSWORD_DEFAULT)];
usort($users, static fn(array $a, array $b): int => strcmp($a['username'], $b['username']));
if ($role !== null && !self::isValidRole($role)) {
throw new InvalidArgumentException("Invalid role: {$role}");
}
$existing = $this->find($username);
$users = $this->without($username);
$users[] = [
'username' => $existing['username'] ?? $username,
'password' => password_hash($password, PASSWORD_DEFAULT),
'role' => $role ?? $existing['role'] ?? 'user',
'createdAt' => $existing !== null ? $existing['createdAt'] : gmdate('c'),
];
$this->write($users);
}
/** @return array{username:string,password:string}|null */
/** @return bool False when there is no such account. */
public function setRole(string $username, string $role): bool
{
if (!self::isValidRole($role)) {
throw new InvalidArgumentException("Invalid role: {$role}");
}
return $this->change($username, static fn(array $u): array => ['role' => $role] + $u);
}
/**
* Gives an account a new name. Only the name: the projects that carry the old one as `owner`
* and the sessions that do are the caller's to move (see `Store::transfer`).
*
* @return bool False when there is no such account.
* @throws InvalidArgumentException when the new name is invalid or taken by another account.
*/
public function rename(string $from, string $to): bool
{
if (!self::isValidName($to)) {
throw new InvalidArgumentException("Invalid user name: {$to}");
}
$other = $this->find($to);
// Only a change of case is the same account; any other match is somebody else's name.
if ($other !== null && strcasecmp($other['username'], $from) !== 0) {
throw new InvalidArgumentException("The name {$to} is taken");
}
return $this->change($from, static fn(array $u): array => ['username' => $to] + $u);
}
/** @return bool False when there is no such account. */
public function remove(string $username): bool
{
if ($this->find($username) === null) {
return false;
}
$this->write($this->without($username));
return true;
}
/** @param callable(array):array $edit */
private function change(string $username, callable $edit): bool
{
$user = $this->find($username);
if ($user === null) {
return false;
}
$users = $this->without($username);
$users[] = $edit($user);
$this->write($users);
return true;
}
/** @return list<array{username:string,password:string,role:string,createdAt:string}> */
private function without(string $username): array
{
return array_values(
array_filter($this->all(), static fn(array $u): bool => strcasecmp($u['username'], $username) !== 0),
);
}
/** @return array{username:string,password:string,role:string,createdAt:string}|null */
private function find(string $username): ?array
{
foreach ($this->all() as $user) {
@@ -78,7 +178,7 @@ final class UserStore
return null;
}
/** @return list<array{username:string,password:string}> */
/** @return list<array{username:string,password:string,role:string,createdAt:string}> */
private function all(): array
{
if (!is_file($this->file)) {
@@ -92,15 +192,22 @@ final class UserStore
$users = [];
foreach ($data['users'] as $user) {
if (is_array($user) && is_string($user['username'] ?? null) && is_string($user['password'] ?? null)) {
$users[] = ['username' => $user['username'], 'password' => $user['password']];
$role = $user['role'] ?? null;
$users[] = [
'username' => $user['username'],
'password' => $user['password'],
'role' => is_string($role) && self::isValidRole($role) ? $role : 'user',
'createdAt' => is_string($user['createdAt'] ?? null) ? $user['createdAt'] : '',
];
}
}
return $users;
}
/** @param list<array{username:string,password:string}> $users */
/** @param list<array{username:string,password:string,role:string,createdAt:string}> $users */
private function write(array $users): void
{
usort($users, static fn(array $a, array $b): int => strcmp($a['username'], $b['username']));
$dir = dirname($this->file);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException("Cannot create data directory {$dir}");
+17 -3
View File
@@ -2,11 +2,20 @@
* Signing in and out. The token itself never reaches this code: the server keeps the session and
* hands the browser a cookie, so all that travels here is the name behind it.
*/
/** `admin` may manage the accounts and open every project (#180, #179); anything else is a `user`. */
export type Role = 'admin' | 'user';
/** Who is signed in, and what they may do – the server decides, this only says what to offer. */
export interface SignedIn {
user: string;
role: Role;
}
export interface AuthStore {
/** The signed-in user, or null while nobody is. */
session(): Promise<string | null>;
/** The user name on success; a rejected promise when the pair is wrong. */
logIn(username: string, password: string): Promise<string>;
session(): Promise<SignedIn | null>;
/** Who signed in on success; a rejected promise when the pair is wrong. */
logIn(username: string, password: string): Promise<SignedIn>;
logOut(): Promise<void>;
/**
* Replaces the password of the signed-in account. The current one has to come along even though
@@ -16,6 +25,11 @@ export interface AuthStore {
changePassword(currentPassword: string, newPassword: string): Promise<void>;
}
/** Anything the server did not call `admin` is a `user`: the weaker reading is the safe one. */
export function roleOf(value: unknown): Role {
return value === 'admin' ? 'admin' : 'user';
}
/** Thrown for a wrong user name or password, as opposed to a server that is not there. */
export class WrongCredentialsError extends Error {
constructor(message = 'Wrong user name or password') {
+11 -7
View File
@@ -1,16 +1,20 @@
import { type AuthStore, WrongCredentialsError } from './AuthStore';
import { type AuthStore, roleOf, type SignedIn, WrongCredentialsError } from './AuthStore';
import { apiRequest } from './http';
export class HttpAuthStore implements AuthStore {
constructor(private readonly endpoint: string) {}
async session(): Promise<string | null> {
const body = await apiRequest<{ user: string | null }>(this.endpoint, 'GET', 'auth/session');
return body.user;
async session(): Promise<SignedIn | null> {
const body = await apiRequest<{ user: string | null; role?: unknown }>(
this.endpoint,
'GET',
'auth/session',
);
return body.user === null ? null : { user: body.user, role: roleOf(body.role) };
}
async logIn(username: string, password: string): Promise<string> {
const body = await apiRequest<{ user: string }>(
async logIn(username: string, password: string): Promise<SignedIn> {
const body = await apiRequest<{ user: string; role?: unknown }>(
this.endpoint,
'POST',
'auth/login',
@@ -23,7 +27,7 @@ export class HttpAuthStore implements AuthStore {
(status, data) =>
status === 401 ? new WrongCredentialsError((data as { error?: string } | null)?.error) : undefined,
);
return body.user;
return { user: body.user, role: roleOf(body.role) };
}
async logOut(): Promise<void> {
+34
View File
@@ -0,0 +1,34 @@
import { type Role, roleOf } from './AuthStore';
import { apiRequest } from './http';
import type { Account, AccountChange, UsersStore } from './UsersStore';
/** A refusal of the API (4xx with an `{error}`) as its bare sentence, without route and status around it. */
function refusal(status: number, data: unknown): Error | undefined {
const message = (data as { error?: unknown } | null)?.error;
return status >= 400 && status < 500 && typeof message === 'string' ? new Error(message) : undefined;
}
export class HttpUsersStore implements UsersStore {
constructor(private readonly endpoint: string) {}
async list(): Promise<Account[]> {
const rows = await apiRequest<Account[]>(this.endpoint, 'GET', 'users', {}, refusal);
return rows.map((row) => ({ ...row, role: roleOf(row.role) }));
}
async create(username: string, password: string, role: Role): Promise<void> {
await apiRequest(this.endpoint, 'POST', 'users', this.json({ username, password, role }), refusal);
}
async update(username: string, change: AccountChange): Promise<void> {
await apiRequest(this.endpoint, 'PATCH', `users/${username}`, this.json(change), refusal);
}
async remove(username: string): Promise<void> {
await apiRequest<void>(this.endpoint, 'DELETE', `users/${username}`, {}, refusal);
}
private json(body: object): { headers: Record<string, string>; body: string } {
return { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
}
}
+36
View File
@@ -0,0 +1,36 @@
import type { Role } from './AuthStore';
/** A project as the user management lists it under its owner. */
export interface OwnedProject {
id: string;
name: string;
updatedAt: string;
}
/** One account as an administrator sees it – never the password. */
export interface Account {
username: string;
role: Role;
/** ISO date; empty for the accounts from before #179, which were made without one. */
createdAt: string;
projects: OwnedProject[];
}
/** What an administrator changes about an account in one go; a field left out stays. */
export interface AccountChange {
username?: string;
role?: Role;
password?: string;
}
/**
* The accounts, for administrators only (#179). The server checks the role on every call: a user
* who got hold of this would get 403s, nothing else. A refusal rejects with the server's own
* sentence as the message, so a dialog can show it as it stands.
*/
export interface UsersStore {
list(): Promise<Account[]>;
create(username: string, password: string, role: Role): Promise<void>;
update(username: string, change: AccountChange): Promise<void>;
remove(username: string): Promise<void>;
}
+10 -7
View File
@@ -1,8 +1,9 @@
import type { Application } from 'pixi.js';
import type { AuthStore } from '../core/store/AuthStore';
import type { AuthStore, SignedIn } from '../core/store/AuthStore';
import { HttpAuthStore } from '../core/store/HttpAuthStore';
import { HttpFileStore } from '../core/store/HttpFileStore';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
import { HttpUsersStore } from '../core/store/HttpUsersStore';
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
import { loadPhysics } from '../runtime/physics/engine';
import { requestRender, type VStack } from '../ui';
@@ -38,14 +39,14 @@ const PIXEL_BASE = '/game-maker/data/pixels';
* right away when the session from an earlier visit is still good – which is what lets a
* deployment replace the editor under an open tab without asking for the password again.
*/
async function signedInUser(app: Application, root: VStack, auth: AuthStore): Promise<string> {
async function signedInUser(app: Application, root: VStack, auth: AuthStore): Promise<SignedIn> {
const existing = await auth.session();
if (existing !== null) return existing;
let done: (user: string) => void = () => {};
const screen = new LoginScreen(auth, (user) => done(user));
let done: (account: SignedIn) => void = () => {};
const screen = new LoginScreen(auth, (account) => done(account));
const removeHook = installLoginHook(app, screen);
const user = await new Promise<string>((resolve) => {
const account = await new Promise<SignedIn>((resolve) => {
done = resolve;
root.add(screen, 1);
root.resize(app.screen.width, app.screen.height);
@@ -59,7 +60,7 @@ async function signedInUser(app: Application, root: VStack, auth: AuthStore): Pr
// the corner.
root.resize(0, 0);
screen.destroy({ children: true });
return user;
return account;
}
/**
@@ -87,7 +88,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
installPixelArtPictures();
const openSession = async (): Promise<void> => {
const user = await signedInUser(app, root, auth);
const { user, role } = await signedInUser(app, root, auth);
// Who is signed in goes into the window title: the toolbar is crowded enough, and the title is
// also what tells two tabs on two accounts apart.
document.title = `Game Maker – ${user}`;
@@ -109,6 +110,8 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
new HttpFileStore(API_ENDPOINT, 'sounds'),
{
user,
role,
users: new HttpUsersStore(API_ENDPOINT),
logOut: () =>
void endSession(editor).catch((error: unknown) => {
console.error('[auth] Signing out failed:', error);
+68 -16
View File
@@ -1,17 +1,20 @@
import type { Application, Ticker } from 'pixi.js';
import type { Asset } from '../core/model/Asset';
import { findAsset, findObjectHolder } from '../core/model/Project';
import type { Role } from '../core/store/AuthStore';
import type { ProjectStore } from '../core/store/ProjectStore';
import { pluginsOf } from '../core/store/projectPlugins';
import type { FontStore } from '../core/store/FontStore';
import type { SoundStore } from '../core/store/SoundStore';
import type { TextureStore } from '../core/store/TextureStore';
import type { UsersStore } from '../core/store/UsersStore';
import { gameClock, timeStep } from '../core/time';
import {
clamp,
HStack,
isRendering,
Menu,
type MenuEntry,
Panel,
renderCount,
requestRender,
@@ -42,7 +45,13 @@ import { resolveList } from './list/slot';
import { AnimationEditor } from './animation/AnimationEditor';
import { TileEditor } from './tile/TileEditor';
import { AssetPanel } from './panels/AssetPanel';
import { ASSET_ICON_SVG, GAME_ICON_SVG, LIST_ICON_SVG, PLUGINS_ICON_SVG } from './panels/assetIcons';
import {
ASSET_ICON_SVG,
GAME_ICON_SVG,
LIST_ICON_SVG,
PLUGINS_ICON_SVG,
USERS_ICON_SVG,
} from './panels/assetIcons';
import { HierarchyPanel } from './panels/HierarchyPanel';
import { TilemapPanel } from './panels/TilemapPanel';
import { Toolbar } from './panels/Toolbar';
@@ -60,6 +69,8 @@ import { TextureEditor } from './texture/TextureEditor';
import { installFileImport } from './fileImport';
import type { PluginManager } from './plugins/loader';
import { PluginsPane } from './plugins/PluginsPane';
import { ManagementPane } from './users/ManagementPane';
import { UsersPane } from './users/UsersPane';
export type CentreTab =
| 'scene'
@@ -75,18 +86,24 @@ export type CentreTab =
| 'tile'
| 'container'
| 'list'
| 'plugins';
| 'management';
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
export type PluginTabId = `plugin:${string}`;
/** What is showing: a fixed tab, a plugin's, or the kind of the open document on screen. */
export type TabId = CentreTab | PluginTabId;
/** An open document (#171): an asset by its id, or the list the List tab edits – there is one. */
export type DocumentId = `asset:${string}` | 'list';
/**
* An open document (#171): an asset by its id, or the list the List tab edits – there is one. The
* user management (#179) is no document, but it opens and closes like one.
*/
export type DocumentId = `asset:${string}` | 'list' | 'management';
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
export interface EditorAccount {
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
user: string;
/** What the editor offers: an administrator gets the user management (#180, #179). */
role: Role;
users: UsersStore;
logOut: () => void;
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
changePassword: (current: string, next: string) => Promise<void>;
@@ -97,9 +114,9 @@ export type StripTab = FixedTab | PluginTabId | DocumentId;
type FixedTab = (typeof FIXED_TABS)[number];
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
type AssetKind = Exclude<CentreTab, FixedTab | 'list'>;
type AssetKind = Exclude<CentreTab, FixedTab | 'list' | 'management'>;
const FIXED_TABS = ['scene', 'game', 'plugins'] as const;
const FIXED_TABS = ['scene', 'game'] as const;
const PANE_ORDER = [
'scene',
@@ -115,7 +132,7 @@ const PANE_ORDER = [
'tile',
'container',
'list',
'plugins',
'management',
] as const;
/** The fixed tabs' names, and a document's kind as a word (its tab shows the icon). */
@@ -133,18 +150,17 @@ const TAB_LABELS: Record<CentreTab, string> = {
tile: 'Tile',
container: 'Container',
list: 'List',
plugins: 'Plugins',
management: 'User management',
};
/** Icons of the fixed tabs (#176); a plugin's own tab has none, it brings no picture of its own. */
const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
scene: ASSET_ICON_SVG.scene,
game: GAME_ICON_SVG,
plugins: PLUGINS_ICON_SVG,
};
function isDocument(tab: StripTab): tab is DocumentId {
return tab === 'list' || tab.startsWith('asset:');
return tab === 'list' || tab === 'management' || tab.startsWith('asset:');
}
const MIN_LEFT_WIDTH = 160;
@@ -178,8 +194,11 @@ export class Editor implements PlayHost {
readonly tileEditor: TileEditor;
readonly containerEditor: ContainerEditor;
readonly listEditor: ListEditor;
/** Plugins tab: the switch per file in `data/plugins/`. */
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
readonly pluginsPane = new PluginsPane();
/** The accounts, above the plugins; null unless an administrator is signed in. */
readonly usersPane: UsersPane | null;
private readonly managementPane: ManagementPane;
readonly hierarchy: HierarchyPanel;
readonly assets: AssetPanel;
readonly inspector: Inspector;
@@ -285,8 +304,18 @@ export class Editor implements PlayHost {
this.inspector.liveVariables = (assetId) =>
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
this.usersPane =
account.role === 'admin'
? new UsersPane(account.users, {
self: account.user,
openProject: (id) => this.projects.openProject(id),
notice: (message, level) => state.setNotice(message, level),
})
: null;
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
this.toolbar = new Toolbar(state, {
openMainMenu: (anchor) => this.projects.openMainMenu(anchor),
openMainMenu: (anchor) => this.projects.openMainMenu(anchor, this.managementEntries()),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(),
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
@@ -347,7 +376,7 @@ export class Editor implements PlayHost {
game: this.gameView,
...this.assetEditors,
list: this.listEditor,
plugins: this.pluginsPane,
management: this.managementPane,
};
// Following a call goes on in the same tab: the tab changes its document, not its place.
this.graphEditor.onRetarget = (from, to) => {
@@ -440,6 +469,19 @@ export class Editor implements PlayHost {
state.on('mode', () => this.editorRun.invalidate());
}
/**
* The main menu's last group (#179): the user management for an administrator, and the plugins
* for everybody – both open the one tab, which holds the plugins below the accounts.
*/
private managementEntries(): MenuEntry[] {
const open = () => this.openDocument('management');
return [
{ separator: true },
...(this.usersPane ? [{ label: 'User management', onSelect: open }] : []),
{ label: 'Plugins', onSelect: open },
];
}
/**
* The account menu under its button (#182). The name stands in it as an entry that cannot be
* picked: it says who is signed in without costing the crowded toolbar another label.
@@ -539,7 +581,8 @@ export class Editor implements PlayHost {
this.documents = closeDocument(this.documents, doc);
this.tabs.removeTab(doc);
const kind = this.kindOf(doc);
if (kind && kind !== 'list') this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
if (kind && kind in this.assetEditors)
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
// The List tab edits the one slot there is; a closed tab lets go of it.
if (doc === 'list' && this.state.listSlot) this.state.openList(null);
if (!wasShowing) return;
@@ -557,6 +600,10 @@ export class Editor implements PlayHost {
if (this.state.listSlot) this.openDocument('list');
return;
}
if (tab === 'management') {
this.openDocument('management');
return;
}
if (tab in this.assetEditors) {
const editor = this.assetEditors[tab as AssetKind];
const id =
@@ -577,6 +624,7 @@ export class Editor implements PlayHost {
private kindOf(doc: DocumentId): CentreTab | null {
if (doc === 'list') return this.state.listSlot ? 'list' : null;
if (doc === 'management') return 'management';
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const kind = type ? TAB_OF_ASSET[type] : undefined;
return kind && kind !== 'scene' ? kind : null;
@@ -585,12 +633,15 @@ export class Editor implements PlayHost {
private showDocument(doc: DocumentId): void {
const kind = this.kindOf(doc);
if (!kind) return;
if (kind !== 'list') this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length));
if (kind in this.assetEditors) this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length));
// Read on every showing: an account may have been changed from another tab in the meantime.
if (kind === 'management') void this.usersPane?.reload();
this.display(kind, doc);
}
/** What a document's tab says: the asset's name, or the list's owner and name, as its tab heads it. */
private labelOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
if (doc === 'list') {
const slot = this.state.listSlot;
return (slot && resolveList(this.state, slot)?.title) ?? '';
@@ -600,6 +651,7 @@ export class Editor implements PlayHost {
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
private iconOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
const type = doc === 'list' ? undefined : findAsset(this.state.project, doc.slice('asset:'.length))?.type;
return type ? ASSET_ICON_SVG[type] : LIST_ICON_SVG;
}
@@ -613,7 +665,7 @@ export class Editor implements PlayHost {
const kind = this.kindOf(doc);
if (!this.tabs.has(doc) && kind)
this.tabs.addTab(doc, this.labelOf(doc), base + index, {
kind: TAB_LABELS[kind],
kind: doc === 'management' ? this.labelOf(doc) : TAB_LABELS[kind],
icon: this.iconOf(doc),
onClose: () => this.closeDocument(doc),
});
+11 -2
View File
@@ -47,6 +47,11 @@ export class ProjectSession {
}
}
/** Opens a project by its id – from the user management, where it may be somebody else's (#179). */
openProject(id: string): void {
void this.run(this.open(id));
}
private async open(id: string): Promise<void> {
this.beforeSwitch();
const loaded = await this.store.load(id, this.prepare);
@@ -75,14 +80,18 @@ export class ProjectSession {
if (id === this.state.project.id) await this.openFirstOrCreate();
}
/** The one menu of the top bar (#183): everything that is about the project rather than a scene. */
openMainMenu(anchor: Widget): void {
/**
* The one menu of the top bar (#183): everything that is about the project rather than a scene.
* `more` comes last – what the editor adds that is not about the project (#179).
*/
openMainMenu(anchor: Widget, more: MenuEntry[] = []): void {
const items: MenuEntry[] = [
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
{ separator: true },
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
...more,
];
new Menu(items).openBelow(anchor);
}
+3 -4
View File
@@ -1,5 +1,5 @@
import { Graphics } from 'pixi.js';
import { type AuthStore, WrongCredentialsError } from '../../core/store/AuthStore';
import { type AuthStore, type SignedIn, WrongCredentialsError } from '../../core/store/AuthStore';
import { Button, drawControlBox, Label, Spacer, TextField, Theme, ui, VStack } from '../../ui';
import { FormStatus, refocus } from './FormStatus';
import { loginError, loginProblem } from './loginRules';
@@ -40,7 +40,7 @@ export class LoginScreen extends VStack {
constructor(
private readonly auth: AuthStore,
private readonly onSignedIn: (user: string) => void,
private readonly onSignedIn: (account: SignedIn) => void,
) {
super({ align: 'center', gap: Theme.gap });
this.add(new Spacer(), 1);
@@ -101,8 +101,7 @@ export class LoginScreen extends VStack {
}
this.status.setBusy(true);
try {
const user = await this.auth.logIn(username, password);
this.onSignedIn(user);
this.onSignedIn(await this.auth.logIn(username, password));
} catch (error) {
this.status.say(loginError(error, error instanceof WrongCredentialsError));
this.status.setBusy(false);
+21
View File
@@ -5,6 +5,7 @@ import { currentFontPicker } from '../font/FontPicker';
import { currentSoundPicker } from '../sound/SoundPicker';
import { rectOf, textProbe } from '../textProbe';
import { currentImagePicker } from '../texture/ImagePicker';
import type { UsersPane } from '../users/UsersPane';
import { Color, type ColorSource, type Container, Text } from 'pixi.js';
import { centreOf, type HookContext } from './hookHelpers';
@@ -168,6 +169,26 @@ export function uiHooks({ app, state, editor }: HookContext) {
tabRow: () => editor.tabRow(),
/** The open "Change password" dialog (#182): its message and whether it is waiting, or null. */
passwordDialog: () => editor.accountDialog()?.info() ?? null,
/**
* The user management (#179); `pane()` is null for an account that is no administrator.
* `buttonAt` takes an action (`rename`, `role`, `password`, `delete`) or `open:<project id>`.
*/
users: {
pane: () =>
editor.usersPane ? { visible: editor.tab === 'management', ...editor.usersPane.debugInfo() } : null,
buttonAt: (username: string, action: Parameters<UsersPane['buttonOf']>[1]) => {
const button = editor.usersPane?.buttonOf(username, action);
return button
? { ...centreOf(button), right: rectOf(button).x + button.w, enabled: button.enabled }
: null;
},
/** The list on screen, for checks that its rows fit into it. */
rect: () => (editor.usersPane ? rectOf(editor.usersPane) : null),
/** The open form: its title, the message under it, whether it waits, its role (null without a picker). */
dialog: () => editor.usersPane?.currentDialog()?.info() ?? null,
/** Reads the list again and answers once it is drawn – `json()` does not wait for a promise, `evaluate()` does. */
reload: () => editor.usersPane?.reload(),
},
/** The project's collision layers (#152), and the names the open dialog holds (null when shut). */
collisionLayers: () => ({
project: [...state.project.collisionLayers],
+2 -2
View File
@@ -63,8 +63,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
plugins: {
/** Every file in `data/plugins/` and what became of it. */
records: () => [...(editor.plugins?.records ?? [])],
/** The Plugins tab: one row per file, with its switch. */
pane: () => editor.pluginsPane.debugInfo(),
/** The plugins in the user management tab (#179): one row per file, with its switch. */
pane: () => ({ ...editor.pluginsPane.debugInfo(), visible: editor.tab === 'management' }),
/** Where to click the switch of one plugin; null when it has no row. */
switchAt: (file: string) => {
const box = editor.pluginsPane.switchOf(file);
+4
View File
@@ -25,6 +25,7 @@ import text from './icons/text.svg?raw';
import texture from './icons/texture.svg?raw';
import tile from './icons/tile.svg?raw';
import tilemap from './icons/tilemap.svg?raw';
import users from './icons/users.svg?raw';
import variables from './icons/variables.svg?raw';
import vectorart from './icons/vectorart.svg?raw';
@@ -60,3 +61,6 @@ export const GAME_ICON_SVG = game;
/** The Plugins tab's: a puzzle piece, as a plugin is a piece added to the editor (#119). */
export const PLUGINS_ICON_SVG = plugins;
/** The User management tab's (#179), which holds the plugins as well; an account sees it only as an administrator. */
export const USERS_ICON_SVG = users;
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- The User management tab (#179): two people, the one behind cut off by the one in front.
Shoulders are cubic curves, not arcs: Pixi's parser draws an A after an L as a spike. -->
<circle cx="-1.5" cy="-2.5" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<path
d="M-6.5 6.5 C-6.5 2.5 -4.5 1 -1.5 1 C1.5 1 3.5 2.5 3.5 6.5"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M3.2 -6.2 C5 -6 5.9 -4.8 5.9 -3.5 C5.9 -2.2 5 -1 3.4 -0.8 M5.2 1.3 C6.8 2 7.4 3.8 7.4 6"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 750 B

+1 -3
View File
@@ -57,7 +57,7 @@ class PluginRow extends HStack {
}
/**
* The Plugins tab (#114, #119): every file in `data/plugins/` with a switch. Switching one on loads
* The plugins (#114, #119), in the user management tab since #179: every file in `data/plugins/` with a switch. Switching one on loads
* and activates it there and then, switching it off takes its tabs and listeners away again – the
* switch is part of the open project, so it travels with it and undo takes it back.
*
@@ -102,11 +102,9 @@ export class PluginsPane extends VStack {
/** The rows as drawn, and the switch to click – what the check scripts read. */
debugInfo(): {
visible: boolean;
rows: { file: string; name: string; status: string; checked: boolean; text: string }[];
} {
return {
visible: this.visible,
rows: this.rowWidgets().map((row) => ({
file: row.record.file,
name: displayName(row.record),
+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 };
}
+26 -7
View File
@@ -23,17 +23,36 @@ afterEach(() => {
});
describe('HttpAuthStore', () => {
it('reads who is signed in, null included', async () => {
stubFetch(JSON.stringify({ user: 'e2e' }), { status: 200 });
await expect(new HttpAuthStore('/api').session()).resolves.toBe('e2e');
it('reads who is signed in and their role, null included', async () => {
stubFetch(JSON.stringify({ user: 'e2e', role: 'user' }), { status: 200 });
await expect(new HttpAuthStore('/api').session()).resolves.toEqual({ user: 'e2e', role: 'user' });
stubFetch(JSON.stringify({ user: null }), { status: 200 });
stubFetch(JSON.stringify({ user: 'admin', role: 'admin' }), { status: 200 });
await expect(new HttpAuthStore('/api').session()).resolves.toEqual({ user: 'admin', role: 'admin' });
stubFetch(JSON.stringify({ user: null, role: null }), { status: 200 });
await expect(new HttpAuthStore('/api').session()).resolves.toBeNull();
});
it('sends the pair as JSON and answers with the name the server confirms', async () => {
const mock = stubFetch(JSON.stringify({ user: 'user' }), { status: 200 });
await expect(new HttpAuthStore('/api').logIn('user', 'secret')).resolves.toBe('user');
// #180: the weaker reading is the safe one – a server from before roles, or a role this editor does
// not know, must not make anyone an administrator.
it('takes a missing or unknown role for user', async () => {
stubFetch(JSON.stringify({ user: 'old' }), { status: 200 });
await expect(new HttpAuthStore('/api').session()).resolves.toEqual({ user: 'old', role: 'user' });
stubFetch(JSON.stringify({ user: 'odd', role: 'root' }), { status: 200 });
await expect(new HttpAuthStore('/api').logIn('odd', 'secret')).resolves.toEqual({
user: 'odd',
role: 'user',
});
});
it('sends the pair as JSON and answers with the name and role the server confirms', async () => {
const mock = stubFetch(JSON.stringify({ user: 'user', role: 'user' }), { status: 200 });
await expect(new HttpAuthStore('/api').logIn('user', 'secret')).resolves.toEqual({
user: 'user',
role: 'user',
});
const [url, init] = mock.mock.calls[0]!;
expect(url as string).toContain('r=auth%2Flogin');
expect(init?.method).toBe('POST');
+97
View File
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpUsersStore } from '../../src/core/store/HttpUsersStore';
import { accountActions, nameProblem, newPasswordProblem } from '../../src/editor/users/accountRules';
import { MIN_PASSWORD_LENGTH } from '../../src/editor/auth/loginRules';
function stubFetch(body: BodyInit | null, init: ResponseInit) {
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
vi.stubGlobal('fetch', mock);
return mock;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('HttpUsersStore (#179)', () => {
it('lists the accounts with their projects, an unknown role read as user', async () => {
const mock = stubFetch(
JSON.stringify([
{ username: 'admin', role: 'admin', createdAt: '', projects: [] },
{ username: 'odd', role: 'root', createdAt: '2026-09-23T10:00:00+00:00', projects: [{ id: 'p1' }] },
]),
{ status: 200 },
);
const accounts = await new HttpUsersStore('/api').list();
expect(accounts.map((a) => [a.username, a.role])).toEqual([
['admin', 'admin'],
['odd', 'user'],
]);
expect(accounts[1]?.projects).toHaveLength(1);
expect(mock.mock.calls[0]?.[0] as string).toContain('r=users');
});
it('creates, changes and deletes through their own routes and methods', async () => {
const mock = stubFetch(JSON.stringify({}), { status: 200 });
const store = new HttpUsersStore('/api');
await store.create('anna', 'a-password', 'admin');
await store.update('anna', { username: 'annabel' });
stubFetch(null, { status: 204 });
await store.remove('annabel');
const [createUrl, create] = mock.mock.calls[0]!;
expect(createUrl as string).toMatch(/r=users$/);
expect(create?.method).toBe('POST');
expect(JSON.parse(create?.body as string)).toEqual({
username: 'anna',
password: 'a-password',
role: 'admin',
});
const [patchUrl, patch] = mock.mock.calls[1]!;
expect(patchUrl as string).toContain('r=users%2Fanna');
expect(patch?.method).toBe('PATCH');
// Only what changes travels: a field left out is one the server leaves alone.
expect(JSON.parse(patch?.body as string)).toEqual({ username: 'annabel' });
});
// The dialogs show the error as it stands, so it has to be the server's sentence and nothing else.
it('turns a refusal into the server’s own sentence', async () => {
stubFetch(JSON.stringify({ error: 'anna still owns 2 projects' }), { status: 409 });
await expect(new HttpUsersStore('/api').remove('anna')).rejects.toThrow(/^anna still owns 2 projects$/);
stubFetch(JSON.stringify({ error: 'Administrators only' }), { status: 403 });
await expect(new HttpUsersStore('/api').list()).rejects.toThrow(/^Administrators only$/);
});
it('keeps route and status for a failure that is no refusal', async () => {
stubFetch(JSON.stringify({ error: 'Internal server error' }), { status: 500 });
await expect(new HttpUsersStore('/api').list()).rejects.toThrow(/Internal server error \(500\)/);
});
});
describe('the rules of the user management forms (#179)', () => {
it('asks for a name the server takes', () => {
expect(nameProblem('')).toBe('Enter a user name');
expect(nameProblem('a')).not.toBeNull();
expect(nameProblem('a b')).not.toBeNull();
expect(nameProblem('-dash-first')).not.toBeNull();
expect(nameProblem('x'.repeat(33))).not.toBeNull();
expect(nameProblem('anna.b_2-x')).toBeNull();
expect(nameProblem('x'.repeat(32))).toBeNull();
});
it('asks for a password long enough, typed twice the same', () => {
const long = 'a'.repeat(MIN_PASSWORD_LENGTH);
expect(newPasswordProblem('', '')).toBe('Enter a password');
expect(newPasswordProblem(long.slice(1), long.slice(1))).toBe(
`The password needs at least ${MIN_PASSWORD_LENGTH} characters`,
);
expect(newPasswordProblem(long, `${long}x`)).toBe('The two passwords are different');
expect(newPasswordProblem(long, long)).toBeNull();
});
it('offers nothing but a new password on the administrator’s own row', () => {
expect(accountActions('admin', 'Admin')).toEqual({ rename: false, role: false, remove: false });
expect(accountActions('admin', 'anna')).toEqual({ rename: true, role: true, remove: true });
});
});
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
/**
* Accounts, roles and who may touch which project (#180, #179), without a web server: the stores
* work on a scratch directory. Plain PHP, no framework – CI runs it with `php tests/php/accounts.test.php`
* and reads the last line, as it does for the other suites.
*/
require __DIR__ . '/../../api/lib/AccountRules.php';
require __DIR__ . '/../../api/lib/Store.php';
require __DIR__ . '/../../api/lib/UserStore.php';
$failed = 0;
$passed = 0;
function check(string $name, bool $ok, mixed $got = null): void
{
global $failed, $passed;
if ($ok) {
$passed++;
echo "PASS {$name}\n";
return;
}
$failed++;
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
}
function throws(callable $work): bool
{
try {
$work();
} catch (InvalidArgumentException) {
return true;
}
return false;
}
$dir = sys_get_temp_dir() . '/game-maker-php-test-' . bin2hex(random_bytes(4));
mkdir($dir);
$file = "{$dir}/users.json";
// --- UserStore ---------------------------------------------------------------------------------
file_put_contents($file, json_encode(['users' => [
['username' => 'old', 'password' => password_hash('secret12', PASSWORD_DEFAULT)],
['username' => 'odd', 'password' => password_hash('secret12', PASSWORD_DEFAULT), 'role' => 'root'],
]]));
$users = new UserStore($file);
check('an account without a role is a user', $users->role('old') === 'user', $users->role('old'));
check('an unknown role counts as user', $users->role('odd') === 'user', $users->role('odd'));
check('an account that is not there is a user', $users->role('nobody') === 'user');
check('the old accounts have no creation date', $users->accounts()[1]['createdAt'] === '', $users->accounts());
$users->put('boss', 'secret12', 'admin');
check('a role given on creation is kept', $users->role('boss') === 'admin');
check('a new account gets a creation date', $users->accounts()[0]['createdAt'] !== '', $users->accounts()[0]);
$created = $users->accounts()[0]['createdAt'];
$users->put('boss', 'another12');
check('a new password without a role leaves the role alone', $users->role('boss') === 'admin');
check('a new password leaves the creation date alone', $users->accounts()[0]['createdAt'] === $created);
check('the new password is the one that works', $users->verify('boss', 'another12') === 'boss');
$users->put('fresh', 'secret12');
check('a new account without a role is a user', $users->role('fresh') === 'user');
check('the role is written to the file', str_contains((string) file_get_contents($file), '"role": "admin"'));
check('an unknown role is refused', throws(fn() => $users->put('x1', 'secret12', 'root')));
check(
'the accounts carry no hash',
!str_contains(json_encode($users->accounts()), 'password') && !str_contains(json_encode($users->accounts()), '$2y$'),
);
check('one administrator counted', $users->adminCount() === 1, $users->adminCount());
$users->setRole('fresh', 'admin');
check('setRole makes an administrator', $users->role('fresh') === 'admin' && $users->adminCount() === 2);
check('setRole on nobody answers false', $users->setRole('nobody', 'user') === false);
check('rename moves the account', $users->rename('fresh', 'renamed') && $users->exists('renamed') && !$users->exists('fresh'));
check('rename keeps password and role', $users->verify('renamed', 'secret12') === 'renamed' && $users->role('renamed') === 'admin');
check('rename onto a taken name is refused', throws(fn() => $users->rename('renamed', 'OLD')));
check('the refused rename changed nothing', $users->exists('renamed') && $users->role('old') === 'user');
check('rename to an invalid name is refused', throws(fn() => $users->rename('renamed', 'a b')));
check('a change of case is the same account', $users->rename('renamed', 'Renamed') && $users->accounts()[0]['username'] === 'Renamed', $users->accounts());
check('rename of nobody answers false', $users->rename('nobody', 'somebody') === false);
check('remove takes the account away', $users->remove('renamed') && !$users->exists('renamed'));
check('remove of nobody answers false', $users->remove('renamed') === false);
// --- Store: who reaches a project --------------------------------------------------------------
$projects = "{$dir}/projects";
$mine = new Store($projects, 'anna');
$doc = $mine->create('Anna\'s game');
$id = $doc['id'];
$stranger = new Store($projects, 'bert');
$admin = new Store($projects, 'boss', true);
check('a stranger does not see the project', $stranger->load($id) === null);
check('a stranger cannot save it', $stranger->save($id, 1, ['name' => 'x']) === null);
check('a stranger cannot delete it', $stranger->delete($id) === false);
check('an administrator loads it', $admin->load($id)['name'] === "Anna's game");
$saved = $admin->save($id, 1, ['name' => 'Edited by boss']);
check('an administrator saves it', $saved !== null && $saved['revision'] === 2, $saved);
check('the save keeps the owner', $mine->load($id)['owner'] === 'anna', $mine->load($id)['owner'] ?? null);
check('the owner sees the edit', $mine->load($id)['name'] === 'Edited by boss');
check('the administrator\'s own list stays their own', $admin->list() === []);
check('listOf lists somebody else\'s projects', array_column($admin->listOf('anna'), 'id') === [$id]);
check('transfer moves the project to the new name', $admin->transfer('anna', 'annabel') === 1);
$moved = (new Store($projects, 'annabel'))->load($id);
check('the new name owns it', $moved !== null && $moved['owner'] === 'annabel');
check('the old name does not', $mine->load($id) === null);
check('transfer keeps the revision', $moved['revision'] === 2, $moved['revision'] ?? null);
$other = (new Store($projects, 'carl'))->create('Carl\'s game');
check('an administrator deletes somebody else\'s project', $admin->delete($other['id']) && $admin->listOf('carl') === []);
// --- AccountRules ------------------------------------------------------------------------------
check('renaming yourself is refused', AccountRules::renameRefusal('boss', 'BOSS') !== null);
check('renaming somebody else is fine', AccountRules::renameRefusal('boss', 'anna') === null);
check('changing your own role is refused', AccountRules::roleRefusal('boss', 'boss', 'admin', 'user', 2) !== null);
check('setting your own role to what it is is fine', AccountRules::roleRefusal('boss', 'boss', 'admin', 'admin', 1) === null);
check('the last administrator cannot be demoted', AccountRules::roleRefusal('x', 'boss', 'admin', 'user', 1) !== null);
check('an administrator among two can be demoted', AccountRules::roleRefusal('x', 'boss', 'admin', 'user', 2) === null);
check('deleting yourself is refused', AccountRules::deleteRefusal('boss', 'boss', 'admin', 2) !== null);
check('the last administrator cannot be deleted', AccountRules::deleteRefusal('x', 'boss', 'admin', 1) !== null);
check('a user can be deleted', AccountRules::deleteRefusal('boss', 'anna', 'user', 1) === null);
check('an account with projects is not deleted', AccountRules::ownerRefusal('anna', 2) === 'anna still owns 2 projects');
check('one project is said as one', AccountRules::ownerRefusal('anna', 1) === 'anna still owns 1 project');
check('an account without projects may go', AccountRules::ownerRefusal('anna', 0) === null);
exec('rm -rf ' . escapeshellarg($dir));
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
exit($failed === 0 ? 0 : 1);
+11 -3
View File
@@ -6,6 +6,7 @@ import {
GAME_ICON_SVG,
LIST_ICON_SVG,
PLUGINS_ICON_SVG,
USERS_ICON_SVG,
} from '../../src/editor/panels/assetIcons';
import { HEADER_ICON_SVG } from '../../src/editor/panels/headerIcons';
import { ARROW_ICON_SVG, KIND_ICON_SVG, ROW_ACTION_ICON_SVG } from '../../src/editor/panels/markIcons';
@@ -44,6 +45,7 @@ describe('icon files', () => {
['list tab', LIST_ICON_SVG],
['game tab', GAME_ICON_SVG],
['plugins tab', PLUGINS_ICON_SVG],
['user management tab', USERS_ICON_SVG],
...Object.entries(TOOLBAR_ICON_SVG).map(([name, svg]) => [`toolbar ${name}`, svg]),
...Object.entries(HEADER_ICON_SVG).map(([name, svg]) => [`header ${name}`, svg]),
...Object.entries(UI_ICON_SVG).map(([name, svg]) => [`ui ${name}`, svg]),
@@ -124,12 +126,18 @@ describe('icon files', () => {
});
it('draws the asset icons in the 16 units a list row and a button reserve', () => {
for (const svg of [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG])
for (const svg of [
...Object.values(ASSET_ICON_SVG),
LIST_ICON_SVG,
GAME_ICON_SVG,
PLUGINS_ICON_SVG,
USERS_ICON_SVG,
])
expect(tags(svg)[0]!.attrs.viewBox).toBe('-8 -8 16 16');
});
it('gives the List, Game and Plugins tabs a picture no asset type has (#176, #119)', () => {
const tabs = [LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG];
it('gives the List, Game, Plugins and User management tabs a picture no asset type has (#176, #119, #179)', () => {
const tabs = [LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG, USERS_ICON_SVG];
for (const svg of tabs) expect(Object.values(ASSET_ICON_SVG)).not.toContain(svg);
expect(new Set(tabs).size).toBe(tabs.length);
});
+6
View File
@@ -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.
+2 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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),
);
+292
View File
@@ -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();
}
}
+2
View File
@@ -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,
];
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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";