Changing the password: the Logout button at the far right of the toolbar became an Account menu that names the signed-in account in an entry of its own that cannot be picked, with Change password... above Logout, and the new dialog (src/editor/auth/PasswordDialog.ts) asks for the current password, the new one and its repetition, all three as bullets, and stays standing when the server refuses so that it can say why, unlike the other form dialogs; the rules for empty, too short, new equal to old and a differing repetition sit as the pure function passwordProblem() next to loginProblem(), the API gained POST ?r=auth/password which answers 204, insists on the current password although a session is there, enforces a minimum length of 8 in UserStore::MIN_PASSWORD_LENGTH and again in the client so the mask can answer without asking the server, and the new SessionStore::destroyAllFor($user, $keep) ends every other session of the account while the tab that changed the password keeps its own; the placeholders admin/admin and user/user stay as they are, which the length check does not touch because it only runs when a password is set through the API; e2e 45 grew by 8 checks for the menu, the dialog, both refusals and signing in with the old and the new password, and it sets the e2e account back at the end - a run dying in between leaves it on <password>-tmp, which a make-user.php call repairs (#182)
game-maker / gate (push) Successful in 2m4s
game-maker / suite (push) Successful in 7m18s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 13:46:40 +02:00
1 parent 1ef497fa17
commit c830c7a3aa
15 files changed
+473 -18

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1118 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1126 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+21
View File
@@ -10,6 +10,9 @@ declare(strict_types=1);
* GET ?r=auth/session → {user} (null while nobody is signed in)
* POST ?r=auth/login {username,password} → {user} | 401
* POST ?r=auth/logout → 204
* POST ?r=auth/password {currentPassword,newPassword} → 204 | 401 | 422
* (the only auth route that needs a session; ends the account's
* other logins, keeps the one asking)
*
* Everything below needs a signed-in user (401 otherwise), and projects belong to one: a project
* of somebody else answers like one that is not there.
@@ -97,6 +100,24 @@ function routeAuth(array $parts, string $method, Auth $auth): never
Response::json(['user' => $user]);
}
if ($what === 'password') {
if ($method !== 'POST') {
Response::error('Method not allowed', 405);
}
// The one route under `auth/` that needs a session: it changes the password of whoever is
// signed in, so there has to be somebody.
$user = $auth->require();
$body = Response::body();
$new = (string) ($body['newPassword'] ?? '');
if (!UserStore::isValidPassword($new)) {
Response::error('The new password needs at least ' . UserStore::MIN_PASSWORD_LENGTH . ' characters', 422);
}
if (!$auth->changePassword($user, (string) ($body['currentPassword'] ?? ''), $new)) {
Response::error('Wrong password', 401);
}
Response::noContent();
}
if ($what === 'logout') {
if ($method !== 'POST') {
Response::error('Method not allowed', 405);
+25 -1
View File
@@ -42,13 +42,37 @@ final class Auth
public function logOut(): void
{
$token = (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
$token = $this->token();
if ($token !== '') {
$this->sessions->destroy($token);
}
$this->setCookie('', time() - 3600);
}
/**
* Sets a new password for `$user`, who has to type the old one again: a session alone is not
* enough, or somebody else's open tab would be all it takes to take an account over.
*
* Every other login of the account ends with it – a session that was started with the old
* password has no business outliving it. The one asking keeps theirs.
*
* @return bool False when the current password does not match; the caller answers 401 then.
*/
public function changePassword(string $user, string $current, string $new): bool
{
if ($this->users->verify($user, $current) === null) {
return false;
}
$this->users->put($user, $new);
$this->sessions->destroyAllFor($user, $this->token());
return true;
}
private function token(): string
{
return (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
}
private function setCookie(string $value, int $expires): void
{
setcookie(SessionStore::COOKIE, $value, [
+22
View File
@@ -60,6 +60,28 @@ final class SessionStore
}
}
/**
* Ends every login of `$username` but the one behind `$keep` – what a password change does, so
* that a session started with the old password cannot outlive it. Returns how many were ended.
*/
public function destroyAllFor(string $username, string $keep = ''): int
{
$keepPath = $keep === '' ? null : $this->path($keep);
$ended = 0;
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
if ($file === $keepPath) {
continue;
}
$raw = file_get_contents($file);
$data = $raw === false ? null : json_decode($raw, true);
if (is_array($data) && strcasecmp((string) ($data['username'] ?? ''), $username) === 0) {
@unlink($file);
$ended++;
}
}
return $ended;
}
/** Throws out what has timed out, so the directory does not grow with every login. */
private function sweep(): void
{
+12
View File
@@ -10,6 +10,13 @@ declare(strict_types=1);
*/
final class UserStore
{
/**
* The shortest password an account may be given. The sign-in form knows the same number
* (`loginRules.ts`) so it can say so without asking, but this is the one that decides: a
* client is free to send anything.
*/
public const MIN_PASSWORD_LENGTH = 8;
public function __construct(private readonly string $file) {}
public static function isValidName(string $name): bool
@@ -17,6 +24,11 @@ final class UserStore
return (bool) preg_match('/^[a-z0-9][a-z0-9._-]{1,31}$/i', $name);
}
public static function isValidPassword(string $password): bool
{
return strlen($password) >= self::MIN_PASSWORD_LENGTH;
}
/** The name as stored, or null when the pair does not match an account. */
public function verify(string $username, string $password): ?string
{
+6
View File
@@ -8,6 +8,12 @@ export interface AuthStore {
/** The user name on success; a rejected promise when the pair is wrong. */
logIn(username: string, password: string): Promise<string>;
logOut(): Promise<void>;
/**
* Replaces the password of the signed-in account. The current one has to come along even though
* there is a session – and the server ends the account's other sessions, not this one.
* Rejects with `WrongCredentialsError` when the current password does not match.
*/
changePassword(currentPassword: string, newPassword: string): Promise<void>;
}
/** Thrown for a wrong user name or password, as opposed to a server that is not there. */
+16
View File
@@ -29,4 +29,20 @@ export class HttpAuthStore implements AuthStore {
async logOut(): Promise<void> {
await apiRequest<void>(this.endpoint, 'POST', 'auth/logout');
}
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
await apiRequest<void>(
this.endpoint,
'POST',
'auth/password',
{
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
},
// Same split as `logIn`: a 401 here means the current password was wrong, which the dialog
// says in its own words. A 422 (too short) carries the server's wording and stays generic.
(status, data) =>
status === 401 ? new WrongCredentialsError((data as { error?: string } | null)?.error) : undefined,
);
}
}
+4
View File
@@ -90,10 +90,14 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
new HttpFileStore(API_ENDPOINT, 'fonts'),
new HttpFileStore(API_ENDPOINT, 'sounds'),
{
user,
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);
+40 -2
View File
@@ -10,6 +10,7 @@ import {
clamp,
HStack,
isRendering,
Menu,
Panel,
renderCount,
requestRender,
@@ -27,6 +28,8 @@ import { GameViewport } from './viewport/GameViewport';
import { SceneViewport } from './viewport/SceneViewport';
import { TAB_OF_ASSET } from './assetTabs';
import { attachAutosave } from './autosave';
import { PasswordDialog } from './auth/PasswordDialog';
import { dialogSlot } from './FormDialog';
import { ContainerEditor } from './container/ContainerEditor';
import { EditorRun } from './EditorRun';
import { GraphEditor } from './graph/GraphEditor';
@@ -73,9 +76,13 @@ export type PluginTabId = `plugin:${string}`;
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';
/** How to end the session; the button for it sits in the toolbar. */
/** 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;
logOut: () => void;
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
changePassword: (current: string, next: string) => Promise<void>;
}
/** A button in the tab row. */
@@ -190,6 +197,7 @@ export class Editor implements PlayHost {
/** The plugins of this editor and their switches; null until `startPlugins` has run. */
plugins: PluginManager | null = null;
private readonly toolbar: Toolbar;
private readonly passwordDialog = dialogSlot<PasswordDialog>();
/** Drawn-frame count at the last measurement, not a counter of its own. */
private fpsFrames = 0;
private fpsElapsed = 0;
@@ -259,7 +267,7 @@ export class Editor implements PlayHost {
openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(),
logOut: () => account.logOut(),
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
});
root.add(this.toolbar);
@@ -415,6 +423,34 @@ export class Editor implements PlayHost {
state.on('mode', () => this.editorRun.invalidate());
}
/**
* 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.
*/
private openAccountMenu(anchor: Widget, account: EditorAccount): void {
new Menu([
{ label: `Signed in as ${account.user}`, disabled: true, onSelect: () => {} },
{ separator: true },
{
label: 'Change password…',
onSelect: () => {
const dialog = this.passwordDialog.open(
new PasswordDialog(account.changePassword, () =>
this.state.setNotice('Password changed – your other sessions were signed out'),
),
);
dialog.focusFirstField();
},
},
{ label: 'Logout', onSelect: () => account.logOut() },
]).openBelow(anchor);
}
/** The open "Change password" dialog, for the check scripts. */
accountDialog(): PasswordDialog | null {
return this.passwordDialog.current();
}
/** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */
hasTab(id: string): boolean {
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
@@ -621,6 +657,8 @@ export class Editor implements PlayHost {
this.editorRun.shutDown();
await this.plugins?.stopAll();
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
// Closed rather than just thrown away with the overlay below, so the slot lets go of it too.
this.passwordDialog.current()?.close();
// A menu or a picker left open belongs to widgets that are about to be destroyed.
for (const popup of ui().overlay.removeChildren()) popup.destroy({ children: true });
ui().focus.blur();
+109
View File
@@ -0,0 +1,109 @@
import { WrongCredentialsError } from '../../core/store/AuthStore';
import { Button, HStack, Label, requestRender, Spacer, TextField, Theme, ui } from '../../ui';
import { FormDialog } from '../FormDialog';
import { MIN_PASSWORD_LENGTH, passwordError, passwordProblem } from './loginRules';
const FIELD_WIDTH = 200;
/**
* "Change password" (#182): the current password, the new one and its repeat, all three drawn as
* bullets like the sign-in screen's.
*
* Unlike the other form dialogs this one does not close when its button is pressed – a refused
* change has to say why, and the form it says it about must still be standing.
*/
export class PasswordDialog extends FormDialog {
private readonly current: TextField;
private readonly next: TextField;
private readonly repeat: TextField;
private readonly message: Label;
private readonly confirm: Button;
private busy = false;
constructor(
private readonly change: (current: string, next: string) => Promise<void>,
private readonly onChanged: () => void,
) {
super(360, 'Change password');
this.content.add(
new Label(`At least ${MIN_PASSWORD_LENGTH} characters. Your other sessions are signed out.`, {
style: 'muted',
}),
);
this.current = this.secretField(() => ui().focus.focus(this.next));
this.next = this.secretField(() => ui().focus.focus(this.repeat));
this.repeat = this.secretField(() => void this.submit());
this.content.add(this.formRow(this.fieldLabel('Current'), this.current));
this.content.add(this.formRow(this.fieldLabel('New'), this.next));
this.content.add(this.formRow(this.fieldLabel('Repeat'), this.repeat));
this.message = new Label('', { style: 'warning' });
this.content.add(this.message);
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
buttons.add(new Spacer(), 1);
const cancel = new Button('Cancel');
cancel.onClick = this.closeThen();
buttons.add(cancel);
this.confirm = new Button('Change password', { variant: 'primary', onClick: () => void this.submit() });
buttons.add(this.confirm);
this.content.add(buttons);
}
/** Puts the caret in the first field, so a keyboard alone can go through the form. */
focusFirstField(): void {
ui().focus.focus(this.current);
}
/** 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 secretField(onSubmit: () => void): TextField {
return new TextField('', { width: FIELD_WIDTH, secret: true, onSubmit });
}
private async submit(): Promise<void> {
if (this.busy) return;
const current = this.current.value;
const next = this.next.value;
const problem = passwordProblem(current, next, this.repeat.value);
if (problem) {
this.say(problem);
this.refocus(current === '' ? this.current : this.next);
return;
}
this.setBusy(true);
try {
await this.change(current, next);
this.close();
this.onChanged();
} catch (error) {
this.say(passwordError(error, error instanceof WrongCredentialsError));
this.setBusy(false);
this.refocus(this.current);
}
}
/**
* Puts the caret back after a refused attempt. A field blurs itself once its Enter has been
* handled, and that happens after this call returns (see `LoginScreen.refocus`).
*/
private refocus(field: TextField): void {
queueMicrotask(() => ui().focus.focus(field));
}
private setBusy(busy: boolean): void {
this.busy = busy;
this.confirm.setEnabled(!busy);
requestRender();
}
private say(message: string): void {
this.message.setText(message);
this.refreshLayout();
requestRender();
}
}
+23
View File
@@ -13,3 +13,26 @@ 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)}`;
}
/**
* The shortest password the editor accepts. `UserStore::MIN_PASSWORD_LENGTH` holds the same number
* and is the one that decides; this is so the form can say it without asking.
*/
export const MIN_PASSWORD_LENGTH = 8;
/** What the "Change password" form may complain about before anything is sent. */
export function passwordProblem(current: string, next: string, repeat: string): string | null {
if (current === '') return 'Enter your current password';
if (next === '') return 'Enter a new password';
if (next.length < MIN_PASSWORD_LENGTH)
return `The new password needs at least ${MIN_PASSWORD_LENGTH} characters`;
if (next === current) return 'The new password is the old one';
if (repeat !== next) return 'The two new passwords are different';
return null;
}
/** The message a refused change puts under the form. */
export function passwordError(error: unknown, wrongCredentials: boolean): string {
if (wrongCredentials) return 'Wrong current password';
return `Could not change the password: ${error instanceof Error ? error.message : String(error)}`;
}
+2
View File
@@ -204,6 +204,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
labelsDropped,
shownLabel,
tabRow: () => editor.tabRow(),
/** The open "Change password" dialog (#182): its message and whether it is waiting, or null. */
passwordDialog: () => editor.accountDialog()?.info() ?? null,
/** The dividers left and right of the centre column – dragging them narrows the tab row. */
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
/** The left column: asset panel on top, scene objects below, and the divider between them. */
+7 -5
View File
@@ -14,8 +14,8 @@ export interface ToolbarActions {
togglePlay(): void;
/** Hold or resume the running world; only reachable in play mode. */
togglePause(): void;
/** Ends the session and returns to the sign-in screen. */
logOut(): void;
/** The account menu at the far right: who is signed in, changing the password, signing out. */
openAccountMenu(anchor: Widget): void;
}
const SAVE_LABELS: Record<SaveStatus, string> = {
@@ -94,7 +94,7 @@ export class Toolbar extends HStack {
this.add(this.pauseButton);
this.add(new Spacer(), 1);
// Flexible, not a fixed strip: it is the one thing here that may shrink, and since the Logout
// Flexible, not a fixed strip: it is the one thing here that may shrink, and since the account
// button (#177) the row no longer fits a narrow window otherwise.
this.notice = new Label('', { style: 'small', align: 'right' });
this.add(this.notice, 1);
@@ -104,8 +104,10 @@ export class Toolbar extends HStack {
this.fps = new Label('– fps', { style: 'small', align: 'right' });
this.fps.minWidth = 56;
this.add(this.fps);
// Far right, where the account lives; who is signed in stands in the window title.
this.add(new Button('Logout', { onClick: () => actions.logOut() }));
// Far right, where the account lives. A menu rather than a Logout button (#182): it holds
// everything about the account without asking the row for more width, which it has none of.
const account = new Dropdown('Account', { onClick: () => actions.openAccountMenu(account) });
this.add(account);
state.on('project', () => this.refresh());
state.on('load', () => this.refresh());
+61 -1
View File
@@ -1,7 +1,13 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WrongCredentialsError } from '../../src/core/store/AuthStore';
import { HttpAuthStore } from '../../src/core/store/HttpAuthStore';
import { loginError, loginProblem } from '../../src/editor/auth/loginRules';
import {
loginError,
loginProblem,
MIN_PASSWORD_LENGTH,
passwordError,
passwordProblem,
} from '../../src/editor/auth/loginRules';
/** What the firewall in front of the live site answers instead of any non-2xx (measured for #177). */
const FIREWALL_418 = '<html><head><title>Status Code 418</title></head><body></body></html>';
@@ -61,6 +67,32 @@ describe('HttpAuthStore', () => {
await expect(new HttpAuthStore('/api').logOut()).resolves.toBeUndefined();
expect(mock.mock.calls[0]?.[1]?.method).toBe('POST');
});
it('sends both passwords to change one and takes 204 for done', async () => {
const mock = stubFetch(null, { status: 204 });
await expect(new HttpAuthStore('/api').changePassword('old', 'a-new-one')).resolves.toBeUndefined();
const [url, init] = mock.mock.calls[0]!;
expect(url as string).toContain('r=auth%2Fpassword');
expect(init?.method).toBe('POST');
expect(JSON.parse(init?.body as string)).toEqual({
currentPassword: 'old',
newPassword: 'a-new-one',
});
});
it('tells a wrong current password apart from a refused new one', async () => {
stubFetch(JSON.stringify({ error: 'Wrong password' }), { status: 401 });
await expect(new HttpAuthStore('/api').changePassword('nope', 'a-new-one')).rejects.toBeInstanceOf(
WrongCredentialsError,
);
// 422 is the server turning the new password down, which is not a wrong pair and must not be
// shown as one: its own wording travels instead.
stubFetch(JSON.stringify({ error: 'The new password needs at least 8 characters' }), { status: 422 });
const refused = new HttpAuthStore('/api').changePassword('old', 'short');
await expect(refused).rejects.not.toBeInstanceOf(WrongCredentialsError);
await expect(refused).rejects.toThrow(/at least 8 characters \(422\)/);
});
});
describe('the rules of the sign-in form', () => {
@@ -78,3 +110,31 @@ describe('the rules of the sign-in form', () => {
);
});
});
describe('the rules of the change-password form', () => {
const long = 'a'.repeat(MIN_PASSWORD_LENGTH);
it('asks for what is missing, the current password first', () => {
expect(passwordProblem('', '', '')).toBe('Enter your current password');
expect(passwordProblem('old', '', '')).toBe('Enter a new password');
expect(passwordProblem('old', long, long)).toBeNull();
});
it('refuses a new password that is too short, before anything is sent', () => {
expect(
passwordProblem('old', 'a'.repeat(MIN_PASSWORD_LENGTH - 1), 'a'.repeat(MIN_PASSWORD_LENGTH - 1)),
).toBe(`The new password needs at least ${MIN_PASSWORD_LENGTH} characters`);
});
it('refuses the old password again and a repeat that differs', () => {
expect(passwordProblem(long, long, long)).toBe('The new password is the old one');
expect(passwordProblem('old', long, `${long}x`)).toBe('The two new passwords are different');
});
it('names the wrong current password and the cause of anything else', () => {
expect(passwordError(new WrongCredentialsError('Wrong password'), true)).toBe('Wrong current password');
expect(passwordError(new Error('Server error (500)'), false)).toBe(
'Could not change the password: Server error (500)',
);
});
});
+124 -8
View File
@@ -19,12 +19,19 @@ export async function login(t) {
press,
typeText,
mouse,
clickText,
shot,
baseUrl,
projectId,
} = t;
const password = await e2ePassword();
/**
* What the password is set to in between (#182). It is set back before the step ends; should a
* run die between the two, the account is repaired with
* `make-user.php e2e <the password in data/e2e-password.txt>`.
*/
const tempPassword = `${password}-tmp`;
/** Loads the page without handing the browser a session first. */
const openSignedOut = async () => {
@@ -124,11 +131,30 @@ export async function login(t) {
await evaluate("window.__stayedUp = 'mark'; document.querySelector('canvas').dataset.stayedUp = 'mark'");
// Pressed by hand, not with `clickText`: that one waits for a drawn frame afterwards, and the
// screen the button brings up is built before that frame is due.
const logout = await locate('Logout');
await mouse('mouseMoved', logout.x, logout.y);
await mouse('mousePressed', logout.x, logout.y);
await mouse('mouseReleased', logout.x, logout.y);
// screen Logout brings up is built before that frame is due.
const pressAt = async (spot) => {
await mouse('mouseMoved', spot.x, spot.y);
await mouse('mousePressed', spot.x, spot.y);
await mouse('mouseReleased', spot.x, spot.y);
};
/** The account menu at the far right of the toolbar (#182), which holds Logout since. */
const openAccountMenu = async () => {
await pressAt(await locate('Account'));
await sleep(300);
};
await openAccountMenu();
const accountMenu = await json('({ rows: __gm.menuRows(), enabled: __gm.menuEnabled() })');
check(
'the account menu says who is signed in, as an entry that cannot be picked',
accountMenu.rows[0] === `Signed in as ${E2E_USER}` &&
accountMenu.enabled[0] === false &&
accountMenu.rows.includes('Change password…') &&
accountMenu.rows.includes('Logout'),
accountMenu,
);
await pressAt(await locate('Logout'));
await waitForScreen('login');
check(
'Logout ends the session and comes back to the sign-in screen',
@@ -162,11 +188,101 @@ export async function login(t) {
);
// The rebuilt editor has to be laid out, not sitting unlaid-out in the corner: `resize` lays out
// only when the size changed, and the sign-in screen has set `root` to the size of the window
// already (see `App.ts`). The Logout button is the far right of the toolbar, so it says so.
const placed = await json('({ logout: __gm.locate("Logout")?.x ?? null, width: window.innerWidth })');
// already (see `App.ts`). The account button is the far right of the toolbar, so it says so.
const placed = await json('({ account: __gm.locate("Account")?.x ?? null, width: window.innerWidth })');
check(
'the editor that came back is laid out across the window',
placed.logout !== null && placed.logout > placed.width / 2,
placed.account !== null && placed.account > placed.width / 2,
placed,
);
// Changing the password (#182), through the real dialog. It runs here, at the very end, because
// it takes the account's password away from `data/e2e-password.txt` for a moment.
const openPasswordDialog = async () => {
await openAccountMenu();
await clickText('Change password…');
await sleep(300);
};
/**
* Fills the three fields of a freshly opened dialog and submits. Freshly opened is the point: a
* refused attempt puts the caret into the field it is about, so typing on would fill the wrong
* one – every attempt here starts from Escape and a new dialog.
*/
const fillPasswords = async (current, next, repeat) => {
await typeText(current);
await press('Enter');
await typeText(next);
await press('Enter');
await typeText(repeat);
await press('Enter');
await sleep(800);
};
await openPasswordDialog();
check('the account menu opens the Change password dialog', (await json('__gm.passwordDialog()')) !== null, {
dialog: await json('__gm.passwordDialog()'),
});
await fillPasswords(password, 'short', 'short');
check(
'a new password below the minimum is refused without asking the server',
(await json('__gm.passwordDialog()'))?.message === 'The new password needs at least 8 characters',
await json('__gm.passwordDialog()'),
);
await press('Escape');
await openPasswordDialog();
await fillPasswords('not-the-password', tempPassword, tempPassword);
check(
'a wrong current password is refused and leaves the dialog standing',
(await json('__gm.passwordDialog()'))?.message === 'Wrong current password',
await json('__gm.passwordDialog()'),
);
await shot('45-password');
await press('Escape');
await openPasswordDialog();
await fillPasswords(password, tempPassword, tempPassword);
const changed = await json('({ dialog: __gm.passwordDialog(), notice: __gm.state.notice })');
check(
'the right current password changes it: the dialog closes and the toolbar says so',
changed.dialog === null && changed.notice.startsWith('Password changed'),
changed,
);
// The proof that the new password is the one that counts now: out through the menu, in with it.
await openAccountMenu();
await pressAt(await locate('Logout'));
await waitForScreen('login');
await typeText(E2E_USER);
await press('Enter');
await typeText(password);
await press('Enter');
await sleep(800);
check(
'the password that was replaced does not sign in any more',
(await json('__gm.login()')).message === 'Wrong user name or password' &&
!(await evaluate('Boolean(window.__gm?.state)')),
await json('__gm.login()'),
);
await press('a', { ctrl: true });
await typeText(tempPassword);
await press('Enter');
await waitForScreen('editor');
check(
'the new password signs in',
await evaluate(`__gm?.state?.project?.id === ${JSON.stringify(projectId)}`),
await json('({ project: __gm?.state?.project?.id ?? null })'),
);
// Back to the password the repo's tools know, so the next run starts where this one did.
await openPasswordDialog();
await fillPasswords(tempPassword, password, password);
const restored = await json('({ dialog: __gm.passwordDialog(), notice: __gm.state.notice })');
check(
'the account is handed back with the password the check tools carry',
restored.dialog === null && restored.notice.startsWith('Password changed'),
restored,
);
}