Skip to content

fix(otp): make the code fillable by password managers and submittable in a plain form - #200

Open
oneleggedswede wants to merge 4 commits into
sheafui:masterfrom
oneleggedswede:fix/otp-autofill-and-form-submission
Open

fix(otp): make the code fillable by password managers and submittable in a plain form#200
oneleggedswede wants to merge 4 commits into
sheafui:masterfrom
oneleggedswede:fix/otp-autofill-and-form-submission

Conversation

@oneleggedswede

Copy link
Copy Markdown

Summary

x-ui.otp cannot be filled by a password manager, and cannot be submitted by a
plain <form>. Both show up on Laravel's Livewire starter kit two-factor
challenge 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 uses
and which handles all of these.

1. A password manager fills one digit and then appears to hang

A password manager sets .value and dispatches an input event. It does not
dispatch paste, so handlePaste() — the only code here that spreads a code
across the boxes — never runs. Three things then combine:

All six boxes claim the code. input.blade.php renders
autocomplete="one-time-code" on every box, so there is no single field to aim
at. Flux assigns it to the first input and off to the rest.

A multi-character value is truncated. The whole code arrives at
handleInput(), which keeps one character of it:

// Always keep last typed character (avoid multi-char paste in one box)
if (value.length > 1) {
    value = value.slice(-1);
    el.value = value;
}

maxlength="1" does not prevent this — it constrains typing, not a programmatic
.value assignment — so el.value really is "123456" here.

Every box ahead of the caret is disabled. With an empty field only box 0 is
enabled, 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 a requestAnimationFrame, the _state watcher runs
updateInputAvailability() and focusAndSelect(next), which un-disables and then
focuses and selects in another frame, and x-on:focus runs
requestAnimationFrame(() => $el.select()) on top. The component keeps taking
focus back frame after frame while the extension is trying to drive the field.

Fixed by assigning autocomplete per index in setupInputs(), moving the
distribution logic out of handlePaste() into a shared fillFrom() that
handleInput() also calls, and holding the caret with tabIndex instead of
disabled. clear() and handleClick() stop reading the flag back —
handleClick() clamps to the boxes in play, which is what disabled was standing
in for.

2. Nothing is submitted in a plain form

name is consumed by @props and read back by otp.input through @aware, so
it lands on every digit box:

{{-- input.blade.php --}}
@aware(['type' => 'text','name'=> null])
...
{{ $attributes->merge(['name' => $name, 'type' => $type]) }}

<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 the
starter kit posts its two-factor challenge as an ordinary form to
two-factor.login.store with the digits in Alpine via x-model, and there the
code never reaches the server at all.

Fixed by dropping name from the boxes and rendering a single hidden input
tracking _state, the way Flux's <ui-otp> does. Nothing is rendered when
wire:model is present, so the Livewire path is untouched.

3. required on a hidden OTP blocks its whole form

Every box is 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 —
"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 required a prop defaulting to true, so nothing changes
unless 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:

whole code into the field claiming one-time-code one character per box
before code=6 code=1
after code=123456 code=123456

No change to anything else (box contents after each interaction):

before after
typing 123456 123456 123456
backspace 12345 12345
paste 987654 987654 987654
click past the caret focus lost to <body> focus clamped to box 0
otp-clear event cleared cleared, focus back on box 0

Under wire:model the rendered output is unchanged apart from the box name
attributes, 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 with
Alpine and its focus plugin, inside <form id="f" x-data="{ code: '' }">, once
per variant as page-before.html / page-after.html, then:

import puppeteer from 'puppeteer-core';

const dir = process.env.SCRATCH;
const browser = await puppeteer.launch({
    executablePath: '/usr/bin/google-chrome',
    args: ['--no-sandbox', '--allow-file-access-from-files'],
});

const results = {};

for (const variant of ['before', 'after']) {
    const page = await browser.newPage();
    await page.goto(`file://${dir}/page-${variant}.html`);
    await page.waitForFunction(() => window.Alpine && document.querySelectorAll('[data-slot=otp-input]').length === 6);
    await new Promise(r => setTimeout(r, 300));

    const snapshot = async () => page.evaluate(() => {
        const boxes = [...document.querySelectorAll('[data-slot=otp-input]')];
        return {
            autocomplete: boxes.map(b => b.getAttribute('autocomplete')),
            disabled: boxes.map(b => b.disabled),
            values: boxes.map(b => b.value),
            posted: new FormData(document.getElementById('f')).get('code'),
        };
    });

    const initial = await snapshot();

    // How 1Password fills: find the field claiming the code, set .value, fire `input`.
    const filled = await page.evaluate(() => {
        const target = document.querySelector('[autocomplete="one-time-code"]')
            ?? document.querySelector('[data-slot=otp-input]');
        target.focus();
        target.value = '123456';
        target.dispatchEvent(new Event('input', { bubbles: true }));
        return target.getAttribute('data-order');
    });

    await new Promise(r => setTimeout(r, 400));
    const afterWholeCode = await snapshot();

    // The other strategy: one character per box, in a tight synchronous loop.
    await page.reload();
    await page.waitForFunction(() => window.Alpine && document.querySelectorAll('[data-slot=otp-input]').length === 6);
    await new Promise(r => setTimeout(r, 300));

    await page.evaluate(() => {
        const boxes = [...document.querySelectorAll('[data-slot=otp-input]')];
        '123456'.split('').forEach((char, i) => {
            const box = boxes[i];
            if (box.disabled) return;          // an extension cannot write to it
            box.focus();
            box.value = char;
            box.dispatchEvent(new Event('input', { bubbles: true }));
        });
    });

    await new Promise(r => setTimeout(r, 400));
    const afterPerBox = await snapshot();

    results[variant] = { initial, filledInto: filled, afterWholeCode, afterPerBox };
    await page.close();
}

await browser.close();
console.log(JSON.stringify(results, null, 2));

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 required
stay unconditional.

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`.
@CharrafiMed

CharrafiMed commented Aug 18, 2026

Copy link
Copy Markdown
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 ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants