Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/build/all.unlayered.min.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/build/beaver-builder/index.min.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/build/default/index.min.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/build/elementor/index.min.css

Large diffs are not rendered by default.

56 changes: 28 additions & 28 deletions assets/build/example.min.js

Large diffs are not rendered by default.

56 changes: 28 additions & 28 deletions assets/build/index.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/build/wp/index.min.css

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions assets/src/components/field/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
@import './gradient/index.scss';
@import './list/index.scss';
@import './number/index.scss';
@import './password/index.scss';
@import './radio/index.scss';
@import './select/index.scss';
@import './simple-dimension/index.scss';
Expand Down
2 changes: 2 additions & 0 deletions assets/src/components/field/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import Gallery from './gallery/Gallery'
import InputHidden from './hidden/InputHidden'
import List from './list/List'
import Number from './number/Number'
import Password from './password/Password'
import Radio from './radio/'
import Select from './select/'
import SimpleDimension from './simple-dimension/SimpleDimension'
Expand Down Expand Up @@ -46,6 +47,7 @@ export {
InputHidden,
List,
Number,
Password,
Radio,
Select,
SimpleDimension,
Expand Down
76 changes: 76 additions & 0 deletions assets/src/components/field/password/Password.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from '@storybook/react-vite'

import Password from './Password'

const meta = {
title: 'Field/Password',
component: Password,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Stored secrets (API keys, tokens). The value never reaches the browser — ' +
'the server sends `isSet` (a boolean) and nothing else, so an untouched field ' +
'submits empty and the save handler reads empty as "keep the stored value".'
}
}
},
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
description: { control: 'text' },
placeholder: { control: 'text' },
isSet: { control: 'boolean' },
locked: { control: 'boolean' },
lockedMessage: { control: 'text' }
},
args: {
label: 'API key',
name: 'api_key'
}
} satisfies Meta<typeof Password>

export default meta

type Story = StoryObj<typeof meta>

export const Empty: Story = {
args: {
description: 'No key saved yet.'
}
}

/** A value exists server-side. The field still holds nothing. */
export const ValueSaved: Story = {
args: {
isSet: true,
placeholder: undefined,
description: 'Leave empty to keep the saved key.'
}
}

/** Defined outside this screen — read-only, no reveal toggle, still focusable. */
export const Locked: Story = {
args: {
isSet: true,
locked: true,
lockedMessage: 'Defined in wp-config.php.'
}
}

/** Every rendered and announced string is overridable. */
export const Translated: Story = {
args: {
label: 'Clé API',
isSet: true,
labels: {
reveal: 'Afficher la valeur',
hide: 'Masquer la valeur',
shown: 'Valeur affichée.',
hidden: 'Valeur masquée.',
valueSet: '•••••••• Enregistrée',
valueSetDescription: 'Une valeur est enregistrée et masquée. La saisie la remplace.'
}
}
}
139 changes: 139 additions & 0 deletions assets/src/components/field/password/Password.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { useRef, useState, useEffect, forwardRef } from 'react'

import { Field, SecretInput } from '@tangible/ui'
import type { SizeStandard, SecretInputLabels } from '@tangible/ui'

/**
* Password field — stored secrets (API keys, tokens) that are never sent to
* the browser.
*
* The field exists to enforce a server-side contract, not just to mask
* characters. `type: 'text'` with `type="password"` on the input would look
* identical and still put the stored key in the page source.
*
* 1. The server sends `value_is_set` — a boolean saying a value exists — and
* never the value itself. This field therefore has no `value` in its config;
* one passed anyway is dropped (with a dev warning), because honouring it
* would reintroduce exactly the leak the field type exists to prevent.
* 2. An untouched field submits empty, and the save handler reads empty as
* "keep the stored value". Typing replaces; clearing is a separate,
* explicit action the consumer provides.
* 3. `locked` marks a value defined outside this screen (a wp-config constant):
* read-only, no reveal toggle, still focusable and readable by AT.
*
* @see https://github.com/TangibleInc/tangible-ui — SecretInput
*/

