When ENABLE_OAUTH_PERSISTENT_CONFIG is off (the default), oauth.* config is
never persisted and is read from environment variables, but the admin panel
still let admins edit the OAuth/OIDC fields and silently dropped every save on
restart, which kept confusing users who missed the docs warning
(open-webui/open-webui#28247).
The OAuth/OIDC section is now read-only in that case: the admin oauth config
endpoint reports the flag and the UI wraps the section in a disabled fieldset,
slightly dimmed with every control inert but all values still visible, plus a
note naming the env var. Saving skips the OAuth POST since nothing can change.
With the flag enabled the section behaves exactly as before.
Known limits: the guard is UI-side only (the POST endpoint keeps accepting
writes, unchanged), and disabled fields mean values cannot be selected and the
masked client secret cannot be revealed while read-only. Switch.svelte gains a
disabled:cursor-not-allowed style that applies to any disabled switch app-wide.
Co-authored-by: Tim Baek <tim@openwebui.com>
Adding a model ID that was already on the list in the connection settings modal simply appended it again, so the same model could sit in the whitelist any number of times. The arena model modal had the same flaw, its dropdown kept offering models that were already selected.
The connection modal now rejects a duplicate with a toast and trims the input first; surrounding whitespace renders invisibly in the list, so an untrimmed ID would slip past the duplicate check and still show up as a visually identical row. The arena modal instead filters already-added models out of the dropdown, matching the existing model selector in the admin settings, so a duplicate can no longer be picked at all. Both modals also drop duplicates when loading a stored list, so configs that already contain them are cleaned on their next save.
Until such a config is re-saved, one residual effect of old data remains: a duplicated ID in an arena model's stored list keeps double weight in the random model draw. New duplicates can no longer be created through the UI.
Fixes#28249
* chore: drop redundant background repaints so surfaces inherit their parent
Four spots repaint the exact color their parent surface already provides
(bg-white / dark:bg-gray-900 rows inside same-colored pages and modals,
and the selectClass dark repaint inside the connection modals — the
sibling input const is already fully transparent). Visually identical in
stock light and dark; removing them lets instance theming show through
instead of leaving opaque boxes:
- .tiptap tr (app.css) — table rows in notes/editors
- Edit User Group Users tab body rows (common Modal surface)
- AddToolServerModal + AddTerminalServerModal selectClass dark repaint
The matching repaints inside the ModelUsage/UserUsage components are
not part of this change — those files were dead code and were removed
entirely in #27574.
* chore: catch remaining redundant surface repaints missed in the first pass
Same rule as the previous commit — every one of these repaints the exact
color its parent surface already provides, so removal is stock-identical
in light and dark while letting instance theming show through:
- Analytics Dashboard's inline Model Usage / User Activity row markup
(the Analytics tab renders these tables from Dashboard.svelte itself;
the unreferenced ModelUsage/UserUsage component files were removed
in #27574)
- Evaluations Feedbacks + Leaderboard body rows (settings modal surface)
- admin UserList body rows (app page surface)
- chat markdown tables (MarkdownTokens): thead and body rows — unlike
the tiptap header (gray-850 contrast, untouched), this thead painted
the page's own color
- CitationsModal source rows (common Modal surface)
- AddConnectionModal selectClass dark repaint — third copy of the same
const already fixed in AddToolServerModal / AddTerminalServerModal
Nothing in the tree imports either component; the admin Analytics tab
renders its own inline copies of both tables directly from
Dashboard.svelte. Both files landed with the dashboard in a4ad34841
(feat: analytics frontend dashboard) but were never wired into it.
The remaining name matches elsewhere (the getUserUsage API and
UserUsage* types in src/lib/apis/users/index.ts, consumed by
chat/Settings/Usage.svelte, plus the backend usage endpoints) belong to
the unrelated per-user usage feature and are untouched.
On latest `dev`, the five sortable column headers in the admin Users table are click handling `<th>` elements:
```svelte
<th scope="col" class="px-2.5 py-1.5 font-normal cursor-pointer select-none" on:click={() => setSortKey('name')}>
```
A `<th>` is not interactive. There is no `<button>`, no `tabindex`, no `role` and no key handler, so **sorting the user list is impossible without a mouse**. The sort direction is also conveyed only by an 8×8 pixel chevron, with no programmatic state, so assistive technology cannot report which column is sorted or in which direction.
Breaks WCAG 2.1.1 Keyboard (Level A) and 4.1.2 Name, Role, Value (Level A).
Fix: move the click handler onto a real `<button>` inside the header, which brings native focus, Enter and Space activation and the correct role, and add `aria-sort` to the `<th>`, which already carries `scope="col"` and therefore the implicit `columnheader` role. Only the active column reports a direction, since `orderBy` is a single value; the non sortable actions column deliberately gets no `aria-sort` at all rather than `none`, so it is not advertised as sortable.
The cell padding moves from the `<th>` onto the button so the whole header stays clickable. Left on the `<th>`, the padding ring would have become a dead zone, shrinking the hit target and flipping the cursor at an invisible boundary inside the header.
`cursor-pointer` is dropped from the `<th>` because `src/tailwind.css` already applies it to every `button`.
The repeated `aria-sort` ternary is extracted to a small `sortState` helper rather than pasted five times.
The same mouse only `<th on:click>` pattern still exists in the Analytics, Evaluations and Groups tables and is not touched here.
Severity: Serious. A core admin function is unreachable without a pointing device.
### 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.
The OAuth / OIDC section in Admin Settings > Authentication had no
enable/disable switch, unlike the LDAP section above it. Add one that
persists via the existing Save flow and actually gates OAuth sign-in,
mirroring how the LDAP toggle works.
- config: new ENABLE_OAUTH persistent config ('oauth.enable'), defaulting
to True so existing deployments with a provider configured keep working.
- oauth: expose ENABLE_OAUTH via the OAuth runtime config and reject the
login and callback handlers with 404 when it is disabled.
- /api/config: report no OAuth providers when disabled so the login page
hides the OAuth buttons (and cannot auto-redirect), without clearing the
admin's provider configuration.
- auths: expose ENABLE_OAUTH through the admin OAuth config get/update
endpoints (OAuthConfigForm + OAUTH_CONFIG_KEYS).
- Authentication.svelte: bind the OAuth / OIDC header Switch to the
persisted oauthConfig.ENABLE_OAUTH and collapse the section when off,
matching the LDAP header (size, weight, alignment).
Follow-up to #27495, reopened as a high contrast mode change. Builds on the `high-contrast` class landed in #27555.
Muted UI text is written as text-gray-400 dark:text-gray-600. The grey scale in src/tailwind.css is achromatic oklch(L 0 0), so relative luminance is exactly L³: text-gray-400 is 2.07:1 on white and dark:text-gray-600 is 3.12:1 on #171717. The pair is effectively inverted, and both halves fail the 4.5:1 required by WCAG 1.4.3, with the light value also failing the 3:1 required of icons under 1.4.11. This is the text used for settings and admin section headings, field descriptions, sidebar labels, timestamps and counters, all at 10px to 12px, so the large-text exemption does not apply.
Rather than rewriting the class literal at 251 sites, the remap is two CSS rules that only apply when the existing High Contrast Mode setting is on, so the default theme is untouched:
- text-gray-400 resolves to gray-600 (5.75:1) in light mode
- dark:text-gray-600 resolves to gray-400 (8.65:1) in dark mode
dark:text-gray-500 already passes at 6.46:1 and is left alone.
The rules live in `@layer utilities` and use `:where()` to stay at low specificity: they outrank the base utility but lose to `hover:` and `dark:hover:` variants, so hover feedback keeps working. Verified in a browser against Tailwind's emitted rules and layer order: with the setting on, resting text resolves to gray-600 in light and gray-400 in dark, hover still resolves to its own value, and with the setting off nothing changes in either theme.
One component change is required alongside it. In admin/Settings/Audio.svelte the help text puts links inside the muted block via `[&_a]:text-gray-600`; once the surrounding prose resolves to gray-600 the link becomes the same colour as the text it sits in, and it has no resting underline, which would be a new WCAG 1.4.1 failure. The link moves to gray-900. This is the only place in the codebase where a link colour is nested inside muted text.
Letting the variants win has one edge: a few elements hover to a grey lighter than their new resting colour, so hovering would have lowered contrast instead of raising it. A light-mode hover landing on gray-500 now resolves to gray-800, which keeps the hover darker than the gray-600 resting state. Sidebar/Section.svelte is the site this branch would otherwise break.
Not covered here: text-gray-500 dark:text-gray-400 (2.77:1 in light), which is a separate branch.
`admin/Users/Groups/Permissions.svelte` contains **64** `<Switch>` instances and not one of them passes `ariaLabel`, `ariaLabelledbyId` or `id`. bits-ui renders the switch as a `<button role="switch">` whose subtree is a text free thumb, so all 64 have **no accessible name**. The visible label is a sibling `<div>` with no association to the control.
This is the worst remaining case in the admin area: 64 toggles in one dialog, many with near identical adjacent labels (Import Models / Export Models / Import Prompts / Export Prompts / Import Tools / Export Tools). A screen reader user hears 64 consecutive "switch, on" and "switch, off" with no way to tell which permission is which.
Breaks WCAG 4.1.2 Name, Role, Value (Level A).
Fix: pass the row's own label to each switch. The `ariaLabel` expression is the **same `$i18n.t()` key** as the visible text two lines above it, so the accessible name equals the visible label in every locale, which also satisfies 2.5.3 Label in Name and keeps voice control working.
`ariaLabel` rather than `ariaLabelledbyId`, which is what `chat/Settings/Interface.svelte` uses for the same row shape. The difference is that `Interface.svelte` is a singleton, whereas this component is rendered from `EditGroupModal`, which is instantiated in three places including once per group in `GroupItem.svelte`. Only one can be visible today, but nothing enforces that, and 64 hardcoded ids would fail silently the day two coexist, since `aria-labelledby` resolves to the first matching id. `aria-label` has no such failure mode and needs half the edits.
All 64 mappings were checked individually rather than assumed. The nearest preceding label is the correct one in every case, including the three rows wrapped in a `<Tooltip>` (whose `content` attribute precedes the label in source order) and the ~60 `{#if}` / `{:else if}` explanatory strings (which always follow their switch). All 64 resulting labels are distinct.
The nested sub toggles are unambiguous on their own because upstream already labelled them fully ("Import Models" rather than "Import"), so no extra scoping is needed.
Two known follow ups, deliberately not bundled:
- The warning tooltips on Tools Access, Skills Access and Automations ("Warning: Enabling this will allow users to upload arbitrary code on the server.") are attached to a non focusable wrapper `<div>`, so keyboard and screen reader users never receive them. That needs a change in `common/Tooltip.svelte` or an `ariaDescribedbyId` on `Switch`, not a naming change.
- This file is a ~14 line block repeated 64 times where only the label and permission key vary, and it wants a shared `PermissionRow` component. Extracting it here would bundle a large structural refactor into an accessibility fix and make the diff unreviewable against the claim, so it is left alone.
The diff is +164/−65 rather than 64 changed lines, because 33 of the switches exceed the 100 column print width and Prettier reflows them to the multi line form. The file is Prettier clean and compiles with no new warnings.
Severity: Serious.
### 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.
On latest `dev`, `common/Checkbox.svelte` renders a `<button type="button">` containing only `aria-hidden="true"` SVGs. It has no `role`, no `aria-checked` and no accessible name, and the component has no `$$restProps` spread, so a caller cannot supply a name either.
Assistive technology announces every one of these as an unnamed "button". A screen reader user cannot tell that the control is a checkbox, cannot tell whether it is on or off, and cannot tell what it toggles. The visible label is always an unassociated sibling element, for example `Capabilities.svelte` puts it in a preceding `<div>` with no `id`, and `Groups/Users.svelte` puts it in a different table cell from the checkbox.
Breaks WCAG 4.1.2 Name, Role, Value (Level A) on all three counts at once.
Fix: expose `role="checkbox"` and `aria-checked` on the control, add an `ariaLabel` prop, and pass the label text that is already in scope at each call site. `aria-checked` mirrors the component's existing icon logic exactly, so the indeterminate dash reports `mixed` rather than `false`. The `ariaLabel={ariaLabel || undefined}` shape matches the sibling `common/Switch.svelte`. Every label expression is the same one that renders the visible text next to the checkbox, so the accessible name always matches what is on screen.
Three call sites are deliberately left out of this PR, because they nest `Checkbox` inside another `<button>`, which is invalid HTML and independently broken:
- `workspace/Knowledge/KnowledgeBase.svelte` — the Checkbox's `on:change` sets `includeContent = true` and then the same click bubbles to the outer button, which flips it back with `includeContent = !includeContent`. Clicking the checkbox square is a no-op today, only the text label works. Giving it a confident name would advertise a control that does nothing.
- `workspace/common/MemberSelector.svelte` (two instances) — the inner Checkbox has no `on:change` at all and only works because its click bubbles to the row button. Naming it would create two focusable controls per row with the same name.
Both need the nesting resolved first, so that the row button carries the checkbox semantics. That is a behavioural fix and belongs in its own PR.
Severity: Serious. Affects model capabilities, default features, builtin tools, tool/filter/skill/action selectors and group membership.
### 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.
`common/Switch.svelte` already accepts `id`, `ariaLabel` and `ariaLabelledbyId`, but **not one of the 148 `<Switch>` instances under `src/lib/components/admin/` passes any of them**.
`admin/Settings/AdminSettingRow.svelte` renders the row label as a plain `<div>` and the control in a **sibling** slot, so there is nothing tying them together. bits-ui renders the switch as a `<button role="switch">` whose subtree is a text free thumb, so it has no accessible name from any source.
A screen reader user working through Admin Settings hears a long run of "switch, on" and "switch, off" with no indication of what any of them controls.
Breaks WCAG 4.1.2 Name, Role, Value (Level A) and 1.3.1 Info and Relationships (Level A).
Fix: `AdminSettingRow` mints a per instance id, puts it on the label element, and hands it to the default slot, so each row's switch can point at the label that is already rendered next to it. This is the pattern `chat/Settings/Interface.svelte` already uses by hand in 45 places, hoisted into the shared row component so call sites stop hand authoring ids.
`aria-labelledby` rather than a wrapping `<label>`: per HTML-AAM a `<button>` takes its name from `aria-labelledby`, then `aria-label`, then its own subtree, never from an associated `<label>`. `chat/Settings/Subagents.svelte` already wraps two switches in a `<label>` and they are still unnamed, which is the same trap. Using the existing label element also guarantees the accessible name is byte identical to the visible text, which keeps voice control working.
The `description` paragraph deliberately sits outside the referenced element, so verbose help text is not pulled into the name.
Scope: this covers the **72** switches that live inside an `AdminSettingRow`, which is every switch that flows through the shared row component. There are no rows containing more than one switch, so nothing is silently skipped.
The remaining 76 admin switches are not in this component and are not touched. 64 of them are in `admin/Users/Groups/Permissions.svelte`, which hand rolls its own row markup, and the other 12 are per entity toggles in lists and dropdowns where the label is a dynamic row name. `Permissions.svelte` is the worst remaining case, 64 toggles with near identical adjacent labels, and it needs either its own labelling pass or a conversion to `AdminSettingRow` that changes its visual styling. Either way that is not an accessibility only diff and belongs in its own PR.
All 12 touched files compile with the Svelte compiler with no new warnings and are Prettier clean.
Severity: Serious. Admin Settings is unusable with a screen reader.
### 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.
On narrow viewports the admin Models list rows previously collapsed into
unusable vertical stacks; the Models page redesign on dev has since
absorbed the truncation fixes this branch carried (min-w-0 chain, real
truncate on the name, shrink-0 action group, inline access label).
The one remaining gap: the per-row edit pencil duplicates the row tap
(both open the model editor) while costing scarce horizontal space on
mobile. Hide it below the sm breakpoint; it remains on sm+ screens.
* feat: expose LDAP group sync settings in admin config
LDAP group synchronization was already wired into the login flow but its
settings (group management, auto-creation, and the group attribute) could
only be set via environment variables. OAuth, by contrast, exposes its
group-mapping settings through the admin config API and UI.
Bring LDAP to parity:
- Add enable_group_management, enable_group_creation and
attribute_for_groups to LdapServerConfig and LDAP_SERVER_CONFIG_KEYS so
the /admin/config/ldap/server endpoint reads and persists them.
- Add a "Group Mapping / Auto-Create Groups / Group Attribute" section to
the LDAP admin settings UI, mirroring the OAuth group-mapping controls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe
* fix: harden LDAP group sync config and login flow
Address review findings on the LDAP group-sync settings:
- ldap_auth: move the auto-create-groups call inside the try/except that
wraps group sync, so a group-creation error is logged instead of
bubbling to the broad handler and failing the whole login.
- update_ldap_server: reject saving with group management enabled but an
empty group attribute, which would otherwise make sync silently no-op
(mirrors the existing required-field validation).
- Authentication.svelte: merge the LDAP server config response into the
client defaults instead of replacing the object, so any key an older
backend omits keeps its default value.
Note: the empty-directory-groups behavior was reviewed and already
matches OAuth (both skip removal when no groups are returned), so it was
left unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe
* fix: default blank LDAP group attribute to memberOf before save
The Group Attribute field advertises "Default to memberOf", but the
backend now rejects an empty group attribute when group management is
enabled. Fall back to the memberOf default client-side when the field is
left blank, so the advertised default holds and the save isn't rejected.
The backend validation remains as defense-in-depth for direct API calls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe
* fix: initialize LDAP port default as null instead of empty string
The backend LdapServerConfig types port as `int | None`, but the frontend
initialized it to an empty string. If a save carried that default (e.g.
when the backend response omits port under version skew), Pydantic would
reject the empty string. `null` matches the model and is also what the
type="number" input yields when the field is empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe
* fix: parse LDAP group DNs correctly instead of splitting on commas
Group CN extraction split the DN on raw commas and sliced off "CN=",
which mangles any group whose name contains an escaped separator (e.g.
"CN=Sales\, EMEA,OU=...") into a truncated, wrong name that then fails to
match the intended Open WebUI group. Use ldap3's parse_dn to split the DN
respecting RFC 4514 escaping, and unescape the resulting value so the CN
matches what an administrator sees.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe
* chore: address review feedback on _unescape_ldap_dn_value
Trim the docstring and rename the loop index to a more descriptive name
(i -> pos) per review feedback on the group DN unescaping helper. No
behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe
---------
Co-authored-by: Claude <noreply@anthropic.com>