open-webui/src/lib/components/common/SensitiveInput.svelte
Classic298 15f724b0f2
fix: give SensitiveInput a unique default id (WCAG 1.3.1, 4.1.2) (#27488)
On latest `dev`, `SensitiveInput` defaults to `export let id = 'password-input'`. The id is used both for the input itself and as the `for` target of the screen reader label rendered just above it.

There are 80 `<SensitiveInput>` usages in `src/` and only 4 pass an explicit id, so the remaining 76 all render `id="password-input"` together with `<label for="password-input">`. These collide on the same page in completely ordinary configurations: `admin/Settings/Audio.svelte` renders 4 at once with `STT_ENGINE === 'openai'` and 4 more with `TTS_ENGINE === 'openai'`, `admin/Settings/Documents.svelte` has 11, and `admin/Settings/WebSearch.svelte` has 33.

`for` resolves to the first matching element, so every label after the first points at the wrong input. In practice a screen reader user tabbing to the OpenAI TTS API key field hears the label belonging to the STT key field from a different section, and every one of those fields announces the same name. Browser password managers and any `getElementById` lookup collapse onto the first element the same way.

Breaks WCAG 1.3.1 Info and Relationships (Level A), because the programmatic label/field relationship is wrong, and 4.1.2 Name, Role, Value (Level A), because the fields do not expose their correct accessible name.

Fix: default the id to a per instance unique value. A Svelte prop default is evaluated per component instance, so each `SensitiveInput` gets its own stable id, and the 4 call sites that pass an explicit id are unaffected. `uuid` is already a direct dependency and `import { v4 as uuidv4 } from 'uuid'` is the existing pattern in the codebase, including `common/Collapsible.svelte`, which already generates a DOM id this way.

Note for self hosted setups: a `#password-input` selector in `static/custom.css` would stop matching. That selector already matched up to 8 elements at once on the Audio settings page, so it was never a reliable hook.

Severity: Serious. Every API key field in Admin Settings is mislabelled for assistive technology.

### Contributor License Agreement

<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.

Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->

- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.

> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
2026-07-26 17:37:45 -04:00

99 lines
3.3 KiB
Svelte

<script lang="ts">
const i18n = getContext('i18n');
import { getContext } from 'svelte';
import { v4 as uuidv4 } from 'uuid';
export let id = `password-input-${uuidv4()}`;
export let value: string = '';
export let placeholder = '';
export let type = 'text';
export let required = true;
export let readOnly = false;
export let variant: 'plain' | 'settings' = 'plain';
export let outerClassName = 'flex flex-1';
export let inputClassName = '';
export let showButtonClassName = '';
export let screenReader = true;
export let autocomplete = 'off';
export let name: string | undefined = undefined;
let className = '';
export { className as class };
let show = false;
$: outerClass =
variant === 'settings'
? `${outerClassName} h-7 items-center rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 transition-colors focus-within:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:focus-within:border-blue-500`
: outerClassName;
$: inputBaseClass =
variant === 'settings'
? 'min-w-0 flex-1 bg-transparent text-xs text-gray-700 outline-hidden placeholder:text-gray-300 disabled:text-gray-500 dark:text-gray-300 dark:placeholder:text-gray-700'
: 'w-full bg-transparent py-0.5 text-sm outline-hidden';
$: resolvedInputClass = `${inputBaseClass} ${variant === 'plain' ? className : ''} ${inputClassName} ${
show ? '' : 'password'
}`;
$: buttonClass =
variant === 'settings'
? `ml-1.5 bg-transparent text-gray-400 transition hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 ${showButtonClassName}`
: `pl-1.5 bg-transparent transition ${showButtonClassName}`;
</script>
<div class={outerClass}>
{#if screenReader}
<label class="sr-only" for={id}>{placeholder || $i18n.t('Password')}</label>
{/if}
<input
{id}
class={resolvedInputClass}
{placeholder}
type={type === 'password' && !show ? 'password' : 'text'}
bind:value
{name}
required={required && !readOnly}
disabled={readOnly}
{autocomplete}
/>
<button
class={buttonClass}
type="button"
aria-pressed={show}
aria-label={$i18n.t('Make password visible in the user interface')}
on:click={(e) => {
e.preventDefault();
show = !show;
}}
>
{#if show}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
class="size-4"
>
<path
fill-rule="evenodd"
d="M3.28 2.22a.75.75 0 0 0-1.06 1.06l10.5 10.5a.75.75 0 1 0 1.06-1.06l-1.322-1.323a7.012 7.012 0 0 0 2.16-3.11.87.87 0 0 0 0-.567A7.003 7.003 0 0 0 4.82 3.76l-1.54-1.54Zm3.196 3.195 1.135 1.136A1.502 1.502 0 0 1 9.45 8.389l1.136 1.135a3 3 0 0 0-4.109-4.109Z"
clip-rule="evenodd"
/>
<path
d="m7.812 10.994 1.816 1.816A7.003 7.003 0 0 1 1.38 8.28a.87.87 0 0 1 0-.566 6.985 6.985 0 0 1 1.113-2.039l2.513 2.513a3 3 0 0 0 2.806 2.806Z"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
aria-hidden="true"
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path
fill-rule="evenodd"
d="M1.38 8.28a.87.87 0 0 1 0-.566 7.003 7.003 0 0 1 13.238.006.87.87 0 0 1 0 .566A7.003 7.003 0 0 1 1.379 8.28ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</div>