export interface FieldsPasswordProps {
/** A value exists server-side. Renders the set-state while the input is empty. */
isSet?: boolean
/** Managed outside this screen — read-only, lock icon, no reveal toggle. */
locked?: boolean
/** Why the field is locked, e.g. "Defined in wp-config.php." */
lockedMessage?: string
/** Overridable strings for i18n — see SecretInput's `labels`. */
labels?: SecretInputLabels
/** Reveal state, for consumers enforcing a re-mask policy. */
revealed?: boolean
defaultRevealed?: boolean
onRevealChange?: (revealed: boolean) => void

onChange?: (value: string) => void
name?: string
placeholder?: string
readOnly?: boolean
isDisabled?: boolean
isRequired?: boolean
isInvalid?: boolean
error?: boolean
label?: string
labelVisuallyHidden?: boolean
description?: string
descriptionVisuallyHidden?: boolean
className?: string
inputClassName?: string
size?: SizeStandard

/**
* Never rendered. Declared only so a caller passing one gets the warning
* rather than a silently leaking field.
*/
value?: string
}

const PasswordField = forwardRef<HTMLInputElement, FieldsPasswordProps>((props, ref) => {

/**
* Always starts empty, regardless of what was passed. `props.value` is not a
* seed here — the value the user types is the only value this field ever has.
*/
const [value, setValue] = useState('')
const mountedRef = useRef(false)

/**
* Mount only. Control feeds its own state back down as `value`, so anything
* the user types would otherwise trip this on the next render.
*
* Deliberately not gated on isDev(): a config that would leak a stored secret
* is worth saying out loud wherever it happens, and reaching this in
* production means the PHP-side strip was bypassed entirely.
*/
useEffect(() => {
if (typeof props.value === 'string' && props.value !== '') {
console.warn(
'[Tangible Fields] A `password` field was given a `value`. It has been dropped: ' +
'stored secrets must not reach the browser. Send `value_is_set => (bool) $stored` ' +
'instead, and treat an empty submitted value as "keep the stored value".'
)
}
}, [])

useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true
return
}
if (props.onChange) props.onChange(value)
}, [value])

return (
<div className="tf-password">
<Field
className={props.className}
required={Boolean(props.isRequired)}
disabled={Boolean(props.isDisabled)}
error={Boolean(props.isInvalid || props.error)}
>
{props.label &&
<Field.Label hidden={Boolean(props.labelVisuallyHidden)}>
{props.label}
</Field.Label>}
<Field.Control>
<SecretInput
ref={ref}
inputClassName={props.inputClassName}
size={props.size}
isSet={Boolean(props.isSet)}
locked={Boolean(props.locked)}
lockedMessage={props.lockedMessage}
labels={props.labels}
revealed={props.revealed}
defaultRevealed={props.defaultRevealed}
onRevealChange={props.onRevealChange}
placeholder={props.placeholder}
readOnly={props.readOnly}
value={value}
onChange={event => setValue(event.target.value)}
name={props.name ?? ''}
/>
</Field.Control>
{props.description &&
<Field.HelperText className={props.descriptionVisuallyHidden ? 'tui-visually-hidden' : undefined}>
{props.description}
</Field.HelperText>}
</Field>
</div>
)
})

