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
+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
|
||||
* `owner` is that user. Someone else's project is answered as missing rather than as forbidden,
|
||||
* because "this id exists" is already more than a stranger should learn.
|
||||
*
|
||||
* An administrator's store (`$seesAll`, #179) reads, writes and deletes every project as if it
|
||||
* were their own, and a save keeps the owner it found. The list stays their own: the others are
|
||||
* reached through the user management, one account at a time.
|
||||
*/
|
||||
final class Store
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $dir,
|
||||
private readonly string $owner,
|
||||
private readonly bool $seesAll = false,
|
||||
) {
|
||||
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
|
||||
throw new RuntimeException("Cannot create data directory {$this->dir}");
|
||||
@@ -43,11 +48,17 @@ final class Store
|
||||
|
||||
/** @return list<array{id:string,name:string,revision:int,updatedAt:string}> */
|
||||
public function list(): array
|
||||
{
|
||||
return $this->listOf($this->owner);
|
||||
}
|
||||
|
||||
/** @return list<array{id:string,name:string,revision:int,updatedAt:string}> */
|
||||
public function listOf(string $owner): array
|
||||
{
|
||||
$items = [];
|
||||
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
|
||||
$doc = $this->readFile($file);
|
||||
if ($doc === null || $doc['owner'] !== $this->owner) {
|
||||
if ($doc === null || $doc['owner'] !== $owner) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
@@ -81,7 +92,7 @@ final class Store
|
||||
'assets' => [],
|
||||
'activeSceneId' => '',
|
||||
];
|
||||
$doc = $this->document($id, $name, 1, $project);
|
||||
$doc = $this->document($id, $name, $this->owner, 1, $project);
|
||||
$this->writeFile($this->path($id), $doc);
|
||||
return $doc;
|
||||
}
|
||||
@@ -94,11 +105,7 @@ final class Store
|
||||
public function save(string $id, int $expectedRevision, array $project): ?array
|
||||
{
|
||||
$path = $this->path($id);
|
||||
$lock = fopen($path . '.lock', 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||
throw new RuntimeException('Cannot acquire project lock');
|
||||
}
|
||||
try {
|
||||
return $this->locked($path, function () use ($path, $id, $expectedRevision, $project): ?array {
|
||||
$current = $this->own($this->readFile($path));
|
||||
if ($current === null) {
|
||||
return null;
|
||||
@@ -108,14 +115,10 @@ final class Store
|
||||
}
|
||||
$name = is_string($project['name'] ?? null) && $project['name'] !== '' ? $project['name'] : $current['name'];
|
||||
$project['id'] = $id;
|
||||
$doc = $this->document($id, $name, $expectedRevision + 1, $project);
|
||||
$doc = $this->document($id, $name, $current['owner'], $expectedRevision + 1, $project);
|
||||
$this->writeFile($path, $doc);
|
||||
return $doc;
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
@unlink($path . '.lock');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(string $id): bool
|
||||
@@ -127,10 +130,53 @@ final class Store
|
||||
return file_exists($path) && unlink($path);
|
||||
}
|
||||
|
||||
/** The document, or null when it belongs to someone else. */
|
||||
/**
|
||||
* Hands every project of `$from` to `$to` – what renaming an account does, since the name is
|
||||
* the owner. Revision and date stay: nothing in the project changed, and a tab that has one of
|
||||
* them open must not be told it was modified elsewhere. Returns how many moved.
|
||||
*/
|
||||
public function transfer(string $from, string $to): int
|
||||
{
|
||||
$moved = 0;
|
||||
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
|
||||
// Under the save lock, or a save running at the same time writes the old owner back.
|
||||
$moved += $this->locked($file, function () use ($file, $from, $to): int {
|
||||
$doc = $this->readFile($file);
|
||||
if ($doc === null || $doc['owner'] !== $from) {
|
||||
return 0;
|
||||
}
|
||||
$doc['owner'] = $to;
|
||||
$this->writeFile($file, $doc);
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
return $moved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param callable():T $work
|
||||
* @return T
|
||||
*/
|
||||
private function locked(string $path, callable $work): mixed
|
||||
{
|
||||
$lock = fopen($path . '.lock', 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||
throw new RuntimeException('Cannot acquire project lock');
|
||||
}
|
||||
try {
|
||||
return $work();
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
@unlink($path . '.lock');
|
||||
}
|
||||
}
|
||||
|
||||
/** The document, or null when it belongs to someone else and this store does not see everything. */
|
||||
private function own(?array $doc): ?array
|
||||
{
|
||||
return $doc !== null && $doc['owner'] === $this->owner ? $doc : null;
|
||||
return $doc !== null && ($this->seesAll || $doc['owner'] === $this->owner) ? $doc : null;
|
||||
}
|
||||
|
||||
private function path(string $id): string
|
||||
@@ -138,12 +184,12 @@ final class Store
|
||||
return $this->dir . '/' . $id . '.json';
|
||||
}
|
||||
|
||||
private function document(string $id, string $name, int $revision, array $project): array
|
||||
private function document(string $id, string $name, string $owner, int $revision, array $project): array
|
||||
{
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'owner' => $this->owner,
|
||||
'owner' => $owner,
|
||||
'revision' => $revision,
|
||||
'updatedAt' => gmdate('c'),
|
||||
'project' => $project,
|
||||
|
||||
Reference in new issue
Block a user