Until now every account could do the same, and managing accounts meant running make-user.php on the server. Accounts now carry a role, and admins get a tab in the editor to create, rename, re-role, reset and delete accounts. Both issues share the same server files, so they land in one commit. #180 Roles: - Every account is admin or user. A missing or unknown role counts as user, on the server and in the client. - auth/session and auth/login return {user, role}. - Auth::requireAdmin() answers everyone else with 403. - make-user.php takes the role as a third argument. Without it an existing account keeps its role, so a password reset from the CLI never demotes an admin. - Admins see and save other users' projects like their own. Saving keeps the original owner. #179 User management: - The account menu gets "User management" (admins only) and "Plugins" (everyone). Both open the same closable tab, accounts on top and plugins below. The fixed Plugins tab is gone. - Per account: Rename, Role, Password (no old password needed) and Delete with a confirmation. "New user..." creates one. - Each account lists its projects with an Open button. - Delete is refused while the account still owns projects. - Renaming moves the projects along and ends every session of the account. transfer takes the same lock as save. - On their own row an admin can only set the password. The last admin cannot be demoted or deleted. The server enforces both. - The account rules live in AccountRules.php and accountRules.ts as pure functions, so they are testable without a server. Findings: - At 1280 px the Delete button reached past the column edge and was not clickable. The name column is flexible now, and module 49 checks the layout. - The server lets an admin delete other users' projects, but the editor has no button for it yet. Left open in the issue. Tests: - ./check.sh: 148 files, 2214 tests, exit 0 (was 147 / 2204). - New tests/php/accounts.test.php, 51 checks, plain PHP. CI runs it as its own step in the gate job, the host has no PHP. - New e2e module 49 with 18 checks. CI creates the e2e-admin account in serve.sh. Modules 14, 38, 46 and the scaffold follow the new tab. - e2e 1-16,37,38,45,46,49: all passed, 581 checks. - Counter-checks: owner on save, admin view, default role and self delete in PHP fail 7, 5, 5 and 1 checks. Unchecked requireAdmin, a wider column and the caller as owner each fail exactly their e2e check. - Check sum 1314 -> 1333 (+1 in 38, +18 in 49), computed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
30fb3a2316
commit
ceaa44e5b8
38 files changed
+1803
-138
No files matched your search
@@ -33,6 +33,11 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GM_TEST_WORKERS: 2
|
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:
|
suite:
|
||||||
runs-on: game-maker-ci
|
runs-on: game-maker-ci
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/data/projects/*.json
|
/data/projects/*.json
|
||||||
/data/projects/*.tmp
|
/data/projects/*.tmp
|
||||||
/data/e2e-password.txt
|
/data/e2e-password.txt
|
||||||
|
/data/e2e-admin-password.txt
|
||||||
/data/sessions/
|
/data/sessions/
|
||||||
/data/users.json
|
/data/users.json
|
||||||
/data/textures/
|
/data/textures/
|
||||||
|
|||||||
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
+139
-12
@@ -7,15 +7,25 @@ declare(strict_types=1);
|
|||||||
* Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the
|
* 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.
|
* site's nginx configuration does not pass PATH_INFO to PHP.
|
||||||
*
|
*
|
||||||
* GET ?r=auth/session → {user} (null while nobody is signed in)
|
* GET ?r=auth/session → {user, role} (both null while nobody is signed in)
|
||||||
* POST ?r=auth/login {username,password} → {user} | 401
|
* POST ?r=auth/login {username,password} → {user, role} | 401
|
||||||
* POST ?r=auth/logout → 204
|
* POST ?r=auth/logout → 204
|
||||||
* POST ?r=auth/password {currentPassword,newPassword} → 204 | 401 | 422
|
* POST ?r=auth/password {currentPassword,newPassword} → 204 | 401 | 422
|
||||||
* (the only auth route that needs a session; ends the account's
|
* (the only auth route that needs a session; ends the account's
|
||||||
* other logins, keeps the one asking)
|
* other logins, keeps the one asking)
|
||||||
*
|
*
|
||||||
* Everything below needs a signed-in user (401 otherwise), and projects belong to one: a project
|
* 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}]
|
* GET ?r=projects → [{id,name,revision,updatedAt}]
|
||||||
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
|
* 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.
|
* `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/Auth.php';
|
||||||
require __DIR__ . '/lib/FontStore.php';
|
require __DIR__ . '/lib/FontStore.php';
|
||||||
require __DIR__ . '/lib/OriginalName.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
|
// 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.
|
// 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') {
|
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.
|
// 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::error('Wrong user name or password', 401);
|
||||||
}
|
}
|
||||||
Response::json(['user' => $user]);
|
Response::json(['user' => $user, 'role' => $auth->role($user)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($what === 'password') {
|
if ($what === 'password') {
|
||||||
@@ -132,9 +144,9 @@ function routeAuth(array $parts, string $method, Auth $auth): never
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param list<string> $parts */
|
/** @param list<string> $parts */
|
||||||
function routeProjects(array $parts, string $method, string $owner): never
|
function routeProjects(array $parts, string $method, string $owner, bool $seesAll): never
|
||||||
{
|
{
|
||||||
$store = new Store(dirname(__DIR__) . '/data/projects', $owner);
|
$store = new Store(dirname(__DIR__) . '/data/projects', $owner, $seesAll);
|
||||||
|
|
||||||
if (count($parts) === 1) {
|
if (count($parts) === 1) {
|
||||||
if ($method === 'GET') {
|
if ($method === 'GET') {
|
||||||
@@ -190,6 +202,120 @@ function routeProjects(array $parts, string $method, string $owner): never
|
|||||||
Response::error('Method not allowed', 405);
|
Response::error('Method not allowed', 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $parts */
|
||||||
|
function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never
|
||||||
|
{
|
||||||
|
$actor = $auth->requireAdmin();
|
||||||
|
$projects = new Store(dirname(__DIR__) . '/data/projects', $actor, true);
|
||||||
|
$shown = static function (string $name) use ($users): array {
|
||||||
|
foreach ($users->accounts() as $account) {
|
||||||
|
if (strcasecmp($account['username'], $name) === 0) {
|
||||||
|
return $account;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new RuntimeException("Account {$name} vanished");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (count($parts) === 1) {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
Response::json(array_map(
|
||||||
|
static fn(array $a): array => $a + ['projects' => $projects->listOf($a['username'])],
|
||||||
|
$users->accounts(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$body = Response::body();
|
||||||
|
$name = (string) ($body['username'] ?? '');
|
||||||
|
$password = (string) ($body['password'] ?? '');
|
||||||
|
$role = (string) ($body['role'] ?? 'user');
|
||||||
|
if (!UserStore::isValidName($name)) {
|
||||||
|
Response::error('A user name is 2–32 letters, digits, dots, dashes or underscores', 422);
|
||||||
|
}
|
||||||
|
if (!UserStore::isValidPassword($password)) {
|
||||||
|
Response::error('The password needs at least ' . UserStore::MIN_PASSWORD_LENGTH . ' characters', 422);
|
||||||
|
}
|
||||||
|
if (!UserStore::isValidRole($role)) {
|
||||||
|
Response::error('Unknown role', 422);
|
||||||
|
}
|
||||||
|
if ($users->exists($name)) {
|
||||||
|
Response::error("The name {$name} is taken", 409);
|
||||||
|
}
|
||||||
|
$users->put($name, $password, $role);
|
||||||
|
Response::json($shown($name), 201);
|
||||||
|
}
|
||||||
|
Response::error('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $parts[1];
|
||||||
|
if (!$users->exists($target)) {
|
||||||
|
Response::error('User not found', 404);
|
||||||
|
}
|
||||||
|
$role = $users->role($target);
|
||||||
|
|
||||||
|
if ($method === 'PATCH') {
|
||||||
|
$body = Response::body();
|
||||||
|
$newName = array_key_exists('username', $body) ? (string) $body['username'] : null;
|
||||||
|
$newRole = array_key_exists('role', $body) ? (string) $body['role'] : null;
|
||||||
|
$password = array_key_exists('password', $body) ? (string) $body['password'] : null;
|
||||||
|
// Everything is checked before anything is written: half an edit is worse than none.
|
||||||
|
if ($newName !== null && $newName !== $target) {
|
||||||
|
$refusal = AccountRules::renameRefusal($actor, $target);
|
||||||
|
if ($refusal !== null) {
|
||||||
|
Response::error($refusal, 422);
|
||||||
|
}
|
||||||
|
if (!UserStore::isValidName($newName)) {
|
||||||
|
Response::error('A user name is 2–32 letters, digits, dots, dashes or underscores', 422);
|
||||||
|
}
|
||||||
|
if (strcasecmp($newName, $target) !== 0 && $users->exists($newName)) {
|
||||||
|
Response::error("The name {$newName} is taken", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($newRole !== null) {
|
||||||
|
if (!UserStore::isValidRole($newRole)) {
|
||||||
|
Response::error('Unknown role', 422);
|
||||||
|
}
|
||||||
|
$refusal = AccountRules::roleRefusal($actor, $target, $role, $newRole, $users->adminCount());
|
||||||
|
if ($refusal !== null) {
|
||||||
|
Response::error($refusal, 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($password !== null && !UserStore::isValidPassword($password)) {
|
||||||
|
Response::error('The password needs at least ' . UserStore::MIN_PASSWORD_LENGTH . ' characters', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($password !== null) {
|
||||||
|
$auth->resetPassword($target, $password);
|
||||||
|
}
|
||||||
|
if ($newRole !== null && $newRole !== $role) {
|
||||||
|
$users->setRole($target, $newRole);
|
||||||
|
}
|
||||||
|
$name = $shown($target)['username'];
|
||||||
|
if ($newName !== null && $newName !== $name) {
|
||||||
|
$users->rename($name, $newName);
|
||||||
|
$projects->transfer($name, $newName);
|
||||||
|
$auth->endSessionsOf($name);
|
||||||
|
$name = $newName;
|
||||||
|
}
|
||||||
|
Response::json($shown($name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$refusal = AccountRules::deleteRefusal($actor, $target, $role, $users->adminCount());
|
||||||
|
if ($refusal !== null) {
|
||||||
|
Response::error($refusal, 422);
|
||||||
|
}
|
||||||
|
$refusal = AccountRules::ownerRefusal($target, count($projects->listOf($shown($target)['username'])));
|
||||||
|
if ($refusal !== null) {
|
||||||
|
Response::error($refusal, 409);
|
||||||
|
}
|
||||||
|
$users->remove($target);
|
||||||
|
$auth->endSessionsOf($target);
|
||||||
|
Response::noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::error('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
/** @param list<string> $parts */
|
/** @param list<string> $parts */
|
||||||
function routeTextures(array $parts, string $method): never
|
function routeTextures(array $parts, string $method): never
|
||||||
{
|
{
|
||||||
@@ -350,19 +476,20 @@ try {
|
|||||||
Response::error('Not found', 404);
|
Response::error('Not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$auth = new Auth(
|
$users = new UserStore(dirname(__DIR__) . '/data/users.json');
|
||||||
new UserStore(dirname(__DIR__) . '/data/users.json'),
|
$auth = new Auth($users, new SessionStore(dirname(__DIR__) . '/data/sessions'));
|
||||||
new SessionStore(dirname(__DIR__) . '/data/sessions'),
|
|
||||||
);
|
|
||||||
if ($parts[0] === 'auth') {
|
if ($parts[0] === 'auth') {
|
||||||
routeAuth($parts, $method, $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
|
// 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.
|
// price of admission, so an uploaded file cannot be dropped in by a stranger.
|
||||||
$user = $auth->require();
|
$user = $auth->require();
|
||||||
|
|
||||||
match ($parts[0]) {
|
match ($parts[0]) {
|
||||||
'projects' => routeProjects($parts, $method, $user),
|
'projects' => routeProjects($parts, $method, $user, $auth->role($user) === 'admin'),
|
||||||
'textures' => routeTextures($parts, $method),
|
'textures' => routeTextures($parts, $method),
|
||||||
'fonts' => routeFonts($parts, $method),
|
'fonts' => routeFonts($parts, $method),
|
||||||
'sounds' => routeSounds($parts, $method),
|
'sounds' => routeSounds($parts, $method),
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an administrator may not do to an account (#179), as plain functions of the facts: each
|
||||||
|
* answers the refusal to send back, or null when the change may go ahead.
|
||||||
|
*
|
||||||
|
* An administrator's own name and role are off limits here – renaming yourself would end the
|
||||||
|
* session you are using, and demoting yourself would lock you out of the page you are on. Their
|
||||||
|
* own password may be set like anyone's.
|
||||||
|
*/
|
||||||
|
final class AccountRules
|
||||||
|
{
|
||||||
|
public static function renameRefusal(string $actor, string $target): ?string
|
||||||
|
{
|
||||||
|
return strcasecmp($actor, $target) === 0 ? 'You cannot rename your own account' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function roleRefusal(string $actor, string $target, string $currentRole, string $newRole, int $admins): ?string
|
||||||
|
{
|
||||||
|
if (strcasecmp($actor, $target) === 0 && $newRole !== $currentRole) {
|
||||||
|
return 'You cannot change your own role';
|
||||||
|
}
|
||||||
|
if ($currentRole === 'admin' && $newRole !== 'admin' && $admins <= 1) {
|
||||||
|
return 'The last administrator cannot be demoted';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deleteRefusal(string $actor, string $target, string $role, int $admins): ?string
|
||||||
|
{
|
||||||
|
if (strcasecmp($actor, $target) === 0) {
|
||||||
|
return 'You cannot delete your own account';
|
||||||
|
}
|
||||||
|
if ($role === 'admin' && $admins <= 1) {
|
||||||
|
return 'The last administrator cannot be deleted';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account with projects is not deleted: they would belong to nobody and vanish from every
|
||||||
|
* list. The administrator deletes them first – there is no way to hand them on yet.
|
||||||
|
*/
|
||||||
|
public static function ownerRefusal(string $target, int $projects): ?string
|
||||||
|
{
|
||||||
|
if ($projects === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return "{$target} still owns {$projects} " . ($projects === 1 ? 'project' : 'projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,26 @@ final class Auth
|
|||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in user if they are an administrator (#180), else 401 or 403. Every route that
|
||||||
|
* may do more than a user goes through here: the editor only hides what it may not offer, the
|
||||||
|
* decision is made on this side.
|
||||||
|
*/
|
||||||
|
public function requireAdmin(): string
|
||||||
|
{
|
||||||
|
$user = $this->require();
|
||||||
|
if ($this->users->role($user) !== 'admin') {
|
||||||
|
Response::error('Administrators only', 403);
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `admin` or `user`; the session answer carries it so the editor knows what to offer. */
|
||||||
|
public function role(string $user): string
|
||||||
|
{
|
||||||
|
return $this->users->role($user);
|
||||||
|
}
|
||||||
|
|
||||||
/** Checks the pair, starts a session and sets the cookie. Null when the pair is wrong. */
|
/** Checks the pair, starts a session and sets the cookie. Null when the pair is wrong. */
|
||||||
public function logIn(string $username, string $password): ?string
|
public function logIn(string $username, string $password): ?string
|
||||||
{
|
{
|
||||||
@@ -68,6 +88,22 @@ final class Auth
|
|||||||
return true;
|
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
|
private function token(): string
|
||||||
{
|
{
|
||||||
return (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
|
return (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
|
||||||
|
|||||||
+63
-17
@@ -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
|
* 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,
|
* `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.
|
* 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
|
final class Store
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $dir,
|
private readonly string $dir,
|
||||||
private readonly string $owner,
|
private readonly string $owner,
|
||||||
|
private readonly bool $seesAll = false,
|
||||||
) {
|
) {
|
||||||
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
|
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
|
||||||
throw new RuntimeException("Cannot create data directory {$this->dir}");
|
throw new RuntimeException("Cannot create data directory {$this->dir}");
|
||||||
@@ -43,11 +48,17 @@ final class Store
|
|||||||
|
|
||||||
/** @return list<array{id:string,name:string,revision:int,updatedAt:string}> */
|
/** @return list<array{id:string,name:string,revision:int,updatedAt:string}> */
|
||||||
public function list(): array
|
public function list(): array
|
||||||
|
{
|
||||||
|
return $this->listOf($this->owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{id:string,name:string,revision:int,updatedAt:string}> */
|
||||||
|
public function listOf(string $owner): array
|
||||||
{
|
{
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
|
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
|
||||||
$doc = $this->readFile($file);
|
$doc = $this->readFile($file);
|
||||||
if ($doc === null || $doc['owner'] !== $this->owner) {
|
if ($doc === null || $doc['owner'] !== $owner) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$items[] = [
|
$items[] = [
|
||||||
@@ -81,7 +92,7 @@ final class Store
|
|||||||
'assets' => [],
|
'assets' => [],
|
||||||
'activeSceneId' => '',
|
'activeSceneId' => '',
|
||||||
];
|
];
|
||||||
$doc = $this->document($id, $name, 1, $project);
|
$doc = $this->document($id, $name, $this->owner, 1, $project);
|
||||||
$this->writeFile($this->path($id), $doc);
|
$this->writeFile($this->path($id), $doc);
|
||||||
return $doc;
|
return $doc;
|
||||||
}
|
}
|
||||||
@@ -94,11 +105,7 @@ final class Store
|
|||||||
public function save(string $id, int $expectedRevision, array $project): ?array
|
public function save(string $id, int $expectedRevision, array $project): ?array
|
||||||
{
|
{
|
||||||
$path = $this->path($id);
|
$path = $this->path($id);
|
||||||
$lock = fopen($path . '.lock', 'c');
|
return $this->locked($path, function () use ($path, $id, $expectedRevision, $project): ?array {
|
||||||
if ($lock === false || !flock($lock, LOCK_EX)) {
|
|
||||||
throw new RuntimeException('Cannot acquire project lock');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$current = $this->own($this->readFile($path));
|
$current = $this->own($this->readFile($path));
|
||||||
if ($current === null) {
|
if ($current === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -108,14 +115,10 @@ final class Store
|
|||||||
}
|
}
|
||||||
$name = is_string($project['name'] ?? null) && $project['name'] !== '' ? $project['name'] : $current['name'];
|
$name = is_string($project['name'] ?? null) && $project['name'] !== '' ? $project['name'] : $current['name'];
|
||||||
$project['id'] = $id;
|
$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);
|
$this->writeFile($path, $doc);
|
||||||
return $doc;
|
return $doc;
|
||||||
} finally {
|
});
|
||||||
flock($lock, LOCK_UN);
|
|
||||||
fclose($lock);
|
|
||||||
@unlink($path . '.lock');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(string $id): bool
|
public function delete(string $id): bool
|
||||||
@@ -127,10 +130,53 @@ final class Store
|
|||||||
return file_exists($path) && unlink($path);
|
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
|
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
|
private function path(string $id): string
|
||||||
@@ -138,12 +184,12 @@ final class Store
|
|||||||
return $this->dir . '/' . $id . '.json';
|
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 [
|
return [
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'owner' => $this->owner,
|
'owner' => $owner,
|
||||||
'revision' => $revision,
|
'revision' => $revision,
|
||||||
'updatedAt' => gmdate('c'),
|
'updatedAt' => gmdate('c'),
|
||||||
'project' => $project,
|
'project' => $project,
|
||||||
|
|||||||
+128
-21
@@ -2,11 +2,15 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The accounts, as one JSON file: `data/users.json` holding `{username, password}` per user,
|
* The accounts, as one JSON file: `data/users.json` holding `{username, password, role, createdAt}`
|
||||||
* where `password` is a hash from `password_hash` – the plain one is never stored.
|
* 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
|
* An entry without `role` is a `user` (#180): the weaker of the two is what a missing field means,
|
||||||
* up is a follow-up of its own, and until it exists nobody may create an account over the wire.
|
* 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
|
final class UserStore
|
||||||
{
|
{
|
||||||
@@ -17,6 +21,8 @@ final class UserStore
|
|||||||
*/
|
*/
|
||||||
public const MIN_PASSWORD_LENGTH = 8;
|
public const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
|
public const ROLES = ['admin', 'user'];
|
||||||
|
|
||||||
public function __construct(private readonly string $file) {}
|
public function __construct(private readonly string $file) {}
|
||||||
|
|
||||||
public static function isValidName(string $name): bool
|
public static function isValidName(string $name): bool
|
||||||
@@ -29,6 +35,11 @@ final class UserStore
|
|||||||
return strlen($password) >= self::MIN_PASSWORD_LENGTH;
|
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. */
|
/** The name as stored, or null when the pair does not match an account. */
|
||||||
public function verify(string $username, string $password): ?string
|
public function verify(string $username, string $password): ?string
|
||||||
{
|
{
|
||||||
@@ -44,30 +55,119 @@ final class UserStore
|
|||||||
return $this->find($username) !== null;
|
return $this->find($username) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** `user` for an account that does not exist, the same as for one without a role. */
|
||||||
public function names(): array
|
public function role(string $username): string
|
||||||
{
|
{
|
||||||
return array_map(static fn(array $u): string => $u['username'], $this->all());
|
return $this->find($username)['role'] ?? 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds the account or replaces the password of one that is already there. */
|
/**
|
||||||
public function put(string $username, string $password): void
|
* Every account as the API may show it – never the hash.
|
||||||
|
*
|
||||||
|
* @return list<array{username:string,role:string,createdAt:string}>
|
||||||
|
*/
|
||||||
|
public function accounts(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn(array $u): array => [
|
||||||
|
'username' => $u['username'],
|
||||||
|
'role' => $u['role'],
|
||||||
|
'createdAt' => $u['createdAt'],
|
||||||
|
],
|
||||||
|
$this->all(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminCount(): int
|
||||||
|
{
|
||||||
|
return count(array_filter($this->all(), static fn(array $u): bool => $u['role'] === 'admin'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the account or replaces the password of one that is already there. Without `$role` a new
|
||||||
|
* account is a `user` and an existing one keeps what it has, so resetting a password from the
|
||||||
|
* command line never demotes an administrator.
|
||||||
|
*/
|
||||||
|
public function put(string $username, string $password, ?string $role = null): void
|
||||||
{
|
{
|
||||||
if (!self::isValidName($username)) {
|
if (!self::isValidName($username)) {
|
||||||
throw new InvalidArgumentException("Invalid user name: {$username}");
|
throw new InvalidArgumentException("Invalid user name: {$username}");
|
||||||
}
|
}
|
||||||
$users = array_values(
|
if ($role !== null && !self::isValidRole($role)) {
|
||||||
array_filter(
|
throw new InvalidArgumentException("Invalid role: {$role}");
|
||||||
$this->all(),
|
}
|
||||||
static fn(array $u): bool => strcasecmp($u['username'], $username) !== 0,
|
$existing = $this->find($username);
|
||||||
),
|
$users = $this->without($username);
|
||||||
);
|
$users[] = [
|
||||||
$users[] = ['username' => $username, 'password' => password_hash($password, PASSWORD_DEFAULT)];
|
'username' => $existing['username'] ?? $username,
|
||||||
usort($users, static fn(array $a, array $b): int => strcmp($a['username'], $b['username']));
|
'password' => password_hash($password, PASSWORD_DEFAULT),
|
||||||
|
'role' => $role ?? $existing['role'] ?? 'user',
|
||||||
|
'createdAt' => $existing !== null ? $existing['createdAt'] : gmdate('c'),
|
||||||
|
];
|
||||||
$this->write($users);
|
$this->write($users);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{username:string,password:string}|null */
|
/** @return bool False when there is no such account. */
|
||||||
|
public function setRole(string $username, string $role): bool
|
||||||
|
{
|
||||||
|
if (!self::isValidRole($role)) {
|
||||||
|
throw new InvalidArgumentException("Invalid role: {$role}");
|
||||||
|
}
|
||||||
|
return $this->change($username, static fn(array $u): array => ['role' => $role] + $u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives an account a new name. Only the name: the projects that carry the old one as `owner`
|
||||||
|
* and the sessions that do are the caller's to move (see `Store::transfer`).
|
||||||
|
*
|
||||||
|
* @return bool False when there is no such account.
|
||||||
|
* @throws InvalidArgumentException when the new name is invalid or taken by another account.
|
||||||
|
*/
|
||||||
|
public function rename(string $from, string $to): bool
|
||||||
|
{
|
||||||
|
if (!self::isValidName($to)) {
|
||||||
|
throw new InvalidArgumentException("Invalid user name: {$to}");
|
||||||
|
}
|
||||||
|
$other = $this->find($to);
|
||||||
|
// Only a change of case is the same account; any other match is somebody else's name.
|
||||||
|
if ($other !== null && strcasecmp($other['username'], $from) !== 0) {
|
||||||
|
throw new InvalidArgumentException("The name {$to} is taken");
|
||||||
|
}
|
||||||
|
return $this->change($from, static fn(array $u): array => ['username' => $to] + $u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return bool False when there is no such account. */
|
||||||
|
public function remove(string $username): bool
|
||||||
|
{
|
||||||
|
if ($this->find($username) === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->write($this->without($username));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param callable(array):array $edit */
|
||||||
|
private function change(string $username, callable $edit): bool
|
||||||
|
{
|
||||||
|
$user = $this->find($username);
|
||||||
|
if ($user === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$users = $this->without($username);
|
||||||
|
$users[] = $edit($user);
|
||||||
|
$this->write($users);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{username:string,password:string,role:string,createdAt:string}> */
|
||||||
|
private function without(string $username): array
|
||||||
|
{
|
||||||
|
return array_values(
|
||||||
|
array_filter($this->all(), static fn(array $u): bool => strcasecmp($u['username'], $username) !== 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{username:string,password:string,role:string,createdAt:string}|null */
|
||||||
private function find(string $username): ?array
|
private function find(string $username): ?array
|
||||||
{
|
{
|
||||||
foreach ($this->all() as $user) {
|
foreach ($this->all() as $user) {
|
||||||
@@ -78,7 +178,7 @@ final class UserStore
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<array{username:string,password:string}> */
|
/** @return list<array{username:string,password:string,role:string,createdAt:string}> */
|
||||||
private function all(): array
|
private function all(): array
|
||||||
{
|
{
|
||||||
if (!is_file($this->file)) {
|
if (!is_file($this->file)) {
|
||||||
@@ -92,15 +192,22 @@ final class UserStore
|
|||||||
$users = [];
|
$users = [];
|
||||||
foreach ($data['users'] as $user) {
|
foreach ($data['users'] as $user) {
|
||||||
if (is_array($user) && is_string($user['username'] ?? null) && is_string($user['password'] ?? null)) {
|
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;
|
return $users;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param list<array{username:string,password:string}> $users */
|
/** @param list<array{username:string,password:string,role:string,createdAt:string}> $users */
|
||||||
private function write(array $users): void
|
private function write(array $users): void
|
||||||
{
|
{
|
||||||
|
usort($users, static fn(array $a, array $b): int => strcmp($a['username'], $b['username']));
|
||||||
$dir = dirname($this->file);
|
$dir = dirname($this->file);
|
||||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||||
throw new RuntimeException("Cannot create data directory {$dir}");
|
throw new RuntimeException("Cannot create data directory {$dir}");
|
||||||
|
|||||||
@@ -2,11 +2,20 @@
|
|||||||
* Signing in and out. The token itself never reaches this code: the server keeps the session and
|
* 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.
|
* 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 {
|
export interface AuthStore {
|
||||||
/** The signed-in user, or null while nobody is. */
|
/** The signed-in user, or null while nobody is. */
|
||||||
session(): Promise<string | null>;
|
session(): Promise<SignedIn | null>;
|
||||||
/** The user name on success; a rejected promise when the pair is wrong. */
|
/** Who signed in on success; a rejected promise when the pair is wrong. */
|
||||||
logIn(username: string, password: string): Promise<string>;
|
logIn(username: string, password: string): Promise<SignedIn>;
|
||||||
logOut(): Promise<void>;
|
logOut(): Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Replaces the password of the signed-in account. The current one has to come along even though
|
* Replaces the password of the signed-in account. The current one has to come along even though
|
||||||
@@ -16,6 +25,11 @@ export interface AuthStore {
|
|||||||
changePassword(currentPassword: string, newPassword: string): Promise<void>;
|
changePassword(currentPassword: string, newPassword: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Anything the server did not call `admin` is a `user`: the weaker reading is the safe one. */
|
||||||
|
export function roleOf(value: unknown): Role {
|
||||||
|
return value === 'admin' ? 'admin' : 'user';
|
||||||
|
}
|
||||||
|
|
||||||
/** Thrown for a wrong user name or password, as opposed to a server that is not there. */
|
/** Thrown for a wrong user name or password, as opposed to a server that is not there. */
|
||||||
export class WrongCredentialsError extends Error {
|
export class WrongCredentialsError extends Error {
|
||||||
constructor(message = 'Wrong user name or password') {
|
constructor(message = 'Wrong user name or password') {
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { type AuthStore, WrongCredentialsError } from './AuthStore';
|
import { type AuthStore, roleOf, type SignedIn, WrongCredentialsError } from './AuthStore';
|
||||||
import { apiRequest } from './http';
|
import { apiRequest } from './http';
|
||||||
|
|
||||||
export class HttpAuthStore implements AuthStore {
|
export class HttpAuthStore implements AuthStore {
|
||||||
constructor(private readonly endpoint: string) {}
|
constructor(private readonly endpoint: string) {}
|
||||||
|
|
||||||
async session(): Promise<string | null> {
|
async session(): Promise<SignedIn | null> {
|
||||||
const body = await apiRequest<{ user: string | null }>(this.endpoint, 'GET', 'auth/session');
|
const body = await apiRequest<{ user: string | null; role?: unknown }>(
|
||||||
return body.user;
|
this.endpoint,
|
||||||
|
'GET',
|
||||||
|
'auth/session',
|
||||||
|
);
|
||||||
|
return body.user === null ? null : { user: body.user, role: roleOf(body.role) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async logIn(username: string, password: string): Promise<string> {
|
async logIn(username: string, password: string): Promise<SignedIn> {
|
||||||
const body = await apiRequest<{ user: string }>(
|
const body = await apiRequest<{ user: string; role?: unknown }>(
|
||||||
this.endpoint,
|
this.endpoint,
|
||||||
'POST',
|
'POST',
|
||||||
'auth/login',
|
'auth/login',
|
||||||
@@ -23,7 +27,7 @@ export class HttpAuthStore implements AuthStore {
|
|||||||
(status, data) =>
|
(status, data) =>
|
||||||
status === 401 ? new WrongCredentialsError((data as { error?: string } | null)?.error) : undefined,
|
status === 401 ? new WrongCredentialsError((data as { error?: string } | null)?.error) : undefined,
|
||||||
);
|
);
|
||||||
return body.user;
|
return { user: body.user, role: roleOf(body.role) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async logOut(): Promise<void> {
|
async logOut(): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { type Role, roleOf } from './AuthStore';
|
||||||
|
import { apiRequest } from './http';
|
||||||
|
import type { Account, AccountChange, UsersStore } from './UsersStore';
|
||||||
|
|
||||||
|
/** A refusal of the API (4xx with an `{error}`) as its bare sentence, without route and status around it. */
|
||||||
|
function refusal(status: number, data: unknown): Error | undefined {
|
||||||
|
const message = (data as { error?: unknown } | null)?.error;
|
||||||
|
return status >= 400 && status < 500 && typeof message === 'string' ? new Error(message) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpUsersStore implements UsersStore {
|
||||||
|
constructor(private readonly endpoint: string) {}
|
||||||
|
|
||||||
|
async list(): Promise<Account[]> {
|
||||||
|
const rows = await apiRequest<Account[]>(this.endpoint, 'GET', 'users', {}, refusal);
|
||||||
|
return rows.map((row) => ({ ...row, role: roleOf(row.role) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(username: string, password: string, role: Role): Promise<void> {
|
||||||
|
await apiRequest(this.endpoint, 'POST', 'users', this.json({ username, password, role }), refusal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(username: string, change: AccountChange): Promise<void> {
|
||||||
|
await apiRequest(this.endpoint, 'PATCH', `users/${username}`, this.json(change), refusal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(username: string): Promise<void> {
|
||||||
|
await apiRequest<void>(this.endpoint, 'DELETE', `users/${username}`, {}, refusal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private json(body: object): { headers: Record<string, string>; body: string } {
|
||||||
|
return { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Role } from './AuthStore';
|
||||||
|
|
||||||
|
/** A project as the user management lists it under its owner. */
|
||||||
|
export interface OwnedProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One account as an administrator sees it – never the password. */
|
||||||
|
export interface Account {
|
||||||
|
username: string;
|
||||||
|
role: Role;
|
||||||
|
/** ISO date; empty for the accounts from before #179, which were made without one. */
|
||||||
|
createdAt: string;
|
||||||
|
projects: OwnedProject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What an administrator changes about an account in one go; a field left out stays. */
|
||||||
|
export interface AccountChange {
|
||||||
|
username?: string;
|
||||||
|
role?: Role;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accounts, for administrators only (#179). The server checks the role on every call: a user
|
||||||
|
* who got hold of this would get 403s, nothing else. A refusal rejects with the server's own
|
||||||
|
* sentence as the message, so a dialog can show it as it stands.
|
||||||
|
*/
|
||||||
|
export interface UsersStore {
|
||||||
|
list(): Promise<Account[]>;
|
||||||
|
create(username: string, password: string, role: Role): Promise<void>;
|
||||||
|
update(username: string, change: AccountChange): Promise<void>;
|
||||||
|
remove(username: string): Promise<void>;
|
||||||
|
}
|
||||||
+10
-7
@@ -1,8 +1,9 @@
|
|||||||
import type { Application } from 'pixi.js';
|
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 { HttpAuthStore } from '../core/store/HttpAuthStore';
|
||||||
import { HttpFileStore } from '../core/store/HttpFileStore';
|
import { HttpFileStore } from '../core/store/HttpFileStore';
|
||||||
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
||||||
|
import { HttpUsersStore } from '../core/store/HttpUsersStore';
|
||||||
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
|
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
|
||||||
import { loadPhysics } from '../runtime/physics/engine';
|
import { loadPhysics } from '../runtime/physics/engine';
|
||||||
import { requestRender, type VStack } from '../ui';
|
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
|
* 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.
|
* deployment replace the editor under an open tab without asking for the password again.
|
||||||
*/
|
*/
|
||||||
async function signedInUser(app: Application, root: VStack, auth: AuthStore): Promise<string> {
|
async function signedInUser(app: Application, root: VStack, auth: AuthStore): Promise<SignedIn> {
|
||||||
const existing = await auth.session();
|
const existing = await auth.session();
|
||||||
if (existing !== null) return existing;
|
if (existing !== null) return existing;
|
||||||
|
|
||||||
let done: (user: string) => void = () => {};
|
let done: (account: SignedIn) => void = () => {};
|
||||||
const screen = new LoginScreen(auth, (user) => done(user));
|
const screen = new LoginScreen(auth, (account) => done(account));
|
||||||
const removeHook = installLoginHook(app, screen);
|
const removeHook = installLoginHook(app, screen);
|
||||||
const user = await new Promise<string>((resolve) => {
|
const account = await new Promise<SignedIn>((resolve) => {
|
||||||
done = resolve;
|
done = resolve;
|
||||||
root.add(screen, 1);
|
root.add(screen, 1);
|
||||||
root.resize(app.screen.width, app.screen.height);
|
root.resize(app.screen.width, app.screen.height);
|
||||||
@@ -59,7 +60,7 @@ async function signedInUser(app: Application, root: VStack, auth: AuthStore): Pr
|
|||||||
// the corner.
|
// the corner.
|
||||||
root.resize(0, 0);
|
root.resize(0, 0);
|
||||||
screen.destroy({ children: true });
|
screen.destroy({ children: true });
|
||||||
return user;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,7 +88,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
|||||||
installPixelArtPictures();
|
installPixelArtPictures();
|
||||||
|
|
||||||
const openSession = async (): Promise<void> => {
|
const openSession = async (): Promise<void> => {
|
||||||
const user = await signedInUser(app, root, auth);
|
const { user, role } = await signedInUser(app, root, auth);
|
||||||
// Who is signed in goes into the window title: the toolbar is crowded enough, and the title is
|
// 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.
|
// also what tells two tabs on two accounts apart.
|
||||||
document.title = `Game Maker – ${user}`;
|
document.title = `Game Maker – ${user}`;
|
||||||
@@ -109,6 +110,8 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
|||||||
new HttpFileStore(API_ENDPOINT, 'sounds'),
|
new HttpFileStore(API_ENDPOINT, 'sounds'),
|
||||||
{
|
{
|
||||||
user,
|
user,
|
||||||
|
role,
|
||||||
|
users: new HttpUsersStore(API_ENDPOINT),
|
||||||
logOut: () =>
|
logOut: () =>
|
||||||
void endSession(editor).catch((error: unknown) => {
|
void endSession(editor).catch((error: unknown) => {
|
||||||
console.error('[auth] Signing out failed:', error);
|
console.error('[auth] Signing out failed:', error);
|
||||||
|
|||||||
+68
-16
@@ -1,17 +1,20 @@
|
|||||||
import type { Application, Ticker } from 'pixi.js';
|
import type { Application, Ticker } from 'pixi.js';
|
||||||
import type { Asset } from '../core/model/Asset';
|
import type { Asset } from '../core/model/Asset';
|
||||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||||
|
import type { Role } from '../core/store/AuthStore';
|
||||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||||
import { pluginsOf } from '../core/store/projectPlugins';
|
import { pluginsOf } from '../core/store/projectPlugins';
|
||||||
import type { FontStore } from '../core/store/FontStore';
|
import type { FontStore } from '../core/store/FontStore';
|
||||||
import type { SoundStore } from '../core/store/SoundStore';
|
import type { SoundStore } from '../core/store/SoundStore';
|
||||||
import type { TextureStore } from '../core/store/TextureStore';
|
import type { TextureStore } from '../core/store/TextureStore';
|
||||||
|
import type { UsersStore } from '../core/store/UsersStore';
|
||||||
import { gameClock, timeStep } from '../core/time';
|
import { gameClock, timeStep } from '../core/time';
|
||||||
import {
|
import {
|
||||||
clamp,
|
clamp,
|
||||||
HStack,
|
HStack,
|
||||||
isRendering,
|
isRendering,
|
||||||
Menu,
|
Menu,
|
||||||
|
type MenuEntry,
|
||||||
Panel,
|
Panel,
|
||||||
renderCount,
|
renderCount,
|
||||||
requestRender,
|
requestRender,
|
||||||
@@ -42,7 +45,13 @@ import { resolveList } from './list/slot';
|
|||||||
import { AnimationEditor } from './animation/AnimationEditor';
|
import { AnimationEditor } from './animation/AnimationEditor';
|
||||||
import { TileEditor } from './tile/TileEditor';
|
import { TileEditor } from './tile/TileEditor';
|
||||||
import { AssetPanel } from './panels/AssetPanel';
|
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 { HierarchyPanel } from './panels/HierarchyPanel';
|
||||||
import { TilemapPanel } from './panels/TilemapPanel';
|
import { TilemapPanel } from './panels/TilemapPanel';
|
||||||
import { Toolbar } from './panels/Toolbar';
|
import { Toolbar } from './panels/Toolbar';
|
||||||
@@ -60,6 +69,8 @@ import { TextureEditor } from './texture/TextureEditor';
|
|||||||
import { installFileImport } from './fileImport';
|
import { installFileImport } from './fileImport';
|
||||||
import type { PluginManager } from './plugins/loader';
|
import type { PluginManager } from './plugins/loader';
|
||||||
import { PluginsPane } from './plugins/PluginsPane';
|
import { PluginsPane } from './plugins/PluginsPane';
|
||||||
|
import { ManagementPane } from './users/ManagementPane';
|
||||||
|
import { UsersPane } from './users/UsersPane';
|
||||||
|
|
||||||
export type CentreTab =
|
export type CentreTab =
|
||||||
| 'scene'
|
| 'scene'
|
||||||
@@ -75,18 +86,24 @@ export type CentreTab =
|
|||||||
| 'tile'
|
| 'tile'
|
||||||
| 'container'
|
| 'container'
|
||||||
| 'list'
|
| 'list'
|
||||||
| 'plugins';
|
| 'management';
|
||||||
|
|
||||||
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
|
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
|
||||||
export type PluginTabId = `plugin:${string}`;
|
export type PluginTabId = `plugin:${string}`;
|
||||||
/** What is showing: a fixed tab, a plugin's, or the kind of the open document on screen. */
|
/** What is showing: a fixed tab, a plugin's, or the kind of the open document on screen. */
|
||||||
export type TabId = CentreTab | PluginTabId;
|
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. */
|
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
|
||||||
export interface EditorAccount {
|
export interface EditorAccount {
|
||||||
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
|
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
|
||||||
user: string;
|
user: string;
|
||||||
|
/** What the editor offers: an administrator gets the user management (#180, #179). */
|
||||||
|
role: Role;
|
||||||
|
users: UsersStore;
|
||||||
logOut: () => void;
|
logOut: () => void;
|
||||||
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
|
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
|
||||||
changePassword: (current: string, next: string) => Promise<void>;
|
changePassword: (current: string, next: string) => Promise<void>;
|
||||||
@@ -97,9 +114,9 @@ export type StripTab = FixedTab | PluginTabId | DocumentId;
|
|||||||
|
|
||||||
type FixedTab = (typeof FIXED_TABS)[number];
|
type FixedTab = (typeof FIXED_TABS)[number];
|
||||||
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
|
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
|
||||||
type AssetKind = Exclude<CentreTab, FixedTab | 'list'>;
|
type AssetKind = Exclude<CentreTab, FixedTab | 'list' | 'management'>;
|
||||||
|
|
||||||
const FIXED_TABS = ['scene', 'game', 'plugins'] as const;
|
const FIXED_TABS = ['scene', 'game'] as const;
|
||||||
|
|
||||||
const PANE_ORDER = [
|
const PANE_ORDER = [
|
||||||
'scene',
|
'scene',
|
||||||
@@ -115,7 +132,7 @@ const PANE_ORDER = [
|
|||||||
'tile',
|
'tile',
|
||||||
'container',
|
'container',
|
||||||
'list',
|
'list',
|
||||||
'plugins',
|
'management',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** The fixed tabs' names, and a document's kind as a word (its tab shows the icon). */
|
/** The fixed tabs' names, and a document's kind as a word (its tab shows the icon). */
|
||||||
@@ -133,18 +150,17 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
|||||||
tile: 'Tile',
|
tile: 'Tile',
|
||||||
container: 'Container',
|
container: 'Container',
|
||||||
list: 'List',
|
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. */
|
/** Icons of the fixed tabs (#176); a plugin's own tab has none, it brings no picture of its own. */
|
||||||
const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
|
const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
|
||||||
scene: ASSET_ICON_SVG.scene,
|
scene: ASSET_ICON_SVG.scene,
|
||||||
game: GAME_ICON_SVG,
|
game: GAME_ICON_SVG,
|
||||||
plugins: PLUGINS_ICON_SVG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function isDocument(tab: StripTab): tab is DocumentId {
|
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;
|
const MIN_LEFT_WIDTH = 160;
|
||||||
@@ -178,8 +194,11 @@ export class Editor implements PlayHost {
|
|||||||
readonly tileEditor: TileEditor;
|
readonly tileEditor: TileEditor;
|
||||||
readonly containerEditor: ContainerEditor;
|
readonly containerEditor: ContainerEditor;
|
||||||
readonly listEditor: ListEditor;
|
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();
|
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 hierarchy: HierarchyPanel;
|
||||||
readonly assets: AssetPanel;
|
readonly assets: AssetPanel;
|
||||||
readonly inspector: Inspector;
|
readonly inspector: Inspector;
|
||||||
@@ -285,8 +304,18 @@ export class Editor implements PlayHost {
|
|||||||
this.inspector.liveVariables = (assetId) =>
|
this.inspector.liveVariables = (assetId) =>
|
||||||
this.play.liveVariables(assetId) ?? this.editorRun.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, {
|
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()),
|
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||||||
togglePause: () => this.play.togglePause(),
|
togglePause: () => this.play.togglePause(),
|
||||||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||||||
@@ -347,7 +376,7 @@ export class Editor implements PlayHost {
|
|||||||
game: this.gameView,
|
game: this.gameView,
|
||||||
...this.assetEditors,
|
...this.assetEditors,
|
||||||
list: this.listEditor,
|
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.
|
// Following a call goes on in the same tab: the tab changes its document, not its place.
|
||||||
this.graphEditor.onRetarget = (from, to) => {
|
this.graphEditor.onRetarget = (from, to) => {
|
||||||
@@ -440,6 +469,19 @@ export class Editor implements PlayHost {
|
|||||||
state.on('mode', () => this.editorRun.invalidate());
|
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
|
* 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.
|
* 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.documents = closeDocument(this.documents, doc);
|
||||||
this.tabs.removeTab(doc);
|
this.tabs.removeTab(doc);
|
||||||
const kind = this.kindOf(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.
|
// 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 (doc === 'list' && this.state.listSlot) this.state.openList(null);
|
||||||
if (!wasShowing) return;
|
if (!wasShowing) return;
|
||||||
@@ -557,6 +600,10 @@ export class Editor implements PlayHost {
|
|||||||
if (this.state.listSlot) this.openDocument('list');
|
if (this.state.listSlot) this.openDocument('list');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (tab === 'management') {
|
||||||
|
this.openDocument('management');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (tab in this.assetEditors) {
|
if (tab in this.assetEditors) {
|
||||||
const editor = this.assetEditors[tab as AssetKind];
|
const editor = this.assetEditors[tab as AssetKind];
|
||||||
const id =
|
const id =
|
||||||
@@ -577,6 +624,7 @@ export class Editor implements PlayHost {
|
|||||||
|
|
||||||
private kindOf(doc: DocumentId): CentreTab | null {
|
private kindOf(doc: DocumentId): CentreTab | null {
|
||||||
if (doc === 'list') return this.state.listSlot ? 'list' : 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 type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||||||
const kind = type ? TAB_OF_ASSET[type] : undefined;
|
const kind = type ? TAB_OF_ASSET[type] : undefined;
|
||||||
return kind && kind !== 'scene' ? kind : null;
|
return kind && kind !== 'scene' ? kind : null;
|
||||||
@@ -585,12 +633,15 @@ export class Editor implements PlayHost {
|
|||||||
private showDocument(doc: DocumentId): void {
|
private showDocument(doc: DocumentId): void {
|
||||||
const kind = this.kindOf(doc);
|
const kind = this.kindOf(doc);
|
||||||
if (!kind) return;
|
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);
|
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. */
|
/** 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 {
|
private labelOf(doc: DocumentId): string {
|
||||||
|
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
|
||||||
if (doc === 'list') {
|
if (doc === 'list') {
|
||||||
const slot = this.state.listSlot;
|
const slot = this.state.listSlot;
|
||||||
return (slot && resolveList(this.state, slot)?.title) ?? '';
|
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. */
|
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
|
||||||
private iconOf(doc: DocumentId): string {
|
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;
|
const type = doc === 'list' ? undefined : findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||||||
return type ? ASSET_ICON_SVG[type] : LIST_ICON_SVG;
|
return type ? ASSET_ICON_SVG[type] : LIST_ICON_SVG;
|
||||||
}
|
}
|
||||||
@@ -613,7 +665,7 @@ export class Editor implements PlayHost {
|
|||||||
const kind = this.kindOf(doc);
|
const kind = this.kindOf(doc);
|
||||||
if (!this.tabs.has(doc) && kind)
|
if (!this.tabs.has(doc) && kind)
|
||||||
this.tabs.addTab(doc, this.labelOf(doc), base + index, {
|
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),
|
icon: this.iconOf(doc),
|
||||||
onClose: () => this.closeDocument(doc),
|
onClose: () => this.closeDocument(doc),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export class ProjectSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens a project by its id – from the user management, where it may be somebody else's (#179). */
|
||||||
|
openProject(id: string): void {
|
||||||
|
void this.run(this.open(id));
|
||||||
|
}
|
||||||
|
|
||||||
private async open(id: string): Promise<void> {
|
private async open(id: string): Promise<void> {
|
||||||
this.beforeSwitch();
|
this.beforeSwitch();
|
||||||
const loaded = await this.store.load(id, this.prepare);
|
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();
|
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[] = [
|
const items: MenuEntry[] = [
|
||||||
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
|
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
|
||||||
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
|
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
|
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
|
||||||
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
|
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
|
||||||
|
...more,
|
||||||
];
|
];
|
||||||
new Menu(items).openBelow(anchor);
|
new Menu(items).openBelow(anchor);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Graphics } from 'pixi.js';
|
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 { Button, drawControlBox, Label, Spacer, TextField, Theme, ui, VStack } from '../../ui';
|
||||||
import { FormStatus, refocus } from './FormStatus';
|
import { FormStatus, refocus } from './FormStatus';
|
||||||
import { loginError, loginProblem } from './loginRules';
|
import { loginError, loginProblem } from './loginRules';
|
||||||
@@ -40,7 +40,7 @@ export class LoginScreen extends VStack {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly auth: AuthStore,
|
private readonly auth: AuthStore,
|
||||||
private readonly onSignedIn: (user: string) => void,
|
private readonly onSignedIn: (account: SignedIn) => void,
|
||||||
) {
|
) {
|
||||||
super({ align: 'center', gap: Theme.gap });
|
super({ align: 'center', gap: Theme.gap });
|
||||||
this.add(new Spacer(), 1);
|
this.add(new Spacer(), 1);
|
||||||
@@ -101,8 +101,7 @@ export class LoginScreen extends VStack {
|
|||||||
}
|
}
|
||||||
this.status.setBusy(true);
|
this.status.setBusy(true);
|
||||||
try {
|
try {
|
||||||
const user = await this.auth.logIn(username, password);
|
this.onSignedIn(await this.auth.logIn(username, password));
|
||||||
this.onSignedIn(user);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.status.say(loginError(error, error instanceof WrongCredentialsError));
|
this.status.say(loginError(error, error instanceof WrongCredentialsError));
|
||||||
this.status.setBusy(false);
|
this.status.setBusy(false);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { currentFontPicker } from '../font/FontPicker';
|
|||||||
import { currentSoundPicker } from '../sound/SoundPicker';
|
import { currentSoundPicker } from '../sound/SoundPicker';
|
||||||
import { rectOf, textProbe } from '../textProbe';
|
import { rectOf, textProbe } from '../textProbe';
|
||||||
import { currentImagePicker } from '../texture/ImagePicker';
|
import { currentImagePicker } from '../texture/ImagePicker';
|
||||||
|
import type { UsersPane } from '../users/UsersPane';
|
||||||
import { Color, type ColorSource, type Container, Text } from 'pixi.js';
|
import { Color, type ColorSource, type Container, Text } from 'pixi.js';
|
||||||
import { centreOf, type HookContext } from './hookHelpers';
|
import { centreOf, type HookContext } from './hookHelpers';
|
||||||
|
|
||||||
@@ -168,6 +169,26 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
|||||||
tabRow: () => editor.tabRow(),
|
tabRow: () => editor.tabRow(),
|
||||||
/** The open "Change password" dialog (#182): its message and whether it is waiting, or null. */
|
/** The open "Change password" dialog (#182): its message and whether it is waiting, or null. */
|
||||||
passwordDialog: () => editor.accountDialog()?.info() ?? null,
|
passwordDialog: () => editor.accountDialog()?.info() ?? null,
|
||||||
|
/**
|
||||||
|
* The user management (#179); `pane()` is null for an account that is no administrator.
|
||||||
|
* `buttonAt` takes an action (`rename`, `role`, `password`, `delete`) or `open:<project id>`.
|
||||||
|
*/
|
||||||
|
users: {
|
||||||
|
pane: () =>
|
||||||
|
editor.usersPane ? { visible: editor.tab === 'management', ...editor.usersPane.debugInfo() } : null,
|
||||||
|
buttonAt: (username: string, action: Parameters<UsersPane['buttonOf']>[1]) => {
|
||||||
|
const button = editor.usersPane?.buttonOf(username, action);
|
||||||
|
return button
|
||||||
|
? { ...centreOf(button), right: rectOf(button).x + button.w, enabled: button.enabled }
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
/** The list on screen, for checks that its rows fit into it. */
|
||||||
|
rect: () => (editor.usersPane ? rectOf(editor.usersPane) : null),
|
||||||
|
/** The open form: its title, the message under it, whether it waits, its role (null without a picker). */
|
||||||
|
dialog: () => editor.usersPane?.currentDialog()?.info() ?? null,
|
||||||
|
/** Reads the list again and answers once it is drawn – `json()` does not wait for a promise, `evaluate()` does. */
|
||||||
|
reload: () => editor.usersPane?.reload(),
|
||||||
|
},
|
||||||
/** The project's collision layers (#152), and the names the open dialog holds (null when shut). */
|
/** The project's collision layers (#152), and the names the open dialog holds (null when shut). */
|
||||||
collisionLayers: () => ({
|
collisionLayers: () => ({
|
||||||
project: [...state.project.collisionLayers],
|
project: [...state.project.collisionLayers],
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
plugins: {
|
plugins: {
|
||||||
/** Every file in `data/plugins/` and what became of it. */
|
/** Every file in `data/plugins/` and what became of it. */
|
||||||
records: () => [...(editor.plugins?.records ?? [])],
|
records: () => [...(editor.plugins?.records ?? [])],
|
||||||
/** The Plugins tab: one row per file, with its switch. */
|
/** The plugins in the user management tab (#179): one row per file, with its switch. */
|
||||||
pane: () => editor.pluginsPane.debugInfo(),
|
pane: () => ({ ...editor.pluginsPane.debugInfo(), visible: editor.tab === 'management' }),
|
||||||
/** Where to click the switch of one plugin; null when it has no row. */
|
/** Where to click the switch of one plugin; null when it has no row. */
|
||||||
switchAt: (file: string) => {
|
switchAt: (file: string) => {
|
||||||
const box = editor.pluginsPane.switchOf(file);
|
const box = editor.pluginsPane.switchOf(file);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import text from './icons/text.svg?raw';
|
|||||||
import texture from './icons/texture.svg?raw';
|
import texture from './icons/texture.svg?raw';
|
||||||
import tile from './icons/tile.svg?raw';
|
import tile from './icons/tile.svg?raw';
|
||||||
import tilemap from './icons/tilemap.svg?raw';
|
import tilemap from './icons/tilemap.svg?raw';
|
||||||
|
import users from './icons/users.svg?raw';
|
||||||
import variables from './icons/variables.svg?raw';
|
import variables from './icons/variables.svg?raw';
|
||||||
import vectorart from './icons/vectorart.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). */
|
/** The Plugins tab's: a puzzle piece, as a plugin is a piece added to the editor (#119). */
|
||||||
export const PLUGINS_ICON_SVG = plugins;
|
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;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||||
|
<!-- The User management tab (#179): two people, the one behind cut off by the one in front.
|
||||||
|
Shoulders are cubic curves, not arcs: Pixi's parser draws an A after an L as a spike. -->
|
||||||
|
<circle cx="-1.5" cy="-2.5" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path
|
||||||
|
d="M-6.5 6.5 C-6.5 2.5 -4.5 1 -1.5 1 C1.5 1 3.5 2.5 3.5 6.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3.2 -6.2 C5 -6 5.9 -4.8 5.9 -3.5 C5.9 -2.2 5 -1 3.4 -0.8 M5.2 1.3 C6.8 2 7.4 3.8 7.4 6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 750 B |
@@ -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
|
* 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.
|
* 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. */
|
/** The rows as drawn, and the switch to click – what the check scripts read. */
|
||||||
debugInfo(): {
|
debugInfo(): {
|
||||||
visible: boolean;
|
|
||||||
rows: { file: string; name: string; status: string; checked: boolean; text: string }[];
|
rows: { file: string; name: string; status: string; checked: boolean; text: string }[];
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
visible: this.visible,
|
|
||||||
rows: this.rowWidgets().map((row) => ({
|
rows: this.rowWidgets().map((row) => ({
|
||||||
file: row.record.file,
|
file: row.record.file,
|
||||||
name: displayName(row.record),
|
name: displayName(row.record),
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Role } from '../../core/store/AuthStore';
|
||||||
|
import { Button, Dropdown, Label, Menu, TextField, ui } from '../../ui';
|
||||||
|
import { FormStatus, refocus } from '../auth/FormStatus';
|
||||||
|
import { dialogButtons, FormDialog } from '../FormDialog';
|
||||||
|
|
||||||
|
const FIELD_WIDTH = 200;
|
||||||
|
|
||||||
|
export interface AccountField {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
secret?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a form hands to its `send`: the fields in order, and the role when the form has one. */
|
||||||
|
export interface AccountForm {
|
||||||
|
values: string[];
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountDialogOptions {
|
||||||
|
title: string;
|
||||||
|
note?: string;
|
||||||
|
fields: AccountField[];
|
||||||
|
/** A role picker below the fields, starting on this role; none when left out. */
|
||||||
|
role?: Role;
|
||||||
|
confirm: string;
|
||||||
|
/** A problem to show instead of sending, and the index of the field it is about. */
|
||||||
|
check: (form: AccountForm) => { message: string; field: number } | null;
|
||||||
|
/** Rejects with the server's sentence when it says no; the dialog stays and shows it. */
|
||||||
|
send: (form: AccountForm) => Promise<void>;
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The forms of the user management (#179) – a new account, a new name, a new password. One class,
|
||||||
|
* because they differ only in their fields. Like the password dialog it stays open when the server
|
||||||
|
* refuses, so the refusal can be read next to what caused it.
|
||||||
|
*/
|
||||||
|
export class AccountDialog extends FormDialog {
|
||||||
|
readonly fields: TextField[];
|
||||||
|
private role: Role;
|
||||||
|
private readonly rolePicker: Dropdown | null = null;
|
||||||
|
private readonly confirm: Button;
|
||||||
|
private readonly status = new FormStatus((busy) => {
|
||||||
|
this.confirm.setEnabled(!busy);
|
||||||
|
this.refreshLayout();
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(private readonly options: AccountDialogOptions) {
|
||||||
|
super(360, options.title);
|
||||||
|
if (options.note) this.content.add(new Label(options.note, { style: 'muted' }));
|
||||||
|
|
||||||
|
this.fields = options.fields.map(
|
||||||
|
(field, i) =>
|
||||||
|
new TextField(field.value ?? '', {
|
||||||
|
width: FIELD_WIDTH,
|
||||||
|
secret: field.secret,
|
||||||
|
// Enter moves on, and on the last field sends – a keyboard alone gets through the form.
|
||||||
|
onSubmit: () => {
|
||||||
|
const next = this.fields[i + 1];
|
||||||
|
if (next) ui().focus.focus(next);
|
||||||
|
else void this.submit();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
options.fields.forEach((field, i) =>
|
||||||
|
this.content.add(this.formRow(this.fieldLabel(field.label), this.fields[i]!)),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.role = options.role ?? 'user';
|
||||||
|
if (options.role) {
|
||||||
|
const picker = new Dropdown(this.role, { width: FIELD_WIDTH, onClick: () => this.pickRole(picker) });
|
||||||
|
this.rolePicker = picker;
|
||||||
|
this.content.add(this.formRow(this.fieldLabel('Role'), picker));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.content.add(this.status.message);
|
||||||
|
this.confirm = new Button(options.confirm, { variant: 'primary', onClick: () => void this.submit() });
|
||||||
|
this.content.add(dialogButtons(this.closeThen(), this.confirm));
|
||||||
|
}
|
||||||
|
|
||||||
|
focusFirstField(): void {
|
||||||
|
const first = this.fields[0];
|
||||||
|
if (first) ui().focus.focus(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the check scripts read. */
|
||||||
|
info(): { title: string; message: string; busy: boolean; role: Role | null } {
|
||||||
|
return { title: this.options.title, ...this.status.info(), role: this.rolePicker ? this.role : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickRole(anchor: Dropdown): void {
|
||||||
|
new Menu(
|
||||||
|
(['user', 'admin'] as const).map((role) => ({
|
||||||
|
label: role,
|
||||||
|
current: role === this.role,
|
||||||
|
onSelect: () => {
|
||||||
|
this.role = role;
|
||||||
|
anchor.setText(role);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
).openBelow(anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async submit(): Promise<void> {
|
||||||
|
if (this.status.busy) return;
|
||||||
|
const form = { values: this.fields.map((f) => f.value), role: this.role };
|
||||||
|
const problem = this.options.check(form);
|
||||||
|
if (problem) {
|
||||||
|
this.status.say(problem.message);
|
||||||
|
const field = this.fields[problem.field];
|
||||||
|
if (field) refocus(field);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.status.setBusy(true);
|
||||||
|
try {
|
||||||
|
await this.options.send(form);
|
||||||
|
this.close();
|
||||||
|
this.options.onDone();
|
||||||
|
} catch (error) {
|
||||||
|
this.status.say(error instanceof Error ? error.message : String(error));
|
||||||
|
this.status.setBusy(false);
|
||||||
|
const first = this.fields[0];
|
||||||
|
if (first) refocus(first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import type { Role } from '../../core/store/AuthStore';
|
||||||
|
import type { Account, UsersStore } from '../../core/store/UsersStore';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
Dropdown,
|
||||||
|
HStack,
|
||||||
|
Label,
|
||||||
|
Menu,
|
||||||
|
ScrollContainer,
|
||||||
|
Spacer,
|
||||||
|
Theme,
|
||||||
|
VStack,
|
||||||
|
} from '../../ui';
|
||||||
|
import { dialogSlot } from '../FormDialog';
|
||||||
|
import { TabHeader } from '../TabHeader';
|
||||||
|
import { AccountDialog } from './AccountDialog';
|
||||||
|
import { accountActions, nameProblem, newPasswordProblem } from './accountRules';
|
||||||
|
|
||||||
|
const ROLE_WIDTH = 56;
|
||||||
|
const DATE_WIDTH = 84;
|
||||||
|
const COUNT_WIDTH = 76;
|
||||||
|
const PROJECT_INDENT = 24;
|
||||||
|
|
||||||
|
/** The row actions a check script can press, by name. */
|
||||||
|
export type AccountAction = 'rename' | 'role' | 'password' | 'delete';
|
||||||
|
|
||||||
|
/** What the pane needs from the editor around it. */
|
||||||
|
export interface UsersPaneHost {
|
||||||
|
/** Who is signed in: their own row offers nothing but a new password. */
|
||||||
|
self: string;
|
||||||
|
openProject(id: string): void;
|
||||||
|
notice(message: string, level?: 'error'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountRow extends VStack {
|
||||||
|
readonly buttons: Record<AccountAction, Button>;
|
||||||
|
readonly openButtons = new Map<string, Button>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly account: Account,
|
||||||
|
self: string,
|
||||||
|
act: (action: AccountAction, anchor: Button) => void,
|
||||||
|
openProject: (id: string) => void,
|
||||||
|
) {
|
||||||
|
super({ padding: Theme.padding, gap: 2 });
|
||||||
|
const line = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
|
line.minHeight = Theme.rowHeight;
|
||||||
|
const own = account.username.toLowerCase() === self.toLowerCase();
|
||||||
|
// The name takes what the fixed columns and the buttons leave, and is cut with "…" in a
|
||||||
|
// narrow window: the buttons at the end are what must stay reachable.
|
||||||
|
line.add(new Label(own ? `${account.username} (you)` : account.username, { style: 'bodyBold' }), 1);
|
||||||
|
line.add(new Label(account.role, { width: ROLE_WIDTH, style: 'muted' }));
|
||||||
|
line.add(new Label(account.createdAt.slice(0, 10) || '—', { width: DATE_WIDTH, style: 'muted' }));
|
||||||
|
const count = account.projects.length;
|
||||||
|
line.add(
|
||||||
|
new Label(`${count} ${count === 1 ? 'project' : 'projects'}`, { width: COUNT_WIDTH, style: 'muted' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowed = accountActions(self, account.username);
|
||||||
|
const button = (action: AccountAction, text: string, enabled: boolean, danger = false): Button => {
|
||||||
|
const made: Button =
|
||||||
|
action === 'role'
|
||||||
|
? new Dropdown('Role', { onClick: () => act(action, made) })
|
||||||
|
: new Button(text, { variant: danger ? 'danger' : 'default', onClick: () => act(action, made) });
|
||||||
|
made.setEnabled(enabled);
|
||||||
|
line.add(made);
|
||||||
|
return made;
|
||||||
|
};
|
||||||
|
this.buttons = {
|
||||||
|
rename: button('rename', 'Rename…', allowed.rename),
|
||||||
|
role: button('role', 'Role', allowed.role),
|
||||||
|
password: button('password', 'Password…', true),
|
||||||
|
delete: button('delete', 'Delete', allowed.remove, true),
|
||||||
|
};
|
||||||
|
this.add(line);
|
||||||
|
|
||||||
|
// Every project is opened like one of the administrator's own: fully editable, saved in place,
|
||||||
|
// still belonging to the account it is listed under.
|
||||||
|
for (const project of account.projects) {
|
||||||
|
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
|
row.minHeight = Theme.rowHeight;
|
||||||
|
row.add(new Spacer(PROJECT_INDENT));
|
||||||
|
row.add(new Label(project.name), 1);
|
||||||
|
row.add(new Label(project.updatedAt.slice(0, 10), { width: DATE_WIDTH, style: 'muted' }));
|
||||||
|
const open = new Button('Open', { variant: 'ghost', onClick: () => openProject(project.id) });
|
||||||
|
row.add(open);
|
||||||
|
this.openButtons.set(project.id, open);
|
||||||
|
this.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accounts, for administrators (#179): one row each with name, role, creation date and the
|
||||||
|
* number of projects, the projects listed under it with Open, and the actions on the right.
|
||||||
|
* Everything goes to the server, which decides; the list is read again after every change.
|
||||||
|
*/
|
||||||
|
export class UsersPane extends VStack {
|
||||||
|
private readonly rows = new VStack();
|
||||||
|
private readonly scroll = new ScrollContainer();
|
||||||
|
private readonly empty = new Label('Loading…', { style: 'muted', padding: Theme.padding });
|
||||||
|
private readonly dialog = dialogSlot<AccountDialog>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly store: UsersStore,
|
||||||
|
private readonly host: UsersPaneHost,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
const header = new TabHeader();
|
||||||
|
header.add(new Label('Users', { style: 'bodyBold' }));
|
||||||
|
header.add(new Spacer(), 1);
|
||||||
|
header.add(new Button('New user…', { onClick: () => this.createAccount() }));
|
||||||
|
this.add(header);
|
||||||
|
this.rows.add(this.empty);
|
||||||
|
this.scroll.setContent(this.rows);
|
||||||
|
this.add(this.scroll, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the accounts again; the pane shows what the server says, not what it asked for. */
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.show(await this.store.list());
|
||||||
|
} catch (error) {
|
||||||
|
this.show([]);
|
||||||
|
this.empty.setText(
|
||||||
|
`Could not load the accounts: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The open form, for the check scripts. */
|
||||||
|
currentDialog(): AccountDialog | null {
|
||||||
|
return this.dialog.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The list as drawn, for the check scripts. */
|
||||||
|
debugInfo(): {
|
||||||
|
rows: { username: string; role: Role; projects: string[]; enabled: Record<AccountAction, boolean> }[];
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
rows: this.rowWidgets().map((row) => ({
|
||||||
|
username: row.account.username,
|
||||||
|
role: row.account.role,
|
||||||
|
projects: row.account.projects.map((p) => p.name),
|
||||||
|
enabled: {
|
||||||
|
rename: row.buttons.rename.enabled,
|
||||||
|
role: row.buttons.role.enabled,
|
||||||
|
password: row.buttons.password.enabled,
|
||||||
|
delete: row.buttons.delete.enabled,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row's button, for the check scripts: an action by name, or `open:<project id>`. The list is
|
||||||
|
* scrolled until the button's line is in view – a click on one scrolled away lands on whatever is
|
||||||
|
* drawn there instead.
|
||||||
|
*/
|
||||||
|
buttonOf(username: string, action: AccountAction | `open:${string}`): Button | null {
|
||||||
|
const row = this.rowWidgets().find((r) => r.account.username === username);
|
||||||
|
if (!row) return null;
|
||||||
|
const button = action.startsWith('open:')
|
||||||
|
? row.openButtons.get(action.slice('open:'.length))
|
||||||
|
: row.buttons[action as AccountAction];
|
||||||
|
const line = button?.parent;
|
||||||
|
if (!button || !(line instanceof HStack)) return button ?? null;
|
||||||
|
this.scroll.ensureVisible(line.getGlobalPosition().y - this.rows.getGlobalPosition().y, line.h);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowWidgets(): AccountRow[] {
|
||||||
|
return this.rows.children.filter((child): child is AccountRow => child instanceof AccountRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private show(accounts: Account[]): void {
|
||||||
|
for (const row of this.rowWidgets()) row.destroy({ children: true });
|
||||||
|
for (const account of accounts)
|
||||||
|
this.rows.add(
|
||||||
|
new AccountRow(
|
||||||
|
account,
|
||||||
|
this.host.self,
|
||||||
|
(action, anchor) => this.act(account, action, anchor),
|
||||||
|
(id) => this.host.openProject(id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.empty.visible = accounts.length === 0;
|
||||||
|
this.scroll.relayoutContent();
|
||||||
|
this.relayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
private act(account: Account, action: AccountAction, anchor: Button): void {
|
||||||
|
if (action === 'rename') this.renameAccount(account);
|
||||||
|
else if (action === 'role') this.pickRole(account, anchor);
|
||||||
|
else if (action === 'password') this.resetPassword(account);
|
||||||
|
else this.confirmDelete(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
private open(dialog: AccountDialog): void {
|
||||||
|
this.dialog.open(dialog).focusFirstField();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createAccount(): void {
|
||||||
|
this.open(
|
||||||
|
new AccountDialog({
|
||||||
|
title: 'New user',
|
||||||
|
fields: [{ label: 'Name' }, { label: 'Password', secret: true }, { label: 'Repeat', secret: true }],
|
||||||
|
role: 'user',
|
||||||
|
confirm: 'Create',
|
||||||
|
check: ({ values: [name = '', password = '', repeat = ''] }) => {
|
||||||
|
const badName = nameProblem(name);
|
||||||
|
if (badName) return { message: badName, field: 0 };
|
||||||
|
const badPassword = newPasswordProblem(password, repeat);
|
||||||
|
return badPassword ? { message: badPassword, field: password === '' ? 1 : 2 } : null;
|
||||||
|
},
|
||||||
|
send: ({ values: [name = '', password = ''], role }) => this.store.create(name, password, role),
|
||||||
|
onDone: () => void this.reload(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renameAccount(account: Account): void {
|
||||||
|
this.open(
|
||||||
|
new AccountDialog({
|
||||||
|
title: `Rename ${account.username}`,
|
||||||
|
note: 'Their projects move with the name. They are signed out everywhere.',
|
||||||
|
fields: [{ label: 'Name', value: account.username }],
|
||||||
|
confirm: 'Rename',
|
||||||
|
check: ({ values: [name = ''] }) => {
|
||||||
|
const problem = nameProblem(name);
|
||||||
|
return problem ? { message: problem, field: 0 } : null;
|
||||||
|
},
|
||||||
|
send: ({ values: [name = ''] }) => this.store.update(account.username, { username: name }),
|
||||||
|
onDone: () => void this.reload(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetPassword(account: Account): void {
|
||||||
|
const own = account.username.toLowerCase() === this.host.self.toLowerCase();
|
||||||
|
this.open(
|
||||||
|
new AccountDialog({
|
||||||
|
title: `New password for ${account.username}`,
|
||||||
|
note: own ? 'Your other sessions are signed out.' : 'Their open sessions are signed out.',
|
||||||
|
fields: [
|
||||||
|
{ label: 'Password', secret: true },
|
||||||
|
{ label: 'Repeat', secret: true },
|
||||||
|
],
|
||||||
|
confirm: 'Set password',
|
||||||
|
check: ({ values: [password = '', repeat = ''] }) => {
|
||||||
|
const problem = newPasswordProblem(password, repeat);
|
||||||
|
return problem ? { message: problem, field: password === '' ? 0 : 1 } : null;
|
||||||
|
},
|
||||||
|
send: ({ values: [password = ''] }) => this.store.update(account.username, { password }),
|
||||||
|
onDone: () => this.host.notice(`Password set for ${account.username}`),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickRole(account: Account, anchor: Button): void {
|
||||||
|
new Menu(
|
||||||
|
(['user', 'admin'] as const).map((role) => ({
|
||||||
|
label: role,
|
||||||
|
current: role === account.role,
|
||||||
|
onSelect: () => {
|
||||||
|
if (role !== account.role) void this.run(this.store.update(account.username, { role }));
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
).openBelow(anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private confirmDelete(account: Account): void {
|
||||||
|
new Dialog(`Delete user "${account.username}"?`, 'The account is removed and signed out everywhere.', [
|
||||||
|
{ label: 'Cancel' },
|
||||||
|
{ label: 'Delete', danger: true, onClick: () => void this.run(this.store.remove(account.username)) },
|
||||||
|
]).openCentered();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A change without a form of its own: a refusal goes to the status line, the list is read again either way. */
|
||||||
|
private async run(work: Promise<void>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await work;
|
||||||
|
} catch (error) {
|
||||||
|
this.host.notice(error instanceof Error ? error.message : String(error), 'error');
|
||||||
|
}
|
||||||
|
await this.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { MIN_PASSWORD_LENGTH } from '../auth/loginRules';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the user management's forms may complain about before anything is sent (#179). The server
|
||||||
|
* checks the same and more – a taken name, the last administrator – and its answer is what counts;
|
||||||
|
* this is so an obvious slip is said without a round trip.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `UserStore::isValidName` in the API: 2 to 32 characters, a letter or digit first. */
|
||||||
|
const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{1,31}$/i;
|
||||||
|
|
||||||
|
export function nameProblem(name: string): string | null {
|
||||||
|
if (name.trim() === '') return 'Enter a user name';
|
||||||
|
if (!NAME_PATTERN.test(name)) return 'A user name is 2–32 letters, digits, dots, dashes or underscores';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newPasswordProblem(password: string, repeat: string): string | null {
|
||||||
|
if (password === '') return 'Enter a password';
|
||||||
|
if (password.length < MIN_PASSWORD_LENGTH)
|
||||||
|
return `The password needs at least ${MIN_PASSWORD_LENGTH} characters`;
|
||||||
|
if (repeat !== password) return 'The two passwords are different';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an administrator may do to a row of the list. Their own name, role and account are off
|
||||||
|
* limits – the server refuses them too, the buttons are just not offered.
|
||||||
|
*/
|
||||||
|
export function accountActions(
|
||||||
|
self: string,
|
||||||
|
username: string,
|
||||||
|
): { rename: boolean; role: boolean; remove: boolean } {
|
||||||
|
const own = self.toLowerCase() === username.toLowerCase();
|
||||||
|
return { rename: !own, role: !own, remove: !own };
|
||||||
|
}
|
||||||
+26
-7
@@ -23,17 +23,36 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('HttpAuthStore', () => {
|
describe('HttpAuthStore', () => {
|
||||||
it('reads who is signed in, null included', async () => {
|
it('reads who is signed in and their role, null included', async () => {
|
||||||
stubFetch(JSON.stringify({ user: 'e2e' }), { status: 200 });
|
stubFetch(JSON.stringify({ user: 'e2e', role: 'user' }), { status: 200 });
|
||||||
await expect(new HttpAuthStore('/api').session()).resolves.toBe('e2e');
|
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();
|
await expect(new HttpAuthStore('/api').session()).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends the pair as JSON and answers with the name the server confirms', async () => {
|
// #180: the weaker reading is the safe one – a server from before roles, or a role this editor does
|
||||||
const mock = stubFetch(JSON.stringify({ user: 'user' }), { status: 200 });
|
// not know, must not make anyone an administrator.
|
||||||
await expect(new HttpAuthStore('/api').logIn('user', 'secret')).resolves.toBe('user');
|
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]!;
|
const [url, init] = mock.mock.calls[0]!;
|
||||||
expect(url as string).toContain('r=auth%2Flogin');
|
expect(url as string).toContain('r=auth%2Flogin');
|
||||||
expect(init?.method).toBe('POST');
|
expect(init?.method).toBe('POST');
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { HttpUsersStore } from '../../src/core/store/HttpUsersStore';
|
||||||
|
import { accountActions, nameProblem, newPasswordProblem } from '../../src/editor/users/accountRules';
|
||||||
|
import { MIN_PASSWORD_LENGTH } from '../../src/editor/auth/loginRules';
|
||||||
|
|
||||||
|
function stubFetch(body: BodyInit | null, init: ResponseInit) {
|
||||||
|
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
|
||||||
|
vi.stubGlobal('fetch', mock);
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HttpUsersStore (#179)', () => {
|
||||||
|
it('lists the accounts with their projects, an unknown role read as user', async () => {
|
||||||
|
const mock = stubFetch(
|
||||||
|
JSON.stringify([
|
||||||
|
{ username: 'admin', role: 'admin', createdAt: '', projects: [] },
|
||||||
|
{ username: 'odd', role: 'root', createdAt: '2026-09-23T10:00:00+00:00', projects: [{ id: 'p1' }] },
|
||||||
|
]),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
const accounts = await new HttpUsersStore('/api').list();
|
||||||
|
expect(accounts.map((a) => [a.username, a.role])).toEqual([
|
||||||
|
['admin', 'admin'],
|
||||||
|
['odd', 'user'],
|
||||||
|
]);
|
||||||
|
expect(accounts[1]?.projects).toHaveLength(1);
|
||||||
|
expect(mock.mock.calls[0]?.[0] as string).toContain('r=users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates, changes and deletes through their own routes and methods', async () => {
|
||||||
|
const mock = stubFetch(JSON.stringify({}), { status: 200 });
|
||||||
|
const store = new HttpUsersStore('/api');
|
||||||
|
await store.create('anna', 'a-password', 'admin');
|
||||||
|
await store.update('anna', { username: 'annabel' });
|
||||||
|
stubFetch(null, { status: 204 });
|
||||||
|
await store.remove('annabel');
|
||||||
|
|
||||||
|
const [createUrl, create] = mock.mock.calls[0]!;
|
||||||
|
expect(createUrl as string).toMatch(/r=users$/);
|
||||||
|
expect(create?.method).toBe('POST');
|
||||||
|
expect(JSON.parse(create?.body as string)).toEqual({
|
||||||
|
username: 'anna',
|
||||||
|
password: 'a-password',
|
||||||
|
role: 'admin',
|
||||||
|
});
|
||||||
|
const [patchUrl, patch] = mock.mock.calls[1]!;
|
||||||
|
expect(patchUrl as string).toContain('r=users%2Fanna');
|
||||||
|
expect(patch?.method).toBe('PATCH');
|
||||||
|
// Only what changes travels: a field left out is one the server leaves alone.
|
||||||
|
expect(JSON.parse(patch?.body as string)).toEqual({ username: 'annabel' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The dialogs show the error as it stands, so it has to be the server's sentence and nothing else.
|
||||||
|
it('turns a refusal into the server’s own sentence', async () => {
|
||||||
|
stubFetch(JSON.stringify({ error: 'anna still owns 2 projects' }), { status: 409 });
|
||||||
|
await expect(new HttpUsersStore('/api').remove('anna')).rejects.toThrow(/^anna still owns 2 projects$/);
|
||||||
|
|
||||||
|
stubFetch(JSON.stringify({ error: 'Administrators only' }), { status: 403 });
|
||||||
|
await expect(new HttpUsersStore('/api').list()).rejects.toThrow(/^Administrators only$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps route and status for a failure that is no refusal', async () => {
|
||||||
|
stubFetch(JSON.stringify({ error: 'Internal server error' }), { status: 500 });
|
||||||
|
await expect(new HttpUsersStore('/api').list()).rejects.toThrow(/Internal server error \(500\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the rules of the user management forms (#179)', () => {
|
||||||
|
it('asks for a name the server takes', () => {
|
||||||
|
expect(nameProblem('')).toBe('Enter a user name');
|
||||||
|
expect(nameProblem('a')).not.toBeNull();
|
||||||
|
expect(nameProblem('a b')).not.toBeNull();
|
||||||
|
expect(nameProblem('-dash-first')).not.toBeNull();
|
||||||
|
expect(nameProblem('x'.repeat(33))).not.toBeNull();
|
||||||
|
expect(nameProblem('anna.b_2-x')).toBeNull();
|
||||||
|
expect(nameProblem('x'.repeat(32))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for a password long enough, typed twice the same', () => {
|
||||||
|
const long = 'a'.repeat(MIN_PASSWORD_LENGTH);
|
||||||
|
expect(newPasswordProblem('', '')).toBe('Enter a password');
|
||||||
|
expect(newPasswordProblem(long.slice(1), long.slice(1))).toBe(
|
||||||
|
`The password needs at least ${MIN_PASSWORD_LENGTH} characters`,
|
||||||
|
);
|
||||||
|
expect(newPasswordProblem(long, `${long}x`)).toBe('The two passwords are different');
|
||||||
|
expect(newPasswordProblem(long, long)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers nothing but a new password on the administrator’s own row', () => {
|
||||||
|
expect(accountActions('admin', 'Admin')).toEqual({ rename: false, role: false, remove: false });
|
||||||
|
expect(accountActions('admin', 'anna')).toEqual({ rename: true, role: true, remove: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accounts, roles and who may touch which project (#180, #179), without a web server: the stores
|
||||||
|
* work on a scratch directory. Plain PHP, no framework – CI runs it with `php tests/php/accounts.test.php`
|
||||||
|
* and reads the last line, as it does for the other suites.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/../../api/lib/AccountRules.php';
|
||||||
|
require __DIR__ . '/../../api/lib/Store.php';
|
||||||
|
require __DIR__ . '/../../api/lib/UserStore.php';
|
||||||
|
|
||||||
|
$failed = 0;
|
||||||
|
$passed = 0;
|
||||||
|
|
||||||
|
function check(string $name, bool $ok, mixed $got = null): void
|
||||||
|
{
|
||||||
|
global $failed, $passed;
|
||||||
|
if ($ok) {
|
||||||
|
$passed++;
|
||||||
|
echo "PASS {$name}\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$failed++;
|
||||||
|
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function throws(callable $work): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$work();
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = sys_get_temp_dir() . '/game-maker-php-test-' . bin2hex(random_bytes(4));
|
||||||
|
mkdir($dir);
|
||||||
|
$file = "{$dir}/users.json";
|
||||||
|
|
||||||
|
// --- UserStore ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
file_put_contents($file, json_encode(['users' => [
|
||||||
|
['username' => 'old', 'password' => password_hash('secret12', PASSWORD_DEFAULT)],
|
||||||
|
['username' => 'odd', 'password' => password_hash('secret12', PASSWORD_DEFAULT), 'role' => 'root'],
|
||||||
|
]]));
|
||||||
|
$users = new UserStore($file);
|
||||||
|
check('an account without a role is a user', $users->role('old') === 'user', $users->role('old'));
|
||||||
|
check('an unknown role counts as user', $users->role('odd') === 'user', $users->role('odd'));
|
||||||
|
check('an account that is not there is a user', $users->role('nobody') === 'user');
|
||||||
|
check('the old accounts have no creation date', $users->accounts()[1]['createdAt'] === '', $users->accounts());
|
||||||
|
|
||||||
|
$users->put('boss', 'secret12', 'admin');
|
||||||
|
check('a role given on creation is kept', $users->role('boss') === 'admin');
|
||||||
|
check('a new account gets a creation date', $users->accounts()[0]['createdAt'] !== '', $users->accounts()[0]);
|
||||||
|
$created = $users->accounts()[0]['createdAt'];
|
||||||
|
$users->put('boss', 'another12');
|
||||||
|
check('a new password without a role leaves the role alone', $users->role('boss') === 'admin');
|
||||||
|
check('a new password leaves the creation date alone', $users->accounts()[0]['createdAt'] === $created);
|
||||||
|
check('the new password is the one that works', $users->verify('boss', 'another12') === 'boss');
|
||||||
|
$users->put('fresh', 'secret12');
|
||||||
|
check('a new account without a role is a user', $users->role('fresh') === 'user');
|
||||||
|
check('the role is written to the file', str_contains((string) file_get_contents($file), '"role": "admin"'));
|
||||||
|
check('an unknown role is refused', throws(fn() => $users->put('x1', 'secret12', 'root')));
|
||||||
|
check(
|
||||||
|
'the accounts carry no hash',
|
||||||
|
!str_contains(json_encode($users->accounts()), 'password') && !str_contains(json_encode($users->accounts()), '$2y$'),
|
||||||
|
);
|
||||||
|
check('one administrator counted', $users->adminCount() === 1, $users->adminCount());
|
||||||
|
$users->setRole('fresh', 'admin');
|
||||||
|
check('setRole makes an administrator', $users->role('fresh') === 'admin' && $users->adminCount() === 2);
|
||||||
|
check('setRole on nobody answers false', $users->setRole('nobody', 'user') === false);
|
||||||
|
|
||||||
|
check('rename moves the account', $users->rename('fresh', 'renamed') && $users->exists('renamed') && !$users->exists('fresh'));
|
||||||
|
check('rename keeps password and role', $users->verify('renamed', 'secret12') === 'renamed' && $users->role('renamed') === 'admin');
|
||||||
|
check('rename onto a taken name is refused', throws(fn() => $users->rename('renamed', 'OLD')));
|
||||||
|
check('the refused rename changed nothing', $users->exists('renamed') && $users->role('old') === 'user');
|
||||||
|
check('rename to an invalid name is refused', throws(fn() => $users->rename('renamed', 'a b')));
|
||||||
|
check('a change of case is the same account', $users->rename('renamed', 'Renamed') && $users->accounts()[0]['username'] === 'Renamed', $users->accounts());
|
||||||
|
check('rename of nobody answers false', $users->rename('nobody', 'somebody') === false);
|
||||||
|
check('remove takes the account away', $users->remove('renamed') && !$users->exists('renamed'));
|
||||||
|
check('remove of nobody answers false', $users->remove('renamed') === false);
|
||||||
|
|
||||||
|
// --- Store: who reaches a project --------------------------------------------------------------
|
||||||
|
|
||||||
|
$projects = "{$dir}/projects";
|
||||||
|
$mine = new Store($projects, 'anna');
|
||||||
|
$doc = $mine->create('Anna\'s game');
|
||||||
|
$id = $doc['id'];
|
||||||
|
$stranger = new Store($projects, 'bert');
|
||||||
|
$admin = new Store($projects, 'boss', true);
|
||||||
|
|
||||||
|
check('a stranger does not see the project', $stranger->load($id) === null);
|
||||||
|
check('a stranger cannot save it', $stranger->save($id, 1, ['name' => 'x']) === null);
|
||||||
|
check('a stranger cannot delete it', $stranger->delete($id) === false);
|
||||||
|
check('an administrator loads it', $admin->load($id)['name'] === "Anna's game");
|
||||||
|
$saved = $admin->save($id, 1, ['name' => 'Edited by boss']);
|
||||||
|
check('an administrator saves it', $saved !== null && $saved['revision'] === 2, $saved);
|
||||||
|
check('the save keeps the owner', $mine->load($id)['owner'] === 'anna', $mine->load($id)['owner'] ?? null);
|
||||||
|
check('the owner sees the edit', $mine->load($id)['name'] === 'Edited by boss');
|
||||||
|
check('the administrator\'s own list stays their own', $admin->list() === []);
|
||||||
|
check('listOf lists somebody else\'s projects', array_column($admin->listOf('anna'), 'id') === [$id]);
|
||||||
|
|
||||||
|
check('transfer moves the project to the new name', $admin->transfer('anna', 'annabel') === 1);
|
||||||
|
$moved = (new Store($projects, 'annabel'))->load($id);
|
||||||
|
check('the new name owns it', $moved !== null && $moved['owner'] === 'annabel');
|
||||||
|
check('the old name does not', $mine->load($id) === null);
|
||||||
|
check('transfer keeps the revision', $moved['revision'] === 2, $moved['revision'] ?? null);
|
||||||
|
|
||||||
|
$other = (new Store($projects, 'carl'))->create('Carl\'s game');
|
||||||
|
check('an administrator deletes somebody else\'s project', $admin->delete($other['id']) && $admin->listOf('carl') === []);
|
||||||
|
|
||||||
|
// --- AccountRules ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
check('renaming yourself is refused', AccountRules::renameRefusal('boss', 'BOSS') !== null);
|
||||||
|
check('renaming somebody else is fine', AccountRules::renameRefusal('boss', 'anna') === null);
|
||||||
|
check('changing your own role is refused', AccountRules::roleRefusal('boss', 'boss', 'admin', 'user', 2) !== null);
|
||||||
|
check('setting your own role to what it is is fine', AccountRules::roleRefusal('boss', 'boss', 'admin', 'admin', 1) === null);
|
||||||
|
check('the last administrator cannot be demoted', AccountRules::roleRefusal('x', 'boss', 'admin', 'user', 1) !== null);
|
||||||
|
check('an administrator among two can be demoted', AccountRules::roleRefusal('x', 'boss', 'admin', 'user', 2) === null);
|
||||||
|
check('deleting yourself is refused', AccountRules::deleteRefusal('boss', 'boss', 'admin', 2) !== null);
|
||||||
|
check('the last administrator cannot be deleted', AccountRules::deleteRefusal('x', 'boss', 'admin', 1) !== null);
|
||||||
|
check('a user can be deleted', AccountRules::deleteRefusal('boss', 'anna', 'user', 1) === null);
|
||||||
|
check('an account with projects is not deleted', AccountRules::ownerRefusal('anna', 2) === 'anna still owns 2 projects');
|
||||||
|
check('one project is said as one', AccountRules::ownerRefusal('anna', 1) === 'anna still owns 1 project');
|
||||||
|
check('an account without projects may go', AccountRules::ownerRefusal('anna', 0) === null);
|
||||||
|
|
||||||
|
exec('rm -rf ' . escapeshellarg($dir));
|
||||||
|
|
||||||
|
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
|
||||||
|
exit($failed === 0 ? 0 : 1);
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
GAME_ICON_SVG,
|
GAME_ICON_SVG,
|
||||||
LIST_ICON_SVG,
|
LIST_ICON_SVG,
|
||||||
PLUGINS_ICON_SVG,
|
PLUGINS_ICON_SVG,
|
||||||
|
USERS_ICON_SVG,
|
||||||
} from '../../src/editor/panels/assetIcons';
|
} from '../../src/editor/panels/assetIcons';
|
||||||
import { HEADER_ICON_SVG } from '../../src/editor/panels/headerIcons';
|
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';
|
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],
|
['list tab', LIST_ICON_SVG],
|
||||||
['game tab', GAME_ICON_SVG],
|
['game tab', GAME_ICON_SVG],
|
||||||
['plugins tab', PLUGINS_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(TOOLBAR_ICON_SVG).map(([name, svg]) => [`toolbar ${name}`, svg]),
|
||||||
...Object.entries(HEADER_ICON_SVG).map(([name, svg]) => [`header ${name}`, svg]),
|
...Object.entries(HEADER_ICON_SVG).map(([name, svg]) => [`header ${name}`, svg]),
|
||||||
...Object.entries(UI_ICON_SVG).map(([name, svg]) => [`ui ${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', () => {
|
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');
|
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)', () => {
|
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];
|
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);
|
for (const svg of tabs) expect(Object.values(ASSET_ICON_SVG)).not.toContain(svg);
|
||||||
expect(new Set(tabs).size).toBe(tabs.length);
|
expect(new Set(tabs).size).toBe(tabs.length);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ game_maker_serve() {
|
|||||||
echo >>"$root/data/e2e-password.txt"
|
echo >>"$root/data/e2e-password.txt"
|
||||||
fi
|
fi
|
||||||
php "$root/tools/make-user.php" e2e "$(cat "$root/data/e2e-password.txt")"
|
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
|
# 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.
|
# answers without a session, so "is it up" does not depend on being signed in.
|
||||||
|
|||||||
@@ -82,15 +82,14 @@ export async function overflow(t) {
|
|||||||
return n;
|
return n;
|
||||||
})()`);
|
})()`);
|
||||||
const fixedIcons = styled.shown
|
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]);
|
.map((b) => [b.tab, b.icon]);
|
||||||
check(
|
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(fixedIcons) ===
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
['scene', true],
|
['scene', true],
|
||||||
['game', true],
|
['game', true],
|
||||||
['plugins', true],
|
|
||||||
]) && sceneInk >= 20,
|
]) && sceneInk >= 20,
|
||||||
JSON.stringify({ fixedIcons, sceneInk }),
|
JSON.stringify({ fixedIcons, sceneInk }),
|
||||||
);
|
);
|
||||||
|
|||||||
+23
-10
@@ -45,7 +45,7 @@ export async function plugins(t) {
|
|||||||
check('the plugin tab is in the tab row', at >= 0, JSON.stringify(order));
|
check('the plugin tab is in the tab row', at >= 0, JSON.stringify(order));
|
||||||
check(
|
check(
|
||||||
'and it comes after the fixed tabs, ahead of any document',
|
'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:')),
|
order.slice(at + 1).every((tab) => tab === 'list' || tab.startsWith('asset:')),
|
||||||
JSON.stringify(order),
|
JSON.stringify(order),
|
||||||
);
|
);
|
||||||
@@ -163,15 +163,18 @@ export async function plugins(t) {
|
|||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
check('switching to another tab hides the board', (await board()).visible === false);
|
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
|
// 38i. The plugins (#114, #119): the switch belongs to the project, and it works while the
|
||||||
// editor runs.
|
// editor runs. Since #179 they are no fixed tab but reached through the menu, in a tab that
|
||||||
await openTab(t, 'plugins');
|
// closes like a document.
|
||||||
|
await t.clickToolbar('menu');
|
||||||
|
const entry = await t.locate('Plugins', true);
|
||||||
|
await t.click(entry.x, entry.y);
|
||||||
await settle();
|
await settle();
|
||||||
const pane = () => json('__gm.plugins.pane()');
|
const pane = () => json('__gm.plugins.pane()');
|
||||||
let rows = await pane();
|
let rows = await pane();
|
||||||
const rowOf = (shown) => shown.rows.find((r) => r.file === 'demo-plugin.js');
|
const rowOf = (shown) => shown.rows.find((r) => r.file === 'demo-plugin.js');
|
||||||
check(
|
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 &&
|
rows.visible === true &&
|
||||||
rowOf(rows)?.status === 'active' &&
|
rowOf(rows)?.status === 'active' &&
|
||||||
rowOf(rows)?.name === 'Demo plugin' &&
|
rowOf(rows)?.name === 'Demo plugin' &&
|
||||||
@@ -213,13 +216,13 @@ export async function plugins(t) {
|
|||||||
const listed = await evaluate(
|
const listed = await evaluate(
|
||||||
`fetch('/game-maker/api/index.php?r=plugins').then(async (r) => JSON.stringify(await r.json()))`,
|
`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(
|
check(
|
||||||
'the project says it is off, while the server still lists the file itself',
|
'the project says it is off, while the server still lists the file itself',
|
||||||
JSON.stringify(await json('__gm.state.project.plugins')) === '[]' &&
|
JSON.stringify(await json('__gm.state.project.plugins')) === '[]' &&
|
||||||
entry !== undefined &&
|
file !== undefined &&
|
||||||
entry.active === undefined,
|
file.active === undefined,
|
||||||
JSON.stringify({ plugins: await json('__gm.state.project.plugins'), entry }),
|
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.
|
// 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() }),
|
JSON.stringify({ nodes: titles(shown), assets: await names() }),
|
||||||
);
|
);
|
||||||
await shot('39-plugins-tab');
|
await shot('39-plugins-tab');
|
||||||
await showTab('scene');
|
await evaluate("__gm.closeDocument('management')");
|
||||||
|
await settle();
|
||||||
|
check(
|
||||||
|
'the tab closes like a document and leaves the row',
|
||||||
|
!(await json('__gm.tabRow()')).order.includes('management') &&
|
||||||
|
(await json('__gm.tab()')) !== 'management',
|
||||||
|
JSON.stringify({ tab: await json('__gm.tab()'), order: (await json('__gm.tabRow()')).order }),
|
||||||
|
);
|
||||||
|
// The switch is a change to the project; a step that navigates next would otherwise meet the
|
||||||
|
// autosave's "leave the page?" and wait on it.
|
||||||
|
await t.saved();
|
||||||
}
|
}
|
||||||
+10
-2
@@ -51,9 +51,17 @@ export async function topbar(t) {
|
|||||||
// that is open. Clearing the saved data is checked in 32.
|
// that is open. Clearing the saved data is checked in 32.
|
||||||
await clickToolbar('menu');
|
await clickToolbar('menu');
|
||||||
const rows = await json('__gm.menuRows()');
|
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(
|
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) === JSON.stringify(expected),
|
||||||
JSON.stringify(rows),
|
JSON.stringify(rows),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ import { login } from './45-login.mjs';
|
|||||||
import { topbar } from './46-topbar.mjs';
|
import { topbar } from './46-topbar.mjs';
|
||||||
import { vectorArt } from './47-vectorart.mjs';
|
import { vectorArt } from './47-vectorart.mjs';
|
||||||
import { pixelArt } from './48-pixelart.mjs';
|
import { pixelArt } from './48-pixelart.mjs';
|
||||||
|
import { users } from './49-users.mjs';
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
hierarchy,
|
hierarchy,
|
||||||
@@ -101,4 +102,5 @@ export const STEPS = [
|
|||||||
topbar,
|
topbar,
|
||||||
vectorArt,
|
vectorArt,
|
||||||
pixelArt,
|
pixelArt,
|
||||||
|
users,
|
||||||
];
|
];
|
||||||
@@ -122,7 +122,6 @@ export const TAB_LABELS = {
|
|||||||
container: 'Container',
|
container: 'Container',
|
||||||
list: 'List',
|
list: 'List',
|
||||||
particles: 'Particles',
|
particles: 'Particles',
|
||||||
plugins: 'Plugins',
|
|
||||||
// Added at start-up by the demo plugin (plugins/demo-plugin.js), after every built-in tab.
|
// Added at start-up by the demo plugin (plugins/demo-plugin.js), after every built-in tab.
|
||||||
'plugin:assets': 'Assets',
|
'plugin:assets': 'Assets',
|
||||||
};
|
};
|
||||||
@@ -170,6 +169,7 @@ const DOCUMENT_KINDS = [
|
|||||||
'tile',
|
'tile',
|
||||||
'container',
|
'container',
|
||||||
'list',
|
'list',
|
||||||
|
'management',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+33
-8
@@ -10,8 +10,12 @@ import { dirname, resolve } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
export const E2E_USER = 'e2e';
|
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';
|
const COOKIE = 'game_maker_session';
|
||||||
|
|
||||||
/** One session per server for the whole process; logging in per request would be pointless load. */
|
/** 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. */
|
/** The password of `e2e-admin`, from `GAME_MAKER_E2E_ADMIN_PASSWORD` or `data/e2e-admin-password.txt`. */
|
||||||
export async function sessionCookie(baseUrl) {
|
export async function e2eAdminPassword() {
|
||||||
const cached = cookies.get(baseUrl);
|
const fromEnv = process.env.GAME_MAKER_E2E_ADMIN_PASSWORD;
|
||||||
if (cached) return cached;
|
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`, {
|
const res = await fetch(`${baseUrl}api/index.php?r=auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: E2E_USER, password: await e2ePassword() }),
|
body: JSON.stringify({ username, password }),
|
||||||
signal: AbortSignal.timeout(20_000),
|
signal: AbortSignal.timeout(20_000),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
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
|
// `getSetCookie` keeps the headers apart; a joined string cannot be split on commas, because a
|
||||||
// cookie's own Expires date carries one.
|
// cookie's own Expires date carries one.
|
||||||
const set = res.headers.getSetCookie().find((c) => c.startsWith(`${COOKIE}=`));
|
const set = res.headers.getSetCookie().find((c) => c.startsWith(`${COOKIE}=`));
|
||||||
if (!set) throw new Error(`signing in at ${baseUrl} returned no ${COOKIE} 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);
|
cookies.set(baseUrl, cookie);
|
||||||
return cookie;
|
return cookie;
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-9
@@ -2,10 +2,12 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an account or sets the password of one that exists. The API has no route for it:
|
* Creates an account or sets the password of one that exists – the way to the first administrator,
|
||||||
* signing up is a follow-up issue, so until then accounts are made here, on the server.
|
* who can then manage the others in the editor (#179).
|
||||||
*
|
*
|
||||||
* Usage: php tools/make-user.php <username> <password>
|
* Without a role a new account is a `user` and an existing one keeps its role (#180).
|
||||||
|
*
|
||||||
|
* Usage: php tools/make-user.php <username> <password> [admin|user]
|
||||||
* php tools/make-user.php --list
|
* php tools/make-user.php --list
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -16,21 +18,26 @@ $store = new UserStore($file);
|
|||||||
|
|
||||||
$args = array_slice($argv, 1);
|
$args = array_slice($argv, 1);
|
||||||
if (($args[0] ?? '') === '--list') {
|
if (($args[0] ?? '') === '--list') {
|
||||||
foreach ($store->names() as $name) {
|
foreach ($store->accounts() as $account) {
|
||||||
echo $name, "\n";
|
echo $account['username'], ' ', $account['role'], "\n";
|
||||||
}
|
}
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
if (count($args) !== 2 || $args[0] === '' || $args[1] === '') {
|
if (count($args) < 2 || count($args) > 3 || $args[0] === '' || $args[1] === '') {
|
||||||
fwrite(STDERR, "Usage: php tools/make-user.php <username> <password>\n");
|
fwrite(STDERR, "Usage: php tools/make-user.php <username> <password> [admin|user]\n");
|
||||||
exit(2);
|
exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[$username, $password] = $args;
|
[$username, $password] = $args;
|
||||||
|
$role = $args[2] ?? null;
|
||||||
if (!UserStore::isValidName($username)) {
|
if (!UserStore::isValidName($username)) {
|
||||||
fwrite(STDERR, "Invalid user name: {$username}\n");
|
fwrite(STDERR, "Invalid user name: {$username}\n");
|
||||||
exit(2);
|
exit(2);
|
||||||
}
|
}
|
||||||
|
if ($role !== null && !UserStore::isValidRole($role)) {
|
||||||
|
fwrite(STDERR, "Unknown role: {$role} (admin or user)\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
$existed = $store->exists($username);
|
$existed = $store->exists($username);
|
||||||
$store->put($username, $password);
|
$store->put($username, $password, $role);
|
||||||
echo ($existed ? 'password set for ' : 'created '), $username, "\n";
|
echo ($existed ? 'password set for ' : 'created '), $username, ' (', $store->role($username), ")\n";
|
||||||
Reference in new issue
Block a user