export default PasswordField
5 changes: 5 additions & 0 deletions assets/src/components/field/password/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.tf-password {
display: flex;
flex-direction: column;
box-sizing: content-box;
}
2 changes: 2 additions & 0 deletions assets/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
InputHidden,
List,
Number,
Password,
Radio,
Select,
SimpleDimension,
Expand Down Expand Up @@ -85,6 +86,7 @@ const getTypes = () => {
'list' : List,
'hidden' : InputHidden,
'number' : Number,
'password' : Password,
'repeater' : Repeater,
'radio' : Radio,
'select' : Select,
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Unreleased

- **New field type: `password`** — stored secrets (API keys, tokens) that never reach the browser. The server sends `value_is_set` (a boolean) instead of the value, an untouched field submits empty so the save handler can read empty as "keep the stored value", and `locked` marks a value defined outside the screen (a `wp-config.php` constant) as read-only with an explanation. A `value` passed to the field is dropped with a warning, and `render_field()` does not call the fetch callback for it at all — the field type exists to stop secrets being echoed into the page, so honouring one would defeat it. Renders TUI's `SecretInput`; every string it renders or exposes to assistive technology is overridable via `labels` for i18n.
- **BREAKING — Text: prefix/suffix are no longer part of the saved value.** A text field with a `prefix` and/or `suffix` now saves only the user's input; the affixes are purely visual. Previously the saved value included the prefix and suffix. Rationale: affixes are presentation, and baking them into stored data meant changing display copy changed the data. Consumers that need the composed value should compose it from the saved value at read/save time. Previously-stored composed values are tolerated on read: affixes are stripped before input-mask validation.
- Fields: Migrate Text, Textarea, Button, Switch, Radio, Checkbox and Notice to @tangible/ui components (legacy components remain available for comparison in Storybook)
- Themes: WP - Sync with LMS admin theme, adopt WordPress 7.0 admin CSS variables with static fallbacks
Expand Down
9 changes: 9 additions & 0 deletions example/register/sections.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,15 @@
'number_max' => []
]
],
'password' => [
'title' => 'Password',
'path' => 'fields/password',
'fields'=> [
'password' => [],
'password-locked' => [],
'password-labels' => [],
]
],
'radio' => [
'title' => 'Radio',
'path' => 'fields/radio',
Expand Down
75 changes: 75 additions & 0 deletions example/templates/fields/password.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<h4>Example</h4>

<p>
A password field never receives the stored value. The server sends
<code>value_is_set</code> — a boolean — and nothing else, so an untouched
field submits empty and the save handler reads empty as "keep the stored
value". Typing replaces it.
</p>

<div class="tangible-settings-row">
<?= $fields->render_field('password', [
'label' => 'API key',
'type' => 'password',
'value_is_set' => (bool) $fields->fetch_value('password'),
'placeholder' => 'Paste a new key to replace',
'description' => 'Leave empty to keep the saved key.'
]) ?>
</div>

<div class="tangible-settings-row">
<?php submit_button() ?>
</div>

<h4>Value</h4>

<?php tangible\see(
$fields->fetch_value('password') ? '(a value is saved)' : '(empty)'
); ?>

<h4>Example with locked</h4>

<p>
For a value defined outside this screen — a <code>wp-config.php</code>
constant, an environment variable. Read-only rather than disabled, so the
field stays in the tab order and screen reader users still learn it exists.
</p>

<div class="tangible-settings-row">
<?= $fields->render_field('password-locked', [
'label' => 'API key',
'type' => 'password',
'value_is_set' => true,
'locked' => true,
'locked_message' => 'Defined in wp-config.php.'
]) ?>
</div>

<h4>Example with translated labels</h4>

<p>
Every string the field renders or exposes to assistive technology can be
replaced. The set-state placeholder is one whole string, bullets included, so
a translation controls the bullet run and the word order.
</p>

<div class="tangible-settings-row">
<?= $fields->render_field('password-labels', [
'label' => 'Clé API',
'type' => 'password',
'value_is_set' => true,
'labels' => [
'reveal' => 'Afficher la valeur',
'hide' => 'Masquer la valeur',
'shown' => 'Valeur affichée.',
'hidden' => 'Valeur masquée.',
'valueSet' => '•••••••• Enregistrée',
'valueSetDescription' => 'Une valeur est enregistrée et masquée. La saisie la remplace.',
'locked' => 'Cette valeur est gérée en dehors de cet écran.'
]
]) ?>
</div>

<div class="tangible-settings-row">
<?php submit_button() ?>
</div>
25 changes: 25 additions & 0 deletions fields/format.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@
$args = $fields->format_value($args, 'read_only', 'readOnly');
break;

/**
* Stored secrets (API keys, tokens). The value never reaches the browser —
* the server sends `value_is_set` (a boolean) and nothing else, so an
* untouched field submits empty and the save handler reads empty as "keep
* the stored value".
*
* A `value` passed here is stripped rather than honoured: the field type
* exists to stop secrets being echoed into the page, and silently rendering
* one would reintroduce that leak with nicer styling.
*/
case 'password':
if( isset($args['value']) ) {
unset($args['value']);
trigger_error(
"Field {$name} is a password field and was given a value, which has been dropped. "
. 'Stored secrets must not reach the browser — pass '
. '"value_is_set" => (bool) $stored instead.',
E_USER_WARNING
);
}
$args = $fields->format_value($args, 'value_is_set', 'isSet');
$args = $fields->format_value($args, 'locked_message', 'lockedMessage');
$args = $fields->format_value($args, 'read_only', 'readOnly');
break;

case 'simple_dimension':
$args['type'] = 'simple-dimension';
break;
Expand Down
Loading
Loading