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
+68
-16
@@ -1,17 +1,20 @@
|
||||
import type { Application, Ticker } from 'pixi.js';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||
import type { Role } from '../core/store/AuthStore';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import { pluginsOf } from '../core/store/projectPlugins';
|
||||
import type { FontStore } from '../core/store/FontStore';
|
||||
import type { SoundStore } from '../core/store/SoundStore';
|
||||
import type { TextureStore } from '../core/store/TextureStore';
|
||||
import type { UsersStore } from '../core/store/UsersStore';
|
||||
import { gameClock, timeStep } from '../core/time';
|
||||
import {
|
||||
clamp,
|
||||
HStack,
|
||||
isRendering,
|
||||
Menu,
|
||||
type MenuEntry,
|
||||
Panel,
|
||||
renderCount,
|
||||
requestRender,
|
||||
@@ -42,7 +45,13 @@ import { resolveList } from './list/slot';
|
||||
import { AnimationEditor } from './animation/AnimationEditor';
|
||||
import { TileEditor } from './tile/TileEditor';
|
||||
import { AssetPanel } from './panels/AssetPanel';
|
||||
import { ASSET_ICON_SVG, GAME_ICON_SVG, LIST_ICON_SVG, PLUGINS_ICON_SVG } from './panels/assetIcons';
|
||||
import {
|
||||
ASSET_ICON_SVG,
|
||||
GAME_ICON_SVG,
|
||||
LIST_ICON_SVG,
|
||||
PLUGINS_ICON_SVG,
|
||||
USERS_ICON_SVG,
|
||||
} from './panels/assetIcons';
|
||||
import { HierarchyPanel } from './panels/HierarchyPanel';
|
||||
import { TilemapPanel } from './panels/TilemapPanel';
|
||||
import { Toolbar } from './panels/Toolbar';
|
||||
@@ -60,6 +69,8 @@ import { TextureEditor } from './texture/TextureEditor';
|
||||
import { installFileImport } from './fileImport';
|
||||
import type { PluginManager } from './plugins/loader';
|
||||
import { PluginsPane } from './plugins/PluginsPane';
|
||||
import { ManagementPane } from './users/ManagementPane';
|
||||
import { UsersPane } from './users/UsersPane';
|
||||
|
||||
export type CentreTab =
|
||||
| 'scene'
|
||||
@@ -75,18 +86,24 @@ export type CentreTab =
|
||||
| 'tile'
|
||||
| 'container'
|
||||
| 'list'
|
||||
| 'plugins';
|
||||
| 'management';
|
||||
|
||||
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
|
||||
export type PluginTabId = `plugin:${string}`;
|
||||
/** What is showing: a fixed tab, a plugin's, or the kind of the open document on screen. */
|
||||
export type TabId = CentreTab | PluginTabId;
|
||||
/** An open document (#171): an asset by its id, or the list the List tab edits – there is one. */
|
||||
export type DocumentId = `asset:${string}` | 'list';
|
||||
/**
|
||||
* An open document (#171): an asset by its id, or the list the List tab edits – there is one. The
|
||||
* user management (#179) is no document, but it opens and closes like one.
|
||||
*/
|
||||
export type DocumentId = `asset:${string}` | 'list' | 'management';
|
||||
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
|
||||
export interface EditorAccount {
|
||||
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
|
||||
user: string;
|
||||
/** What the editor offers: an administrator gets the user management (#180, #179). */
|
||||
role: Role;
|
||||
users: UsersStore;
|
||||
logOut: () => void;
|
||||
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
|
||||
changePassword: (current: string, next: string) => Promise<void>;
|
||||
@@ -97,9 +114,9 @@ export type StripTab = FixedTab | PluginTabId | DocumentId;
|
||||
|
||||
type FixedTab = (typeof FIXED_TABS)[number];
|
||||
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
|
||||
type AssetKind = Exclude<CentreTab, FixedTab | 'list'>;
|
||||
type AssetKind = Exclude<CentreTab, FixedTab | 'list' | 'management'>;
|
||||
|
||||
const FIXED_TABS = ['scene', 'game', 'plugins'] as const;
|
||||
const FIXED_TABS = ['scene', 'game'] as const;
|
||||
|
||||
const PANE_ORDER = [
|
||||
'scene',
|
||||
@@ -115,7 +132,7 @@ const PANE_ORDER = [
|
||||
'tile',
|
||||
'container',
|
||||
'list',
|
||||
'plugins',
|
||||
'management',
|
||||
] as const;
|
||||
|
||||
/** The fixed tabs' names, and a document's kind as a word (its tab shows the icon). */
|
||||
@@ -133,18 +150,17 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
||||
tile: 'Tile',
|
||||
container: 'Container',
|
||||
list: 'List',
|
||||
plugins: 'Plugins',
|
||||
management: 'User management',
|
||||
};
|
||||
|
||||
/** Icons of the fixed tabs (#176); a plugin's own tab has none, it brings no picture of its own. */
|
||||
const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
|
||||
scene: ASSET_ICON_SVG.scene,
|
||||
game: GAME_ICON_SVG,
|
||||
plugins: PLUGINS_ICON_SVG,
|
||||
};
|
||||
|
||||
function isDocument(tab: StripTab): tab is DocumentId {
|
||||
return tab === 'list' || tab.startsWith('asset:');
|
||||
return tab === 'list' || tab === 'management' || tab.startsWith('asset:');
|
||||
}
|
||||
|
||||
const MIN_LEFT_WIDTH = 160;
|
||||
@@ -178,8 +194,11 @@ export class Editor implements PlayHost {
|
||||
readonly tileEditor: TileEditor;
|
||||
readonly containerEditor: ContainerEditor;
|
||||
readonly listEditor: ListEditor;
|
||||
/** Plugins tab: the switch per file in `data/plugins/`. */
|
||||
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
|
||||
readonly pluginsPane = new PluginsPane();
|
||||
/** The accounts, above the plugins; null unless an administrator is signed in. */
|
||||
readonly usersPane: UsersPane | null;
|
||||
private readonly managementPane: ManagementPane;
|
||||
readonly hierarchy: HierarchyPanel;
|
||||
readonly assets: AssetPanel;
|
||||
readonly inspector: Inspector;
|
||||
@@ -285,8 +304,18 @@ export class Editor implements PlayHost {
|
||||
this.inspector.liveVariables = (assetId) =>
|
||||
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
|
||||
|
||||
this.usersPane =
|
||||
account.role === 'admin'
|
||||
? new UsersPane(account.users, {
|
||||
self: account.user,
|
||||
openProject: (id) => this.projects.openProject(id),
|
||||
notice: (message, level) => state.setNotice(message, level),
|
||||
})
|
||||
: null;
|
||||
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
|
||||
|
||||
this.toolbar = new Toolbar(state, {
|
||||
openMainMenu: (anchor) => this.projects.openMainMenu(anchor),
|
||||
openMainMenu: (anchor) => this.projects.openMainMenu(anchor, this.managementEntries()),
|
||||
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||||
togglePause: () => this.play.togglePause(),
|
||||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||||
@@ -347,7 +376,7 @@ export class Editor implements PlayHost {
|
||||
game: this.gameView,
|
||||
...this.assetEditors,
|
||||
list: this.listEditor,
|
||||
plugins: this.pluginsPane,
|
||||
management: this.managementPane,
|
||||
};
|
||||
// Following a call goes on in the same tab: the tab changes its document, not its place.
|
||||
this.graphEditor.onRetarget = (from, to) => {
|
||||
@@ -440,6 +469,19 @@ export class Editor implements PlayHost {
|
||||
state.on('mode', () => this.editorRun.invalidate());
|
||||
}
|
||||
|
||||
/**
|
||||
* The main menu's last group (#179): the user management for an administrator, and the plugins
|
||||
* for everybody – both open the one tab, which holds the plugins below the accounts.
|
||||
*/
|
||||
private managementEntries(): MenuEntry[] {
|
||||
const open = () => this.openDocument('management');
|
||||
return [
|
||||
{ separator: true },
|
||||
...(this.usersPane ? [{ label: 'User management', onSelect: open }] : []),
|
||||
{ label: 'Plugins', onSelect: open },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The account menu under its button (#182). The name stands in it as an entry that cannot be
|
||||
* picked: it says who is signed in without costing the crowded toolbar another label.
|
||||
@@ -539,7 +581,8 @@ export class Editor implements PlayHost {
|
||||
this.documents = closeDocument(this.documents, doc);
|
||||
this.tabs.removeTab(doc);
|
||||
const kind = this.kindOf(doc);
|
||||
if (kind && kind !== 'list') this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
|
||||
if (kind && kind in this.assetEditors)
|
||||
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
|
||||
// The List tab edits the one slot there is; a closed tab lets go of it.
|
||||
if (doc === 'list' && this.state.listSlot) this.state.openList(null);
|
||||
if (!wasShowing) return;
|
||||
@@ -557,6 +600,10 @@ export class Editor implements PlayHost {
|
||||
if (this.state.listSlot) this.openDocument('list');
|
||||
return;
|
||||
}
|
||||
if (tab === 'management') {
|
||||
this.openDocument('management');
|
||||
return;
|
||||
}
|
||||
if (tab in this.assetEditors) {
|
||||
const editor = this.assetEditors[tab as AssetKind];
|
||||
const id =
|
||||
@@ -577,6 +624,7 @@ export class Editor implements PlayHost {
|
||||
|
||||
private kindOf(doc: DocumentId): CentreTab | null {
|
||||
if (doc === 'list') return this.state.listSlot ? 'list' : null;
|
||||
if (doc === 'management') return 'management';
|
||||
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||||
const kind = type ? TAB_OF_ASSET[type] : undefined;
|
||||
return kind && kind !== 'scene' ? kind : null;
|
||||
@@ -585,12 +633,15 @@ export class Editor implements PlayHost {
|
||||
private showDocument(doc: DocumentId): void {
|
||||
const kind = this.kindOf(doc);
|
||||
if (!kind) return;
|
||||
if (kind !== 'list') this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length));
|
||||
if (kind in this.assetEditors) this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length));
|
||||
// Read on every showing: an account may have been changed from another tab in the meantime.
|
||||
if (kind === 'management') void this.usersPane?.reload();
|
||||
this.display(kind, doc);
|
||||
}
|
||||
|
||||
/** What a document's tab says: the asset's name, or the list's owner and name, as its tab heads it. */
|
||||
private labelOf(doc: DocumentId): string {
|
||||
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
|
||||
if (doc === 'list') {
|
||||
const slot = this.state.listSlot;
|
||||
return (slot && resolveList(this.state, slot)?.title) ?? '';
|
||||
@@ -600,6 +651,7 @@ export class Editor implements PlayHost {
|
||||
|
||||
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
|
||||
private iconOf(doc: DocumentId): string {
|
||||
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
|
||||
const type = doc === 'list' ? undefined : findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||||
return type ? ASSET_ICON_SVG[type] : LIST_ICON_SVG;
|
||||
}
|
||||
@@ -613,7 +665,7 @@ export class Editor implements PlayHost {
|
||||
const kind = this.kindOf(doc);
|
||||
if (!this.tabs.has(doc) && kind)
|
||||
this.tabs.addTab(doc, this.labelOf(doc), base + index, {
|
||||
kind: TAB_LABELS[kind],
|
||||
kind: doc === 'management' ? this.labelOf(doc) : TAB_LABELS[kind],
|
||||
icon: this.iconOf(doc),
|
||||
onClose: () => this.closeDocument(doc),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user