User management: signing in is required before anything is loaded, the sign-in screen is drawn in the same canvas (user name, password as bullets, Enter moves on and submits) and the editor is built only after it; the API gained auth/session, auth/login and auth/logout while every other route answers 401 without a session, projects carry an owner and a foreign one comes back as 404, accounts live in data/users.json with a password hash and sessions as files in data/sessions with a sliding 30 days, so a build under the open tab does not sign anybody out; Logout sits at the far right of the toolbar and, as asked in the issue, does not reload the page: Editor.dispose() takes the editor down while the canvas stays, which is why the autosave, the focus handlers, the window listeners of the tilemap panel, the shortcuts and the plugins all got a way back; the tools sign in as e2e (tools/lib/auth.mjs, password from GAME_MAKER_E2E_PASSWORD or the git-ignored data/e2e-password.txt) and the browser gets the same session by cookie, new e2e module 45 covers the screen, the wrong pair, the bullets, logout without a reload and signing in again, new unit tests for HttpAuthStore and for what a session leaves behind (#177)
game-maker / gate (push) Successful in 2m5s
game-maker / suite (push) Failing after 6m22s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 12:50:52 +02:00
1 parent 60ea56a2ce
commit 8e4bb6e1f9
38 files changed
+1530 -139

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
import { Graphics } from 'pixi.js';
import { type AuthStore, WrongCredentialsError } from '../../core/store/AuthStore';
import { Button, drawControlBox, Label, requestRender, Spacer, TextField, Theme, ui, VStack } from '../../ui';
import { loginError, loginProblem } from './loginRules';
const CARD_WIDTH = 280;
/** The box in the middle of the screen; the screen around it is the plain editor background. */
class Card extends VStack {
private readonly bg = new Graphics();
constructor() {
super({ padding: Theme.padding * 2, gap: Theme.gap });
this.addChildAt(this.bg, 0);
this.minWidth = CARD_WIDTH;
}
override redraw(): void {
this.bg.clear();
drawControlBox(this.bg, this.w, this.h, { fill: Theme.colors.panel, border: Theme.colors.border });
}
}
/**
* Sign-in screen, drawn in the same canvas as the editor: there is no second canvas and no HTML
* form, so it looks and behaves like every other part of the editor.
*
* It is shown instead of the editor, not over it – nothing of a project is loaded while nobody is
* signed in, so there is nothing behind it to hide.
*/
export class LoginScreen extends VStack {
private readonly user: TextField;
private readonly password: TextField;
private readonly message: Label;
private readonly submit: Button;
private busy = false;
constructor(
private readonly auth: AuthStore,
private readonly onSignedIn: (user: string) => void,
) {
super({ align: 'center', gap: Theme.gap });
this.add(new Spacer(), 1);
const card = new Card();
card.add(new Label('Game Maker', { style: 'title' }));
card.add(new Label('Sign in to open your projects', { style: 'muted' }));
card.add(new Spacer(Theme.gap));
card.add(new Label('User name', { style: 'small' }));
this.user = new TextField('', {
width: CARD_WIDTH - Theme.padding * 4,
placeholder: 'User name',
onSubmit: () => ui().focus.focus(this.password),
});
card.add(this.user);
card.add(new Label('Password', { style: 'small' }));
this.password = new TextField('', {
width: CARD_WIDTH - Theme.padding * 4,
placeholder: 'Password',
secret: true,
onSubmit: () => void this.signIn(),
});
card.add(this.password);
this.message = new Label('', { style: 'warning' });
card.add(this.message);
this.submit = new Button('Sign in', {
variant: 'primary',
width: CARD_WIDTH - Theme.padding * 4,
onClick: () => void this.signIn(),
});
card.add(this.submit);
this.add(card);
this.add(new Spacer(), 1);
}
/** Puts the caret in the first field, so a keyboard alone can sign in. */
focusFirstField(): void {
ui().focus.focus(this.user);
}
/** What the check scripts read: the message under the form and whether it is waiting. */
info(): { message: string; busy: boolean } {
return { message: this.message.text, busy: this.busy };
}
private async signIn(): Promise<void> {
if (this.busy) return;
const username = this.user.value.trim();
const password = this.password.value;
const problem = loginProblem(username, password);
if (problem) {
this.say(problem);
this.refocus(username === '' ? this.user : this.password);
return;
}
this.setBusy(true);
try {
const user = await this.auth.logIn(username, password);
this.onSignedIn(user);
} catch (error) {
this.say(loginError(error, error instanceof WrongCredentialsError));
this.setBusy(false);
// A second attempt starts where the first one went wrong, not in an empty form.
this.refocus(this.password);
}
}
/**
* Puts the caret back into a field after a refused attempt. A field blurs itself once its Enter
* has been handled, and that happens after this call returns – so the focus has to be set again
* behind it, or the next thing typed goes nowhere.
*/
private refocus(field: TextField): void {
queueMicrotask(() => ui().focus.focus(field));
}
private setBusy(busy: boolean): void {
this.busy = busy;
this.submit.setText(busy ? 'Signing in…' : 'Sign in');
this.submit.setEnabled(!busy);
requestRender();
}
private say(message: string): void {
this.message.setText(message);
requestRender();
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { Application } from 'pixi.js';
import { afterFrames, requestRender } from '../../ui';
import { textProbe } from '../textProbe';
import type { LoginScreen } from './LoginScreen';
/**
* A small `window.__gm` for the sign-in screen: the editor's hook cannot be installed yet, and
* without one the check scripts have no way to find the fields they have to fill in.
*
* It carries no `state` – that is how a check tells the sign-in screen from the editor.
* Returns the call that takes it down again.
*/
export function installLoginHook(app: Application, screen: LoginScreen): () => void {
const { locate, locateAll } = textProbe(app);
const global = window as unknown as { __gm?: unknown };
global.__gm = {
locate,
locateAll,
afterFrames,
wake: () => requestRender(),
login: () => screen.info(),
};
return () => {
delete global.__gm;
};
}
+15
View File
@@ -0,0 +1,15 @@
/**
* What a login form may complain about before anything is sent. Kept apart from the screen so it
* can be read without a browser: the screen only shows what this returns.
*/
export function loginProblem(username: string, password: string): string | null {
if (username.trim() === '') return 'Enter your user name';
if (password === '') return 'Enter your password';
return null;
}
/** The message a failed attempt puts under the form. */
export function loginError(error: unknown, wrongCredentials: boolean): string {
if (wrongCredentials) return 'Wrong user name or password';
return `Could not sign in: ${error instanceof Error ? error.message : String(error)}`;
}