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

+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)}`;
}