Add password field type — stored secrets that never reach the browser - #91
Open
juliacanzani wants to merge 1 commit into
Open
Add password field type — stored secrets that never reach the browser#91juliacanzani wants to merge 1 commit into
password field type — stored secrets that never reach the browser#91juliacanzani wants to merge 1 commit into
Conversation
Implements Part 2 of the SecretInput spec, on top of TUI's SecretInput (shipped in @tangible/ui 0.2.14). The field exists to enforce a server-side contract, not just to mask characters — `type: 'text'` with a password input would look identical and still put the stored key in the page source: 1. The server sends `value_is_set` (a boolean) and never the value itself. 2. An untouched field submits empty, so the save handler reads empty as "keep the stored value". Typing replaces; clearing stays a separate, explicit action the consumer provides. 3. `locked` marks a value defined outside the screen (a wp-config constant) as read-only with an explanation — readOnly rather than disabled, so the field stays in the tab order and AT users still learn it exists. Two server-side routes could leak a secret, and they need different fixes: - `render_field()` auto-populates `value` from `fetch_callback` when no value is set, so a registered password field would have had its secret fetched and serialised without the caller ever passing one. It no longer fetches for this type at all. - A value passed explicitly is stripped in `format_args()` with a warning. That alone is not enough: `render_callback` receives the raw `$field` as well as the formatted args, so the raw copy is cleaned too. Removing either strip fails a test — verified by mutation, not assumed. React side: `props.value` is never a seed, only a trigger for a warning at mount. The warning is deliberately not gated on `isDev()` — a config that would leak a secret is worth saying out loud anywhere, and reaching it in production means the PHP strips were bypassed entirely. (Worth knowing separately: `isDev()` returns false under jest, since the config injects `import.meta.env.MODE: production` — anything gated on it is silently untested here.) Every string the field renders or exposes to AT is overridable via `labels`, including the set-state placeholder, which is one whole string so a translation controls the bullet run and the word order. Testing: 9 jest tests, mutation-checked three ways (seeding state from props.value, dropping the labels passthrough, and mapping locked to disabled each fail a test). PHPUnit cases added for both strips and the fetch skip, but they could not be run here — wp-env needs network access to resolve the WordPress version and the fields containers are not up. The behaviour they assert was verified instead by bootstrapping the framework standalone (19 checks, all passing, each mutation caught); PHP files lint clean. Deliberately not included: the spec's optional `input_type` pass-through on `text`. It is unrelated to the security contract and would widen the API on a PR about narrowing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Built on top of TUI's
SecretInput(shipped in@tangible/ui0.2.14 — dependency bumped from^0.2.10).What it's for
The field enforces a server-side contract rather than just character masking .
type: 'text'with a password input would look identical but still put the stored key in the page source.value_is_set(a boolean) and never the value itself.lockedmarks a value defined outside the screen as read-only with an explanation —readOnlyrather thandisabled, so the field stays in the tab order and AT users still learn it exists.Two leak routes, two different fixes
This is the part that took the most care, because the obvious fix is incomplete:
render_field()auto-populatesvaluefromfetch_callbackwhen no value is set. A registered password field would have had its secret fetched and serialised without the caller ever passing one. It no longer fetches for this type at all.format_args(), with a warning naming the field. That alone is not enough:render_callbackis handed the raw$fieldas well as the formatted args, so the raw copy is cleaned too.Both were confirmed necessary by mutation rather than assumed. Removing either one fails a test. Dropping the raw-field strip in particular leaves the enqueued payload clean while the secret still reaches a custom render callback, which is exactly the kind of half-fix that reads as done.
React side
props.valueis not a seed, only a trigger for a warning at mount. It has to be mount-only:Controlfeeds its own state back down asvalue, so anything the user types would otherwise trip it on the next render.The warning is not gated on
isDev()a config that would leak a secret is worth saying out loud anywhere, and reaching it in production means the PHP strips were bypassed entirely.i18n
Every string the field renders or exposes to assistive technology is overridable via
labels. The set-state placeholder is one whole string ('•••••••• Saved'), bullets included, so a translation controls the bullet run and the word order rather than having them concatenated in code.Testing
props.value, dropping thelabelspassthrough, and mappinglockedtodisabledeach fail a test. Full suite: 531 passing.wp-envneeds network access to resolve the WordPress version and the fields containers are not up. Rather than ship untested assertions, the behaviour they assert was verified by bootstrapping the framework standalone (19 checks, all passing, every mutation caught) and all touched PHP files lint clean. The PHPUnit run is the one gate on this PR that still needs a real environment.tscis not a gate here.)Also included
Storybook stories (Empty / ValueSaved / Locked / Translated), an example template with the three variants, and a changelog entry.
Follow-up question
Should
value_is_setbe derived when a field has afetch_callback? The framework could set it from(bool) fetch_value()without the secret ever leaving PHP, which removes a step consumers can forget — forgetting it means the field silently claims nothing is saved. Left explicit here because that is what the spec specifies, but it's a real ergonomic gap.🤖 Generated with Claude Code