screentinker/server/test/login-identifier-first.test.js
ScreenTinker 184ff71dee Let an existing account move to SSO, and ask who you are before how
Two halves of the same problem: an account created with a password could never
use single sign-on, and the login page offered a credential before it knew
which one applied.

LINKING. Signing in with a provider never adopts an account that already has a
password -- that is the takeover the login path exists to refuse. The README
promised the way out ("the owner signs in locally and links from Settings") but
nothing had ever been built, so the refusal was a dead end rather than a
redirection. Settings now has a Sign-in method block: an account with a password
can link an instance-wide provider, and one on a provider can unlink back to a
password.

The account being linked comes from the SIGNED TRANSACTION -- the session that
started it -- never from the email in the returned token. That distinction is
the whole feature: taking it from the token would be the same email-keyed
takeover under a friendlier name. The email must still match the account's own,
because login resolves accounts by the asserted address, and one provider
subject may not be linked to two accounts.

Linking DELETES the password rather than keeping it alongside. One credential at
a time, and the confirmation says so in those words, because a password left
behind is a second way in that the user believes they replaced. Unlink therefore
takes the new password up front and writes it in the SAME statement as the
unlink -- never unlink now and set a password after, which leaves an account
briefly, or on failure permanently, with no way in.

Instance-wide providers only. An organization's provider is chosen by a
customer; letting one attach itself to a platform account would hand that
customer whatever the account can do.

IDENTIFIER-FIRST. The password box now appears only after an address has been
submitted, which is what lets the organization lookup happen before a credential
is offered: someone whose company requires its own provider is shown that,
rather than a password box that will be refused. Editing the address returns to
the identifier step so a corrected domain gets a fresh answer.

The per-keystroke lookup is gone with it. It answered for half-typed domains,
changed the form under someone mid-address, and spent a 10/min per-IP budget on
people who had not finished typing -- an office behind one address could exhaust
it without a single sign-in attempt.

Instance-wide providers stay visible at all times now, by decision: the server
refuses them for an SSO-only organization anyway, and hiding them made the page
change shape while typing.

Verified in a real browser, not only by rendering: password hidden -> submit ->
visible and focused -> edit the address -> hidden again, with no page errors.
Four mutations of the linking rules fail the tests (account from the email
instead of the session, keeping the password, allowing org providers, dropping
requireAuth).
2026-08-12 11:48:11 -05:00

64 lines
2.9 KiB
JavaScript

'use strict';
/*
* Identifier-first login (#258).
*
* The password box does not exist until an address has been submitted. That is what lets the
* organization lookup happen BEFORE a credential is offered, so someone whose company requires its
* own identity provider is never shown a password box that is going to be refused.
*
* Verified in a real browser as well (password hidden -> submit -> visible + focused -> edit the
* address -> hidden again); these assertions stop the wiring being removed silently.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const LOGIN = fs.readFileSync(path.join(__dirname, '..', '..', 'frontend', 'js', 'views', 'login.js'), 'utf8');
test('password visibility depends on BOTH identification and SSO-only', () => {
assert.match(LOGIN, /const showPassword = identified && !ssoOnlyDomain;/,
'the two drivers must be combined in one place so they cannot disagree');
});
test('the primary button advances before it signs in', () => {
assert.match(LOGIN, /if \(identified && !ssoOnlyDomain\) return doLogin\(\);\s*\n\s*identify\(\);/,
'the button must identify first and only sign in once an address is known');
assert.match(LOGIN, /btn\.textContent = identified && !ssoOnlyDomain \? t\('auth\.sign_in'\) : t\('auth\.next'\)/);
});
test('editing the address returns to the identifier step', () => {
assert.match(LOGIN, /if \(!identified\) return;\s*\n\s*identified = false;/,
'a corrected address must get a fresh answer, not the previous domain\'s');
});
test('the per-keystroke lookup is gone', () => {
assert.doesNotMatch(LOGIN, /ssoLookupTimer/,
'the debounced lookup answered for half-typed domains and burned a 10/min budget');
assert.match(LOGIN, /async function identify\(\)[\s\S]{0,400}await lookupOrgSso\(email\)/,
'the lookup now runs on submit');
});
test('instance-wide providers are never hidden', () => {
// Deliberate: they are the operator's, offered to everyone, and the server refuses them for an
// SSO-only organization anyway. Hiding them made the page change shape while typing.
assert.doesNotMatch(LOGIN, /getElementById\('instanceProviders'\)[\s\S]{0,120}style\.display/,
'nothing may hide #instanceProviders');
});
test('first-run setup skips identifier-first', () => {
assert.match(LOGIN, /if \(isSetup\) identified = true;/,
'creating the first admin needs both fields at once');
});
test('the initial state is applied after its declarations (temporal dead zone)', () => {
const decl = LOGIN.indexOf('let identified = false;');
const call = LOGIN.lastIndexOf('\n applyFormState();');
assert.ok(decl !== -1 && call !== -1, 'both the declaration and the init call must exist');
assert.ok(call > decl,
'applyFormState() must be called AFTER the let declarations — earlier throws on the TDZ, which '
+ 'on this page means a login form that never renders');
});