diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index dc73c364..db50e934 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3a05c82d..ad40f7d7 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index e1dea9c4..262dfaaa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/index.php b/api/index.php index f5d1ed01..73009357 100644 --- a/api/index.php +++ b/api/index.php @@ -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 $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 $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 $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), diff --git a/api/lib/AccountRules.php b/api/lib/AccountRules.php new file mode 100644 index 00000000..583ceff1 --- /dev/null +++ b/api/lib/AccountRules.php @@ -0,0 +1,52 @@ +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] ?? ''); diff --git a/api/lib/Store.php b/api/lib/Store.php index 13c2f4ef..ae344c4d 100644 --- a/api/lib/Store.php +++ b/api/lib/Store.php @@ -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 */ public function list(): array + { + return $this->listOf($this->owner); + } + + /** @return list */ + 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, diff --git a/api/lib/UserStore.php b/api/lib/UserStore.php index b7a4c1d5..21883e9b 100644 --- a/api/lib/UserStore.php +++ b/api/lib/UserStore.php @@ -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 */ - 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 + */ + 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 */ + 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 */ + /** @return list */ 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 $users */ + /** @param list $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}"); diff --git a/src/core/store/AuthStore.ts b/src/core/store/AuthStore.ts index 3c94d053..802ee19b 100644 --- a/src/core/store/AuthStore.ts +++ b/src/core/store/AuthStore.ts @@ -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; - /** The user name on success; a rejected promise when the pair is wrong. */ - logIn(username: string, password: string): Promise; + session(): Promise; + /** Who signed in on success; a rejected promise when the pair is wrong. */ + logIn(username: string, password: string): Promise; logOut(): Promise; /** * 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; } +/** 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') { diff --git a/src/core/store/HttpAuthStore.ts b/src/core/store/HttpAuthStore.ts index 4152b744..03540c00 100644 --- a/src/core/store/HttpAuthStore.ts +++ b/src/core/store/HttpAuthStore.ts @@ -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 { - const body = await apiRequest<{ user: string | null }>(this.endpoint, 'GET', 'auth/session'); - return body.user; + async session(): Promise { + 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 { - const body = await apiRequest<{ user: string }>( + async logIn(username: string, password: string): Promise { + 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 { diff --git a/src/core/store/HttpUsersStore.ts b/src/core/store/HttpUsersStore.ts new file mode 100644 index 00000000..f1bb292a --- /dev/null +++ b/src/core/store/HttpUsersStore.ts @@ -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 { + const rows = await apiRequest(this.endpoint, 'GET', 'users', {}, refusal); + return rows.map((row) => ({ ...row, role: roleOf(row.role) })); + } + + async create(username: string, password: string, role: Role): Promise { + await apiRequest(this.endpoint, 'POST', 'users', this.json({ username, password, role }), refusal); + } + + async update(username: string, change: AccountChange): Promise { + await apiRequest(this.endpoint, 'PATCH', `users/${username}`, this.json(change), refusal); + } + + async remove(username: string): Promise { + await apiRequest(this.endpoint, 'DELETE', `users/${username}`, {}, refusal); + } + + private json(body: object): { headers: Record; body: string } { + return { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; + } +} diff --git a/src/core/store/UsersStore.ts b/src/core/store/UsersStore.ts new file mode 100644 index 00000000..f48cc739 --- /dev/null +++ b/src/core/store/UsersStore.ts @@ -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; + create(username: string, password: string, role: Role): Promise; + update(username: string, change: AccountChange): Promise; + remove(username: string): Promise; +} diff --git a/src/editor/App.ts b/src/editor/App.ts index 19916bde..364e14fc 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -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 { +async function signedInUser(app: Application, root: VStack, auth: AuthStore): Promise { 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((resolve) => { + const account = await new Promise((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 installPixelArtPictures(); const openSession = async (): Promise => { - 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 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); diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 38c0fd01..de24b0e3 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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; @@ -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; +type AssetKind = Exclude; -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 = { 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> = { 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), }); diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 9c55d4ec..46e45823 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -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 { 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); } diff --git a/src/editor/auth/LoginScreen.ts b/src/editor/auth/LoginScreen.ts index 0499f0de..3302688b 100644 --- a/src/editor/auth/LoginScreen.ts +++ b/src/editor/auth/LoginScreen.ts @@ -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); diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 621d4de4..667e2360 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -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:`. + */ + users: { + pane: () => + editor.usersPane ? { visible: editor.tab === 'management', ...editor.usersPane.debugInfo() } : null, + buttonAt: (username: string, action: Parameters[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], diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index f583d2b4..caf52591 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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); diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index b26e7c56..220dd5be 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -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; diff --git a/src/editor/panels/icons/users.svg b/src/editor/panels/icons/users.svg new file mode 100644 index 00000000..7a6b1684 --- /dev/null +++ b/src/editor/panels/icons/users.svg @@ -0,0 +1,19 @@ + + + + + + diff --git a/src/editor/plugins/PluginsPane.ts b/src/editor/plugins/PluginsPane.ts index 5bcd84ba..b7f4a9c9 100644 --- a/src/editor/plugins/PluginsPane.ts +++ b/src/editor/plugins/PluginsPane.ts @@ -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), diff --git a/src/editor/users/AccountDialog.ts b/src/editor/users/AccountDialog.ts new file mode 100644 index 00000000..05ce8899 --- /dev/null +++ b/src/editor/users/AccountDialog.ts @@ -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; + 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 { + 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); + } + } +} diff --git a/src/editor/users/ManagementPane.ts b/src/editor/users/ManagementPane.ts new file mode 100644 index 00000000..dade8e88 --- /dev/null +++ b/src/editor/users/ManagementPane.ts @@ -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(); + }; + } +} diff --git a/src/editor/users/UsersPane.ts b/src/editor/users/UsersPane.ts new file mode 100644 index 00000000..55f8054b --- /dev/null +++ b/src/editor/users/UsersPane.ts @@ -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; + readonly openButtons = new Map(); + + 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(); + + 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 { + 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 }[]; + } { + 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:`. 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): Promise { + try { + await work; + } catch (error) { + this.host.notice(error instanceof Error ? error.message : String(error), 'error'); + } + await this.reload(); + } +} diff --git a/src/editor/users/accountRules.ts b/src/editor/users/accountRules.ts new file mode 100644 index 00000000..27ca6de6 --- /dev/null +++ b/src/editor/users/accountRules.ts @@ -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 }; +} diff --git a/tests/core/auth.test.ts b/tests/core/auth.test.ts index 4840c42c..69463efa 100644 --- a/tests/core/auth.test.ts +++ b/tests/core/auth.test.ts @@ -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'); diff --git a/tests/core/users-store.test.ts b/tests/core/users-store.test.ts new file mode 100644 index 00000000..070eedd4 --- /dev/null +++ b/tests/core/users-store.test.ts @@ -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(() => 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 }); + }); +}); diff --git a/tests/php/accounts.test.php b/tests/php/accounts.test.php new file mode 100644 index 00000000..73baf951 --- /dev/null +++ b/tests/php/accounts.test.php @@ -0,0 +1,133 @@ + [ + ['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); diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index 1be7c1ef..a88f6e5d 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -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); }); diff --git a/tools/ci/serve.sh b/tools/ci/serve.sh index f0a57925..9702a469 100644 --- a/tools/ci/serve.sh +++ b/tools/ci/serve.sh @@ -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. diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index 0b1a919e..d090d13c 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -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 }), ); diff --git a/tools/e2e/38-plugins.mjs b/tools/e2e/38-plugins.mjs index fe4d93b4..48488f62 100644 --- a/tools/e2e/38-plugins.mjs +++ b/tools/e2e/38-plugins.mjs @@ -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(); } diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index ab24826a..ecef386f 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -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), ); diff --git a/tools/e2e/49-users.mjs b/tools/e2e/49-users.mjs new file mode 100644 index 00000000..5b1880ac --- /dev/null +++ b/tools/e2e/49-users.mjs @@ -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(); + } +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index f01bee3a..3cc78760 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -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, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 65f67043..3ad45708 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -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', ]; /** diff --git a/tools/lib/auth.mjs b/tools/lib/auth.mjs index 23077b47..c1db4bc4 100644 --- a/tools/lib/auth.mjs +++ b/tools/lib/auth.mjs @@ -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; } diff --git a/tools/make-user.php b/tools/make-user.php index e5623829..2f7de385 100644 --- a/tools/make-user.php +++ b/tools/make-user.php @@ -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 + * Without a role a new account is a `user` and an existing one keeps its role (#180). + * + * Usage: php tools/make-user.php [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 \n"); +if (count($args) < 2 || count($args) > 3 || $args[0] === '' || $args[1] === '') { + fwrite(STDERR, "Usage: php tools/make-user.php [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";