mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-18 16:13:51 -06:00
On a fresh install with no users, typing an email address made the password field disappear. Identifier-first login asks the server which identity provider an address uses before offering a credential, so someone whose organization requires its own IdP is never shown a password box that will be refused. First-run setup opted out of that by setting `identified = true` up front - there is nobody to identify, the operator is creating the first account - and then the "editing the address returns to the identifier step" listener fired on the very first keystroke, set it back to false, and re-rendered. Password gone, mid-typing. The same re-render also replaced "Create admin account" on the button with "Sign in" and then "Next". Initialising a flag to the right value is not the same as the flow being inert: any later event could undo it, and one did. The decision now lives in frontend/js/lib/login-form-state.js as a pure function that ignores `identified` and `ssoOnlyDomain` entirely when there are no users, so no event can take a field away. The input listener also returns early during setup, which additionally stops it spending an org-lookup rate-limit budget that has nothing to answer. Two assertions in login-identifier-first.test.js pinned those expressions to their old address inside login.js. Their intent - "the two drivers must be combined in one place so they cannot disagree" - is better served by the extracted module, so they now follow the logic there rather than being loosened. Verified: the extracted pre-fix logic returns showPassword=false and buttonKey=auth.next for setup-plus-one-keystroke, which is the reported symptom exactly; the new truth table covers it. i18n/login/auth/session/sso suites go from 108 to 116 passing, none failing. Not addressed here, and worth its own change: loadAuthConfig() has no error handling and caches its first result, so a /api/auth/config that fails leaves needsSetup undefined and the form falls back to the restrictive layout - the same visible symptom from a different cause. Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56 Co-authored-by: Dan Walters <dan.walters@bytetinker.net> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
91 lines
4.2 KiB
JavaScript
91 lines
4.2 KiB
JavaScript
'use strict';
|
|
|
|
// THE BUG: on a fresh install with no users, typing an email address made the password field
|
|
// disappear.
|
|
//
|
|
// The login form is identifier-first for normal sign-in: it asks the server which identity provider
|
|
// an address uses before offering a credential, so an SSO-only user is never shown a password box
|
|
// that will be refused. First-run setup set `identified = true` up front so both fields were
|
|
// available - there is nobody to identify, the operator is creating the first account - and then the
|
|
// "editing the address returns to the identifier step" listener fired on the first keystroke, set it
|
|
// back to false, and hid the password field mid-typing. The same re-render relabelled the button
|
|
// from "Create admin account" to "Next".
|
|
//
|
|
// The decision now lives in frontend/js/lib/login-form-state.js as a pure function so the whole
|
|
// truth table can be pinned, including the state the old code could not represent: setup mode where
|
|
// `identified` has been clobbered.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const { pathToFileURL } = require('node:url');
|
|
|
|
const MOD = pathToFileURL(
|
|
path.join(__dirname, '..', '..', 'frontend', 'js', 'lib', 'login-form-state.js')).href;
|
|
|
|
let loginFormState;
|
|
test('load the module', async () => {
|
|
({ loginFormState } = await import(MOD));
|
|
assert.equal(typeof loginFormState, 'function');
|
|
});
|
|
|
|
test('THE BUG: during setup the password survives a keystroke in the email box', async () => {
|
|
({ loginFormState } = await import(MOD));
|
|
// identified:false is exactly what the input listener used to leave behind. Setup must not care.
|
|
const s = loginFormState({ isSetup: true, identified: false, ssoOnlyDomain: false });
|
|
assert.equal(s.showPassword, true, 'the password field must stay visible during first-run setup');
|
|
assert.equal(s.buttonKey, 'auth.create_admin_account', 'and the button must not become Next');
|
|
});
|
|
|
|
test('setup shows both fields regardless of any other flag', async () => {
|
|
({ loginFormState } = await import(MOD));
|
|
for (const identified of [true, false]) {
|
|
for (const ssoOnlyDomain of [true, false]) {
|
|
const s = loginFormState({ isSetup: true, identified, ssoOnlyDomain });
|
|
assert.equal(s.showPassword, true,
|
|
`setup must show the password (identified=${identified} ssoOnly=${ssoOnlyDomain})`);
|
|
assert.equal(s.showButton, true, 'and must always offer the button');
|
|
assert.equal(s.buttonKey, 'auth.create_admin_account');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('normal sign-in still hides the password until an address is submitted', async () => {
|
|
({ loginFormState } = await import(MOD));
|
|
const before = loginFormState({ isSetup: false, identified: false, ssoOnlyDomain: false });
|
|
assert.equal(before.showPassword, false, 'identifier-first: no password box yet');
|
|
assert.equal(before.buttonKey, 'auth.next');
|
|
|
|
const after = loginFormState({ isSetup: false, identified: true, ssoOnlyDomain: false });
|
|
assert.equal(after.showPassword, true);
|
|
assert.equal(after.buttonKey, 'auth.sign_in');
|
|
});
|
|
|
|
test('an SSO-only domain gets neither a password box nor a submit button', async () => {
|
|
({ loginFormState } = await import(MOD));
|
|
// The provider button is the only way in; offering a password that will be refused, or a submit
|
|
// that cannot work, is worse than offering nothing.
|
|
const s = loginFormState({ isSetup: false, identified: true, ssoOnlyDomain: true });
|
|
assert.equal(s.showPassword, false);
|
|
assert.equal(s.showButton, false);
|
|
});
|
|
|
|
test('the button key is always a real translation key', async () => {
|
|
({ loginFormState } = await import(MOD));
|
|
// t() renders the KEY when it is undefined, so a typo here puts a bare identifier on the button.
|
|
const fs = require('node:fs');
|
|
const en = fs.readFileSync(
|
|
path.join(__dirname, '..', '..', 'frontend', 'js', 'i18n', 'en.js'), 'utf8');
|
|
const seen = new Set();
|
|
for (const isSetup of [true, false]) {
|
|
for (const identified of [true, false]) {
|
|
for (const ssoOnlyDomain of [true, false]) {
|
|
seen.add(loginFormState({ isSetup, identified, ssoOnlyDomain }).buttonKey);
|
|
}
|
|
}
|
|
}
|
|
for (const key of seen) {
|
|
assert.ok(en.includes(`'${key}'`), `${key} is not defined in en.js`);
|
|
}
|
|
});
|