diff --git a/README.md b/README.md index 3b99d053..e52a0ae4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/index.php b/api/index.php index 47385042..0eb30905 100644 --- a/api/index.php +++ b/api/index.php @@ -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); diff --git a/api/lib/Auth.php b/api/lib/Auth.php index fded2eaf..7f40f604 100644 --- a/api/lib/Auth.php +++ b/api/lib/Auth.php @@ -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, [ diff --git a/api/lib/SessionStore.php b/api/lib/SessionStore.php index acc1a8ba..811dc7cc 100644 --- a/api/lib/SessionStore.php +++ b/api/lib/SessionStore.php @@ -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 { diff --git a/api/lib/UserStore.php b/api/lib/UserStore.php index b0c3cb31..b7a4c1d5 100644 --- a/api/lib/UserStore.php +++ b/api/lib/UserStore.php @@ -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 { diff --git a/src/core/store/AuthStore.ts b/src/core/store/AuthStore.ts index 5fbcfc91..3c94d053 100644 --- a/src/core/store/AuthStore.ts +++ b/src/core/store/AuthStore.ts @@ -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; logOut(): Promise; + /** + * 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; } /** Thrown for a wrong user name or password, as opposed to a server that is not there. */ diff --git a/src/core/store/HttpAuthStore.ts b/src/core/store/HttpAuthStore.ts index 05cd0280..4152b744 100644 --- a/src/core/store/HttpAuthStore.ts +++ b/src/core/store/HttpAuthStore.ts @@ -29,4 +29,20 @@ export class HttpAuthStore implements AuthStore { async logOut(): Promise { await apiRequest(this.endpoint, 'POST', 'auth/logout'); } + + async changePassword(currentPassword: string, newPassword: string): Promise { + await apiRequest( + 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, + ); + } } diff --git a/src/editor/App.ts b/src/editor/App.ts index 98b331a6..2a42be5f 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -90,10 +90,14 @@ export async function startEditor(app: Application, root: VStack): Promise 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); diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index a7b23d5d..30451580 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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; } /** 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(); /** 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(); diff --git a/src/editor/auth/PasswordDialog.ts b/src/editor/auth/PasswordDialog.ts new file mode 100644 index 00000000..de3e05d0 --- /dev/null +++ b/src/editor/auth/PasswordDialog.ts @@ -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, + 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 { + 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(); + } +} diff --git a/src/editor/auth/loginRules.ts b/src/editor/auth/loginRules.ts index 88f3b324..921962da 100644 --- a/src/editor/auth/loginRules.ts +++ b/src/editor/auth/loginRules.ts @@ -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)}`; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 1d61abff..b779d9ab 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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. */ diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index 9c700023..4fdd68ef 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -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 = { @@ -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()); diff --git a/tests/core/auth.test.ts b/tests/core/auth.test.ts index 94dace0f..4840c42c 100644 --- a/tests/core/auth.test.ts +++ b/tests/core/auth.test.ts @@ -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 = 'Status Code 418'; @@ -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)', + ); + }); +}); diff --git a/tools/e2e/45-login.mjs b/tools/e2e/45-login.mjs index a02479d7..0c8c9880 100644 --- a/tools/e2e/45-login.mjs +++ b/tools/e2e/45-login.mjs @@ -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 `. + */ + 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, + ); }