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
+10
-7
@@ -1,8 +1,9 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { AuthStore } from '../core/store/AuthStore';
|
||||
import type { AuthStore, SignedIn } from '../core/store/AuthStore';
|
||||
import { HttpAuthStore } from '../core/store/HttpAuthStore';
|
||||
import { HttpFileStore } from '../core/store/HttpFileStore';
|
||||
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
||||
import { HttpUsersStore } from '../core/store/HttpUsersStore';
|
||||
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
|
||||
import { loadPhysics } from '../runtime/physics/engine';
|
||||
import { requestRender, type VStack } from '../ui';
|
||||
@@ -38,14 +39,14 @@ const PIXEL_BASE = '/game-maker/data/pixels';
|
||||
* right away when the session from an earlier visit is still good – which is what lets a
|
||||
* deployment replace the editor under an open tab without asking for the password again.
|
||||
*/
|
||||
async function signedInUser(app: Application, root: VStack, auth: AuthStore): Promise<string> {
|
||||
async function signedInUser(app: Application, root: VStack, auth: AuthStore): Promise<SignedIn> {
|
||||
const existing = await auth.session();
|
||||
if (existing !== null) return existing;
|
||||
|
||||
let done: (user: string) => void = () => {};
|
||||
const screen = new LoginScreen(auth, (user) => done(user));
|
||||
let done: (account: SignedIn) => void = () => {};
|
||||
const screen = new LoginScreen(auth, (account) => done(account));
|
||||
const removeHook = installLoginHook(app, screen);
|
||||
const user = await new Promise<string>((resolve) => {
|
||||
const account = await new Promise<SignedIn>((resolve) => {
|
||||
done = resolve;
|
||||
root.add(screen, 1);
|
||||
root.resize(app.screen.width, app.screen.height);
|
||||
@@ -59,7 +60,7 @@ async function signedInUser(app: Application, root: VStack, auth: AuthStore): Pr
|
||||
// the corner.
|
||||
root.resize(0, 0);
|
||||
screen.destroy({ children: true });
|
||||
return user;
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +88,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
installPixelArtPictures();
|
||||
|
||||
const openSession = async (): Promise<void> => {
|
||||
const user = await signedInUser(app, root, auth);
|
||||
const { user, role } = await signedInUser(app, root, auth);
|
||||
// Who is signed in goes into the window title: the toolbar is crowded enough, and the title is
|
||||
// also what tells two tabs on two accounts apart.
|
||||
document.title = `Game Maker – ${user}`;
|
||||
@@ -109,6 +110,8 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
new HttpFileStore(API_ENDPOINT, 'sounds'),
|
||||
{
|
||||
user,
|
||||
role,
|
||||
users: new HttpUsersStore(API_ENDPOINT),
|
||||
logOut: () =>
|
||||
void endSession(editor).catch((error: unknown) => {
|
||||
console.error('[auth] Signing out failed:', error);
|
||||
|
||||
+68
-16
@@ -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),
|
||||
});
|
||||
|
||||
@@ -47,6 +47,11 @@ export class ProjectSession {
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a project by its id – from the user management, where it may be somebody else's (#179). */
|
||||
openProject(id: string): void {
|
||||
void this.run(this.open(id));
|
||||
}
|
||||
|
||||
private async open(id: string): Promise<void> {
|
||||
this.beforeSwitch();
|
||||
const loaded = await this.store.load(id, this.prepare);
|
||||
@@ -75,14 +80,18 @@ export class ProjectSession {
|
||||
if (id === this.state.project.id) await this.openFirstOrCreate();
|
||||
}
|
||||
|
||||
/** The one menu of the top bar (#183): everything that is about the project rather than a scene. */
|
||||
openMainMenu(anchor: Widget): void {
|
||||
/**
|
||||
* The one menu of the top bar (#183): everything that is about the project rather than a scene.
|
||||
* `more` comes last – what the editor adds that is not about the project (#179).
|
||||
*/
|
||||
openMainMenu(anchor: Widget, more: MenuEntry[] = []): void {
|
||||
const items: MenuEntry[] = [
|
||||
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
|
||||
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
|
||||
{ separator: true },
|
||||
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
|
||||
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
|
||||
...more,
|
||||
];
|
||||
new Menu(items).openBelow(anchor);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { type AuthStore, WrongCredentialsError } from '../../core/store/AuthStore';
|
||||
import { type AuthStore, type SignedIn, WrongCredentialsError } from '../../core/store/AuthStore';
|
||||
import { Button, drawControlBox, Label, Spacer, TextField, Theme, ui, VStack } from '../../ui';
|
||||
import { FormStatus, refocus } from './FormStatus';
|
||||
import { loginError, loginProblem } from './loginRules';
|
||||
@@ -40,7 +40,7 @@ export class LoginScreen extends VStack {
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthStore,
|
||||
private readonly onSignedIn: (user: string) => void,
|
||||
private readonly onSignedIn: (account: SignedIn) => void,
|
||||
) {
|
||||
super({ align: 'center', gap: Theme.gap });
|
||||
this.add(new Spacer(), 1);
|
||||
@@ -101,8 +101,7 @@ export class LoginScreen extends VStack {
|
||||
}
|
||||
this.status.setBusy(true);
|
||||
try {
|
||||
const user = await this.auth.logIn(username, password);
|
||||
this.onSignedIn(user);
|
||||
this.onSignedIn(await this.auth.logIn(username, password));
|
||||
} catch (error) {
|
||||
this.status.say(loginError(error, error instanceof WrongCredentialsError));
|
||||
this.status.setBusy(false);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { currentFontPicker } from '../font/FontPicker';
|
||||
import { currentSoundPicker } from '../sound/SoundPicker';
|
||||
import { rectOf, textProbe } from '../textProbe';
|
||||
import { currentImagePicker } from '../texture/ImagePicker';
|
||||
import type { UsersPane } from '../users/UsersPane';
|
||||
import { Color, type ColorSource, type Container, Text } from 'pixi.js';
|
||||
import { centreOf, type HookContext } from './hookHelpers';
|
||||
|
||||
@@ -168,6 +169,26 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
||||
tabRow: () => editor.tabRow(),
|
||||
/** The open "Change password" dialog (#182): its message and whether it is waiting, or null. */
|
||||
passwordDialog: () => editor.accountDialog()?.info() ?? null,
|
||||
/**
|
||||
* The user management (#179); `pane()` is null for an account that is no administrator.
|
||||
* `buttonAt` takes an action (`rename`, `role`, `password`, `delete`) or `open:<project id>`.
|
||||
*/
|
||||
users: {
|
||||
pane: () =>
|
||||
editor.usersPane ? { visible: editor.tab === 'management', ...editor.usersPane.debugInfo() } : null,
|
||||
buttonAt: (username: string, action: Parameters<UsersPane['buttonOf']>[1]) => {
|
||||
const button = editor.usersPane?.buttonOf(username, action);
|
||||
return button
|
||||
? { ...centreOf(button), right: rectOf(button).x + button.w, enabled: button.enabled }
|
||||
: null;
|
||||
},
|
||||
/** The list on screen, for checks that its rows fit into it. */
|
||||
rect: () => (editor.usersPane ? rectOf(editor.usersPane) : null),
|
||||
/** The open form: its title, the message under it, whether it waits, its role (null without a picker). */
|
||||
dialog: () => editor.usersPane?.currentDialog()?.info() ?? null,
|
||||
/** Reads the list again and answers once it is drawn – `json()` does not wait for a promise, `evaluate()` does. */
|
||||
reload: () => editor.usersPane?.reload(),
|
||||
},
|
||||
/** The project's collision layers (#152), and the names the open dialog holds (null when shut). */
|
||||
collisionLayers: () => ({
|
||||
project: [...state.project.collisionLayers],
|
||||
|
||||
@@ -63,8 +63,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
plugins: {
|
||||
/** Every file in `data/plugins/` and what became of it. */
|
||||
records: () => [...(editor.plugins?.records ?? [])],
|
||||
/** The Plugins tab: one row per file, with its switch. */
|
||||
pane: () => editor.pluginsPane.debugInfo(),
|
||||
/** The plugins in the user management tab (#179): one row per file, with its switch. */
|
||||
pane: () => ({ ...editor.pluginsPane.debugInfo(), visible: editor.tab === 'management' }),
|
||||
/** Where to click the switch of one plugin; null when it has no row. */
|
||||
switchAt: (file: string) => {
|
||||
const box = editor.pluginsPane.switchOf(file);
|
||||
|
||||
@@ -25,6 +25,7 @@ import text from './icons/text.svg?raw';
|
||||
import texture from './icons/texture.svg?raw';
|
||||
import tile from './icons/tile.svg?raw';
|
||||
import tilemap from './icons/tilemap.svg?raw';
|
||||
import users from './icons/users.svg?raw';
|
||||
import variables from './icons/variables.svg?raw';
|
||||
import vectorart from './icons/vectorart.svg?raw';
|
||||
|
||||
@@ -60,3 +61,6 @@ export const GAME_ICON_SVG = game;
|
||||
|
||||
/** The Plugins tab's: a puzzle piece, as a plugin is a piece added to the editor (#119). */
|
||||
export const PLUGINS_ICON_SVG = plugins;
|
||||
|
||||
/** The User management tab's (#179), which holds the plugins as well; an account sees it only as an administrator. */
|
||||
export const USERS_ICON_SVG = users;
|
||||
@@ -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
|
||||
* switch is part of the open project, so it travels with it and undo takes it back.
|
||||
*
|
||||
@@ -102,11 +102,9 @@ export class PluginsPane extends VStack {
|
||||
|
||||
/** The rows as drawn, and the switch to click – what the check scripts read. */
|
||||
debugInfo(): {
|
||||
visible: boolean;
|
||||
rows: { file: string; name: string; status: string; checked: boolean; text: string }[];
|
||||
} {
|
||||
return {
|
||||
visible: this.visible,
|
||||
rows: this.rowWidgets().map((row) => ({
|
||||
file: row.record.file,
|
||||
name: displayName(row.record),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user