fix(otp): make the code fillable by password managers and submittable in a plain form - #200
Open
oneleggedswede wants to merge 4 commits into
Open
Conversation
A password manager sets `.value` and dispatches `input`; it never dispatches `paste`. Three things then stop it filling the code: - all six inputs claim `autocomplete="one-time-code"`, so there is no single field to aim at; - `handleInput()` keeps only the last character of a multi-character value, and `handlePaste()` — the only code that spreads a code across the boxes — never runs, so a filled `123456` becomes `6`; - every box ahead of the caret is `disabled`, and a disabled input cannot be written to at all, so a fill that goes box by box stops after the first. `setupInputs()` now assigns `one-time-code` to the first box and `off` to the rest, the distribution logic moves out of `handlePaste()` into a shared `fillFrom()` that `handleInput()` also calls, and the caret is held with `tabIndex` instead of `disabled`. `clear()` and `handleClick()` stop reading the flag back; `handleClick()` clamps to the boxes in play instead, which is what `disabled` was standing in for.
The component renders nothing carrying the joined value. `name` is consumed by `@props` and read back by `otp.input` through `@aware`, so it lands on every digit box: `<x-ui.otp name="code" length="6" />` renders six `<input name="code">`, the browser posts all six, and the server keeps one of them. That is invisible under `wire:model`, where Livewire is the transport. But Laravel's Livewire starter kit posts its two-factor challenge as an ordinary form to `two-factor.login.store` with the digits held in Alpine via `x-model`, and there the code never reaches the server at all — every login is rejected. The boxes now carry no name of their own, and the component renders a single hidden input tracking `_state` under `name`, the way `<flux:otp>` does. Nothing is rendered when `wire:model` is present, so the Livewire path is untouched.
The inputs are unconditionally `required`. A `required` control that is `display: none` is still validated, so an OTP kept in the same `<form>` as an alternative field and swapped with `x-show` makes the browser refuse the submit outright — "An invalid form control with name='' is not focusable" — and the other field can never be sent. That is exactly the shape of the starter kit's two-factor challenge, where the recovery-code path becomes unusable. `required` becomes a prop defaulting to `true`, so nothing changes unless a page passes `:required="false"`.
The click-to-focus section described disabled inputs and the `::after` overlay that worked around them; neither exists now. Adds `name` and `required` to the props table, a plain-form example, and a note about `required` on an OTP hidden behind `x-show`.
Member
|
thank you @oneleggedswede, it seems like an AI agent PR; I don't mind that, but going to force a deferral until I have time to review it well. glad to hear that you're working on that tool, but it doesn't explicitly have any docs to convert from flux to sheafUI ? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
x-ui.otpcannot be filled by a password manager, and cannot be submitted by aplain
<form>. Both show up on Laravel's Livewire starter kit two-factorchallenge page, where between them they make two-factor login impossible: the
code either never gets typed or never reaches the server.
Comparisons below are against
<flux:otp>, which the same starter kit page usesand which handles all of these.
1. A password manager fills one digit and then appears to hang
A password manager sets
.valueand dispatches aninputevent. It does notdispatch
paste, sohandlePaste()— the only code here that spreads a codeacross the boxes — never runs. Three things then combine:
All six boxes claim the code.
input.blade.phprendersautocomplete="one-time-code"on every box, so there is no single field to aimat. Flux assigns it to the first input and
offto the rest.A multi-character value is truncated. The whole code arrives at
handleInput(), which keeps one character of it:maxlength="1"does not prevent this — it constrains typing, not a programmatic.valueassignment — soel.valuereally is"123456"here.Every box ahead of the caret is
disabled. With an empty field only box 0 isenabled, and a disabled input cannot be written to by an extension at all, so a
fill that goes box by box stops after the first. Flux never disables individual
boxes for this; it uses
tabindex.What follows looks like the keyboard locking up:
handleInput()schedules$updateStateFromInputs()in arequestAnimationFrame, the_statewatcher runsupdateInputAvailability()andfocusAndSelect(next), which un-disables and thenfocuses and selects in another frame, and
x-on:focusrunsrequestAnimationFrame(() => $el.select())on top. The component keeps takingfocus back frame after frame while the extension is trying to drive the field.
Fixed by assigning
autocompleteper index insetupInputs(), moving thedistribution logic out of
handlePaste()into a sharedfillFrom()thathandleInput()also calls, and holding the caret withtabIndexinstead ofdisabled.clear()andhandleClick()stop reading the flag back —handleClick()clamps to the boxes in play, which is whatdisabledwas standingin for.
2. Nothing is submitted in a plain form
nameis consumed by@propsand read back byotp.inputthrough@aware, soit lands on every digit box:
<x-ui.otp name="code" length="6" />therefore renders six<input name="code">.The browser posts all six and the server keeps one — PHP's parser takes the last.
There is no field carrying the joined value.
This is invisible under
wire:model, where Livewire is the transport. But thestarter kit posts its two-factor challenge as an ordinary form to
two-factor.login.storewith the digits in Alpine viax-model, and there thecode never reaches the server at all.
Fixed by dropping
namefrom the boxes and rendering a single hidden inputtracking
_state, the way Flux's<ui-otp>does. Nothing is rendered whenwire:modelis present, so the Livewire path is untouched.3.
requiredon a hidden OTP blocks its whole formEvery box is unconditionally
required. Arequiredcontrol that isdisplay: noneis still validated, so an OTP kept in the same<form>as analternative field and swapped with
x-showmakes the browser refuse the submit —"An invalid form control with name='' is not focusable". On the starter kit page
that is the recovery-code path, which cannot be submitted at all.
Fixed by making
requireda prop defaulting totrue, so nothing changesunless a page passes
:required="false".Verification
There is no test harness in this repository, so I verified this in headless
Chrome against the component rendered before and after the change. The script is
below — it drives the two ways a password manager fills a field, and re-checks
typing, paste, backspace, click and
clear()for regressions.Filling
123456, reading back what the form would post:one-time-codecode=6code=1code=123456code=123456No change to anything else (box contents after each interaction):
1234561234561234561234512345987654987654987654<body>otp-cleareventUnder
wire:modelthe rendered output is unchanged apart from the boxnameattributes, and no hidden input is added.
verify.mjs — reproduction script (puppeteer-core, system Chrome)
Render
<x-ui.otp name="code" x-model="code" length="6" />into a page withAlpine and its focus plugin, inside
<form id="f" x-data="{ code: '' }">, onceper variant as
page-before.html/page-after.html, then:Notes
I maintain a migration tool that
moves the Livewire starter kit from Flux to Sheaf, which is how I ran into all
three. It currently patches the installed component to work around them; if this
lands, that workaround goes away.
Happy to split this into separate PRs, or to drop 3 if you would rather
requiredstay unconditional.