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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
60ea56a2ce
commit
8e4bb6e1f9
38 files changed
+1530
-139
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in new issue
Block a user