Skip to content

Add password field type — stored secrets that never reach the browser - #91

Open
juliacanzani wants to merge 1 commit into
mainfrom
feat/password-field
Open

Add password field type — stored secrets that never reach the browser#91
juliacanzani wants to merge 1 commit into
mainfrom
feat/password-field

Conversation

@juliacanzani

@juliacanzani juliacanzani commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Built on top of TUI's SecretInput (shipped in @tangible/ui 0.2.14 — dependency bumped from ^0.2.10).

What it's for

$fields->render_field( 'api_key', [
  'type'           => 'password',
  'label'          => __( 'Admin API Key', 'textdomain' ),
  'value_is_set'   => (bool) $stored,          // NEVER the value itself
  'description'    => __( 'Leave empty to keep the saved key.', 'textdomain' ),
  'locked'         => defined( 'MY_API_KEY' ),
  'locked_message' => __( 'Defined in wp-config.php.', 'textdomain' ),
] );

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.

  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 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 leak routes, two different fixes

This is the part that took the most care, because the obvious fix is incomplete:

  • render_field() auto-populates value from fetch_callback when 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.
  • A value passed explicitly is stripped in format_args(), with a warning naming the field. That alone is not enough: render_callback is handed the raw $field as 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.value is not a seed, only a trigger for a warning at mount. It has to be mount-only: Control feeds its own state back down as value, 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.

Worth knowing: isDev() returns false under jest, because the config injects import.meta.env.MODE: 'production'. Anything gated on it is silently untested in this repo and that currently includes three warnings in base/button/Button.tsx. Not touched here but worth reviewing.

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

  • 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. Full suite: 531 passing.
  • PHPUnit cases added for both strips, the arg mapping, and the fetch skip — but I could not run them here. wp-env needs 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.
  • Type errors: 628 before, 628 after — none in the new files. (The repo has a large pre-existing count; tsc is not a gate here.)
  • Built assets rebuilt and committed, per repo convention.

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_set be derived when a field has a fetch_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

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>
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.

1 participant