Skip to content

Commit e53204d

Browse files
JeroenDeDauwclaude
andauthored
Lead the login form with the code login (#40)
The login form now leads with the member route on wikis that offer the code route. The address box and "Continue with email" come first, that button is the form's primary one, focus lands in the address box, and the password form follows under a divider reading "or log in with a password" in place of "or". Enter submits whatever was filled in: an address asks for a code, a password logs in. The account creation form and the code entry screen are untouched. Done in the form description rather than site CSS, which would move the boxes but leave document order, tab order, focus on load and what Enter submits where they were. How: * Negative weights on the member fields. Core weights none of its own boxes below zero, so weights settle document and tab order at once. MobileFrontend's watermark, which led the mobile form at weight zero, is re-weighted to stay above the member section. * `autofocus` on the address box. Core adds its own to the username or password box after every handler has run, where nothing can unset it; the first in document order wins. * A nameless submit button, first in the form and clipped out of sight, is what Enter submits through. Named after neither route, it leaves the choice to the filled box, as #39 made possible. Core reads nothing from its own button's name. * The divider is described only where core described a password box, so a single-sign-on-only wiki gets no divider rather than a wrong one. Verified in Chromium on Vector and Minerva: focus lands in the address box on load, also with core's autofocus on the password box; Enter in the address box reaches the code screen posting no button name; Enter in the password box logs in; the hidden field leaves no gap and takes no pointer hits. Considered, omitted: * Hiding the password form: an opt-in setting of its own. * A plain "or" divider for wikis without a password form. * Demoting single sign-on buttons: which route a wiki with several recommends is not this extension's call. * `formnovalidate` on the hidden button: it would loosen the address box's own validation, which core's button did not have either. * Any JavaScript. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d4222f4 commit e53204d

7 files changed

Lines changed: 305 additions & 48 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,11 @@ says so: see [Login routes](#login-routes).
4343

4444
### Login codes
4545

46-
A visitor asks for a login code by entering their email address in the login form's own box for it.
47-
Whether one is sent depends on the allowlist, where each entry belongs to exactly one group. A code
48-
is six digits, valid for ten minutes and usable once, and leads the subject line of the mail that
49-
carries it, so it can be read off a notification. The response to a code request is the same either
50-
way, so it never reveals who is on the list.
46+
A visitor asks for a login code by entering their email address in the box the login form leads
47+
with. Whether one is sent depends on the allowlist, where each entry belongs to exactly one group. A
48+
code is six digits, valid for ten minutes and usable once, and leads the subject line of the mail
49+
that carries it, so it can be read off a notification. The response to a code request is the same
50+
either way, so it never reveals who is on the list.
5151

5252
The screen that asks for the code names the address back, so an address typed wrongly can be seen
5353
rather than waited on. It names it whether or not the allowlist admits it. From there the visitor

‎i18n/en.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"memberaccess-auth-email-label": "Email address",
2727
"memberaccess-auth-email-help": "The address you are a member with.",
2828
"memberaccess-auth-email-placeholder": "Enter your email",
29-
"memberaccess-auth-form-divider": "or",
29+
"memberaccess-auth-form-divider": "or log in with a password",
3030
"memberaccess-auth-email-missing": "Enter your email address to get a login code.",
3131
"memberaccess-auth-email-invalid": "Enter a valid email address.",
3232
"memberaccess-auth-throttled": "Too many login codes have been requested. Try again later.",

‎i18n/qqq.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
"memberaccess-auth-restart-line": "Offer on the code entry screen to go back and enter a different address. Parameters:\n* $1 - a link labelled {{msg-mw|Memberaccess-auth-restart}}, back to the start of the login form",
2626
"memberaccess-auth-email-label": "Label of the email address field.",
2727
"memberaccess-auth-email-help": "Help text for the email address field.",
28-
"memberaccess-auth-email-placeholder": "Placeholder shown in the email address field on the login form. Follows the wording of {{msg-mw|Userlogin-yourname-ph}} and {{msg-mw|Userlogin-yourpassword-ph}} above it.",
29-
"memberaccess-auth-form-divider": "Separator between the password login form and the email login section below it on the login form. Kept to a word or two.\n{{Identical|Or}}",
28+
"memberaccess-auth-email-placeholder": "Placeholder shown in the email address field on the login form. Follows the wording of {{msg-mw|Userlogin-yourname-ph}} and {{msg-mw|Userlogin-yourpassword-ph}}.",
29+
"memberaccess-auth-form-divider": "Separator on the login form, below the email login section and above the password login form it names. Not a sentence: no capital at the start and no full stop, and short enough for one line between the two horizontal rules.",
3030
"memberaccess-auth-email-missing": "Error shown when a login code is asked for without an email address.",
3131
"memberaccess-auth-email-invalid": "Error shown when the entered email address is malformed.",
3232
"memberaccess-auth-throttled": "Error shown when too many login codes have been asked for. The translation must not reveal whether the address is a member.",

‎modules/ext.memberAccess.loginForm.less‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
2-
* Sets the member login route apart from the password form above it.
2+
* Sets the member login route apart from the password form below it.
33
*/
44

5+
@import 'mediawiki.mixins.less';
56
@import 'mediawiki.skin.variables.less';
67

78
.mw-memberaccess-divider {
@@ -90,3 +91,13 @@ button.mw-memberaccess-inline-action {
9091
.mw-memberaccess-collector {
9192
display: none;
9293
}
94+
95+
/**
96+
* The button Enter submits the form through, there to be pressed and not seen. Clipped away rather
97+
* than not displayed, since a browser need not submit through a button it does not render, and
98+
* Safari did not before 16.4. The mixin hides from sight alone; the button's own markup hides it
99+
* from a screen reader as well.
100+
*/
101+
.mw-memberaccess-default-submit {
102+
.mixin-screen-reader-text();
103+
}

‎src/EntryPoints/Auth/LoginCodeRequest.php‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@
2424
* field is mandatory only where every provider asking for it says so. An empty box is refused when
2525
* this button is pressed instead.
2626
*
27-
* Pressing Enter in the box submits the form through its first button, which is the password
28-
* login's, so the request cannot wait for its own button: an address in the box asks for a code as
29-
* the button does. What a username and password beside the address mean is the provider's to say.
27+
* Pressing Enter in the box submits the form through a button that names no route, so the request
28+
* cannot wait for its own button: an address in the box asks for a code as the button does. What a
29+
* username and password beside the address mean is the provider's to say.
3030
* {@see MemberAuthenticationProvider::beginPrimaryAuthentication}
3131
*/
3232
class LoginCodeRequest extends ButtonAuthenticationRequest {

‎src/EntryPoints/LoginFormHandler.php‎

Lines changed: 114 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,25 @@
1818
use Skin;
1919

2020
/**
21-
* Sets the member route apart on the login form. Described fields are laid out in provider order,
22-
* which puts the code button above the button it is an alternative to.
21+
* Leads the login form with the member route and sets it apart from the password form below it.
22+
*
23+
* Core lays the described fields out by weight and numbers the tab order from what that leaves, so
24+
* the weights put here decide both.
2325
*/
2426
class LoginFormHandler implements AuthChangeFormFieldsHook, BeforePageDisplayHook {
2527

26-
/** Core gives the log in button weight 100 and the help link below it 200. */
27-
private const DIVIDER_WEIGHT = 105;
28-
private const EMAIL_WEIGHT = 106;
29-
private const BUTTON_WEIGHT = 107;
28+
/**
29+
* Core leaves the form's own boxes unweighted and gives its buttons 100 and up, and puts only
30+
* the banner carrying an entry error above them, at -100. The member section sits between,
31+
* under the watermark MobileFrontend heads the mobile form with.
32+
*/
33+
private const MOBILE_WATERMARK_WEIGHT = -5;
34+
private const DEFAULT_SUBMIT_WEIGHT = -4;
35+
private const EMAIL_WEIGHT = -3;
36+
private const BUTTON_WEIGHT = -2;
37+
private const DIVIDER_WEIGHT = -1;
38+
39+
/** Below core's button at 100, which on the code screen enters the code. */
3040
private const RESEND_PROMPT_WEIGHT = 110;
3141
private const RESEND_WEIGHT = 111;
3242
private const RESTART_WEIGHT = 120;
@@ -36,8 +46,19 @@ class LoginFormHandler implements AuthChangeFormFieldsHook, BeforePageDisplayHoo
3646
private const FALLBACK_CLASS = 'mw-memberaccess-fallback';
3747
/** The field the press is collected from, which the sentence above draws its own button for. */
3848
private const COLLECTOR_CLASS = 'mw-memberaccess-collector';
49+
private const DEFAULT_SUBMIT_CLASS = 'mw-memberaccess-default-submit';
3950

40-
/** @var array<string, int> */
51+
/** Core's own password box and log in button. {@see \MediaWiki\SpecialPage\LoginSignupSpecialPage::getFieldDefinitions} */
52+
private const PASSWORD_FIELD = 'password';
53+
private const PASSWORD_LOGIN_BUTTON = 'loginattempt';
54+
/** MobileFrontend's watermark, described by its own handler of this hook. */
55+
private const MOBILE_WATERMARK_FIELD = 'mfLogo';
56+
57+
/**
58+
* Below core's log in button at 100 and above its help link at 200.
59+
*
60+
* @var array<string, int>
61+
*/
4162
private const CAPTCHA_WEIGHTS = [
4263
'captchaInfo' => 108,
4364
'captchaWord' => 109
@@ -66,40 +87,117 @@ public function onAuthChangeFormFields( $requests, $fieldInfo, &$formDescriptor,
6687
// An address is what the box takes, so it says so: the keyboard a phone offers for it, and
6788
// what the browser fills it from. AuthenticationRequest has no type for that, describing
6889
// every text box as a string, so it is put on here.
90+
//
91+
// The box the form leads with is the box a visitor arrives in. Core puts autofocus on its
92+
// own username or password box after this hook has run, so the page carries two; a browser
93+
// honours the first in document order, which the weight makes this one.
6994
$formDescriptor[LoginCodeRequest::EMAIL_FIELD] = array_merge(
7095
$this->fieldAt( $formDescriptor, LoginCodeRequest::EMAIL_FIELD ),
7196
[
7297
'type' => 'email',
7398
'placeholder-message' => 'memberaccess-auth-email-placeholder',
7499
'autocomplete' => 'email',
100+
'autofocus' => true,
75101
'cssclass' => 'mw-memberaccess-email',
76102
'weight' => self::EMAIL_WEIGHT
77103
]
78104
);
79105

80-
// Progressive without being primary: the log in button above already is.
106+
// The route the form leads with is the route it recommends.
81107
$formDescriptor[LoginCodeRequest::BUTTON_NAME] = array_merge(
82108
$this->fieldAt( $formDescriptor, LoginCodeRequest::BUTTON_NAME ),
83109
[
84-
'flags' => [ 'progressive' ],
110+
'flags' => [ 'primary', 'progressive' ],
85111
'weight' => self::BUTTON_WEIGHT
86112
]
87113
);
88114

89-
$formDescriptor['memberaccessDivider'] = [
115+
// The divider names the form it introduces, so it is described only where that form is. A
116+
// wiki that offers no password login at all has single sign-on buttons under it instead,
117+
// which the divider would name wrongly.
118+
if ( isset( $formDescriptor[self::PASSWORD_FIELD] ) ) {
119+
$formDescriptor['memberaccessDivider'] = [
120+
'type' => 'info',
121+
'cssclass' => 'mw-memberaccess-divider',
122+
'default' => wfMessage( 'memberaccess-auth-form-divider' )->text(),
123+
'weight' => self::DIVIDER_WEIGHT
124+
];
125+
}
126+
127+
$this->demoteThePasswordLoginButton( $formDescriptor );
128+
$this->letEnterFollowWhatWasFilledIn( $formDescriptor );
129+
$this->keepTheMobileWatermarkAboveTheForm( $formDescriptor );
130+
$this->moveTheCaptchaBelowBothRoutes( $formDescriptor );
131+
}
132+
133+
/**
134+
* A submit button recommends itself unless it is told not to, so core's has to be told, now that
135+
* the form recommends the member route. {@see \MediaWiki\HTMLForm\Field\HTMLSubmitField}
136+
*
137+
* Only where the form describes one, so that no field is invented.
138+
*
139+
* @param array<string, mixed> &$formDescriptor
140+
*/
141+
private function demoteThePasswordLoginButton( array &$formDescriptor ): void {
142+
if ( isset( $formDescriptor[self::PASSWORD_LOGIN_BUTTON] ) ) {
143+
$formDescriptor[self::PASSWORD_LOGIN_BUTTON] = array_merge(
144+
$this->fieldAt( $formDescriptor, self::PASSWORD_LOGIN_BUTTON ),
145+
[ 'flags' => [ 'progressive' ] ]
146+
);
147+
}
148+
}
149+
150+
/**
151+
* Enter in a box submits the form through its first submit button, whichever box that was. Named
152+
* after a route, that button would answer for the visitor; named after neither, it leaves the
153+
* answer with the box they filled in: an address asks for a code, a password logs in with one.
154+
* {@see \ProfessionalWiki\MemberAccess\EntryPoints\Auth\LoginCodeRequest::loadFromSubmission}
155+
*
156+
* Core reads nothing from its own log in button either, so a submission naming no button carries
157+
* everything either route is answered from.
158+
*
159+
* The button is drawn and hidden rather than left out, since the form describes every control it
160+
* has and this one is for the keyboard alone.
161+
*
162+
* @param array<string, mixed> &$formDescriptor
163+
*/
164+
private function letEnterFollowWhatWasFilledIn( array &$formDescriptor ): void {
165+
$formDescriptor['memberaccessDefaultSubmit'] = [
90166
'type' => 'info',
91-
'cssclass' => 'mw-memberaccess-divider',
92-
'default' => wfMessage( 'memberaccess-auth-form-divider' )->text(),
93-
'weight' => self::DIVIDER_WEIGHT
167+
'cssclass' => self::DEFAULT_SUBMIT_CLASS,
168+
'raw' => true,
169+
'default' => Html::element(
170+
'button',
171+
[ 'type' => 'submit', 'tabindex' => '-1', 'aria-hidden' => 'true' ],
172+
''
173+
),
174+
'weight' => self::DEFAULT_SUBMIT_WEIGHT
94175
];
176+
}
95177

96-
$this->moveTheCaptchaBelowBothRoutes( $formDescriptor );
178+
/**
179+
* MobileFrontend heads the mobile form with a watermark it leaves unweighted, which led the form
180+
* while nothing was described below zero. It is the masthead either way, so it is weighted as one.
181+
*
182+
* Only where it is already described, which is where the wiki loads that extension before this
183+
* one: a field this handler cannot see is one it cannot place.
184+
*
185+
* @param array<string, mixed> &$formDescriptor
186+
*/
187+
private function keepTheMobileWatermarkAboveTheForm( array &$formDescriptor ): void {
188+
if ( isset( $formDescriptor[self::MOBILE_WATERMARK_FIELD] ) ) {
189+
$formDescriptor[self::MOBILE_WATERMARK_FIELD] = array_merge(
190+
$this->fieldAt( $formDescriptor, self::MOBILE_WATERMARK_FIELD ),
191+
[ 'weight' => self::MOBILE_WATERMARK_WEIGHT ]
192+
);
193+
}
97194
}
98195

99196
/**
100197
* A captcha refuses the attempt whatever button was pressed: ConfirmEdit counts bad logins per
101-
* client IP, and either route fills that counter. Described it sits inside the password form,
102-
* where it reads as that route's alone; below both it is next to neither.
198+
* client IP, and either route fills that counter. Described it sits among the password form's
199+
* own boxes, where it reads as that route's alone; below the log in button it is under both
200+
* routes rather than inside one.
103201
*
104202
* The fields are ConfirmEdit's, so one under another name is left where it puts itself.
105203
*

0 commit comments

Comments
 (0)