Files
feuerwerk/src/editor/App.ts
T
adminandClaude Opus 5.5 ceaa44e5b8
game-maker / gate (push) Successful in 2m50s
game-maker / suite (push) Successful in 8m57s
Add roles and a user management tab for admins (#180, #179)
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>
2026-09-23 13:59:04 +02:00

153 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Application } from 'pixi.js';
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';
import { fontLibrary } from '../viewport/FontLibrary';
import { soundLibrary } from '../viewport/SoundLibrary';
import { projectPictureFactory } from '../viewport/generatedPictures';
import { textureLibrary } from '../viewport/TextureLibrary';
import { installPixelArtPictures } from '../viewport/pixelArtPicture';
import { pixelCels } from '../viewport/PixelCels';
import { installVectorArtPictures } from '../viewport/vectorArtPicture';
import { LoginScreen } from './auth/LoginScreen';
import { installLoginHook } from './auth/loginHook';
import { consoleLog } from './console/log';
import { installDebugHook } from './debugHook';
import { Editor } from './Editor';
import { startPlugins } from './plugins/startPlugins';
import { CelUploadStore } from './pixelart/celUploadStore';
import { EditorState } from './state';
const API_ENDPOINT = '/game-maker/api/index.php';
/**
* Where uploaded images live: the API writes them, the browser reads them straight off disk as
* static files named by their content hash. Fonts and sounds work the same way, one directory over.
*/
const TEXTURE_BASE = '/game-maker/data/textures';
const FONT_BASE = '/game-maker/data/fonts';
const SOUND_BASE = '/game-maker/data/sounds';
const PIXEL_BASE = '/game-maker/data/pixels';
/**
* Shows the sign-in screen until somebody is signed in, and answers with their name. Returns
* 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<SignedIn> {
const existing = await auth.session();
if (existing !== null) return existing;
let done: (account: SignedIn) => void = () => {};
const screen = new LoginScreen(auth, (account) => done(account));
const removeHook = installLoginHook(app, screen);
const account = await new Promise<SignedIn>((resolve) => {
done = resolve;
root.add(screen, 1);
root.resize(app.screen.width, app.screen.height);
screen.focusFirstField();
});
removeHook();
root.removeChild(screen);
// The editor sizes `root` itself once it is built, but `resize` lays out only when the size
// changed – and the sign-in screen has already set it to the size of the window. Forgetting the
// size here is what makes that call count again; without it the whole editor sits unlaid-out in
// the corner.
root.resize(0, 0);
screen.destroy({ children: true });
return account;
}
/**
* Wires state, stores and the editor composition together and opens the first project.
*
* Signing out does not reload the page (#177): it takes the editor down and puts the sign-in
* screen back, in the same canvas with the same renderer. So this is a loop rather than a straight
* line – every round is one account's session. Only the first round is awaited, because the caller
* has more to set up once the editor is up; the rounds after it run on their own.
*/
export async function startEditor(app: Application, root: VStack): Promise<void> {
const auth = new HttpAuthStore(API_ENDPOINT);
textureLibrary.configure(TEXTURE_BASE);
fontLibrary.configure(FONT_BASE);
soundLibrary.configure(SOUND_BASE);
pixelCels.configure(PIXEL_BASE);
// An image or a font arrives long after the click that asked for it, and the widget waiting for
// it repaints without telling anyone: both libraries are a wake-up of their own. Registered once
// for the page, not per session – the files they hold are shared by every account anyway.
textureLibrary.onChange(() => requestRender());
fontLibrary.onChange(() => requestRender());
// Vector art draws its own picture and needs the renderer to do it (#129). Registered once for
// the page like the libraries above: which project it is asked about comes from the factory below.
installVectorArtPictures(app.renderer);
installPixelArtPictures();
const openSession = async (): Promise<void> => {
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}`;
const state = new EditorState();
// Drawn pictures (#185) are looked up in the project this session holds; the next session
// installs its own, which is also what lets go of everything the old one had drawn.
textureLibrary.setPictureFactory(projectPictureFactory(() => state.project));
const editor = new Editor(
app,
root,
state,
new CelUploadStore(
new HttpProjectStore(API_ENDPOINT),
pixelCels,
new HttpFileStore(API_ENDPOINT, 'pixels'),
),
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
new HttpFileStore(API_ENDPOINT, 'fonts'),
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);
}),
// Straight through to the store: the server checks the old password, and the session this
// tab holds is the one it keeps – so there is nothing to do here afterwards.
changePassword: (current, next) => auth.changePassword(current, next),
},
);
installDebugHook(app, state, editor);
// Before the project, not after it (#119): which plugins run is written in the project file,
// and `boot()` hands that list over before it reads the file into the model.
await startPlugins(API_ENDPOINT, state, editor);
await editor.boot();
loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error));
};
/**
* Ends the session the Logout button belongs to: the editor writes what is unsaved and lets go
* of everything, then the server drops the session, and the sign-in screen comes back. The
* editor goes first, so its last save still travels with a session the server accepts.
*/
const endSession = async (editor: Editor): Promise<void> => {
await editor.dispose();
document.title = 'Game Maker';
// The console panel is page-wide, the lines in it are not: what one account's project printed
// is none of the next one's business.
consoleLog.clear();
try {
await auth.logOut();
} catch (error) {
console.error('[auth] Signing out did not reach the server:', error);
}
await openSession();
};
await openSession();
}