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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ef497fa17
commit
c830c7a3aa
15 files changed
+473
-18
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user