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.
287 lines
7.7 KiB
Svelte
287 lines
7.7 KiB
Svelte
<script lang="ts">
|
|
import { getContext, onDestroy } from 'svelte';
|
|
const i18n = getContext('i18n');
|
|
|
|
import dayjs from 'dayjs';
|
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
|
dayjs.extend(relativeTime);
|
|
dayjs.extend(localizedFormat);
|
|
|
|
import { getUsers } from '$lib/apis/users';
|
|
import { toast } from 'svelte-sonner';
|
|
|
|
import { addUserToGroup, removeUserFromGroup } from '$lib/apis/groups';
|
|
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
|
|
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
|
import Checkbox from '$lib/components/common/Checkbox.svelte';
|
|
import Search from '$lib/components/icons/Search.svelte';
|
|
import Pagination from '$lib/components/common/Pagination.svelte';
|
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
|
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
|
|
|
export let groupId: string;
|
|
export let userCount = 0;
|
|
|
|
let users = null;
|
|
let total = null;
|
|
|
|
let query = '';
|
|
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
|
let orderBy = groupId ? `group_id:${groupId}` : 'last_active_at'; // default sort key
|
|
let direction = 'desc'; // default sort order
|
|
|
|
let page = 1;
|
|
|
|
const setSortKey = (key) => {
|
|
if (orderBy === key) {
|
|
direction = direction === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
orderBy = key;
|
|
direction = 'asc';
|
|
}
|
|
page = 1;
|
|
};
|
|
|
|
const roleClass = (role) => {
|
|
if (role === 'admin') {
|
|
return 'text-[#4f6f93] dark:text-[#8ba6c6]';
|
|
}
|
|
if (role === 'user') {
|
|
return 'text-[#4f7a5a] dark:text-[#8db395]';
|
|
}
|
|
return 'text-gray-500 dark:text-gray-400';
|
|
};
|
|
|
|
const getUserList = async () => {
|
|
try {
|
|
const res = await getUsers(localStorage.token, query, orderBy, direction, page).catch(
|
|
(error) => {
|
|
toast.error(`${error}`);
|
|
return null;
|
|
}
|
|
);
|
|
|
|
if (res) {
|
|
users = res.users;
|
|
total = res.total;
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const toggleMember = async (userId, state) => {
|
|
if (state === 'checked') {
|
|
await addUserToGroup(localStorage.token, groupId, [userId]).catch((error) => {
|
|
toast.error(`${error}`);
|
|
return null;
|
|
});
|
|
} else {
|
|
await removeUserFromGroup(localStorage.token, groupId, [userId]).catch((error) => {
|
|
toast.error(`${error}`);
|
|
return null;
|
|
});
|
|
}
|
|
|
|
getUserList();
|
|
};
|
|
|
|
$: if (page !== null && orderBy !== null && direction !== null) {
|
|
getUserList();
|
|
}
|
|
|
|
const handleSearchInput = () => {
|
|
clearTimeout(searchDebounceTimer);
|
|
searchDebounceTimer = setTimeout(() => {
|
|
if (page !== 1) {
|
|
page = 1;
|
|
} else {
|
|
getUserList();
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
onDestroy(() => {
|
|
clearTimeout(searchDebounceTimer);
|
|
});
|
|
</script>
|
|
|
|
<div class=" max-h-full h-full w-full flex flex-col overflow-y-hidden">
|
|
<div class="w-full h-fit mb-1.5">
|
|
<div class="flex flex-1 h-fit">
|
|
<div class=" self-center mr-3">
|
|
<Search />
|
|
</div>
|
|
<input
|
|
class=" w-full text-sm pr-4 rounded-r-xl outline-hidden bg-transparent"
|
|
bind:value={query}
|
|
on:input={handleSearchInput}
|
|
placeholder={$i18n.t('Search')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{#if users === null || total === null}
|
|
<div class="my-10">
|
|
<Spinner className="size-5" />
|
|
</div>
|
|
{:else}
|
|
{#if users.length > 0}
|
|
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
|
|
<table
|
|
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full"
|
|
>
|
|
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
|
<tr class=" border-b-[1.5px] border-gray-50/50 dark:border-gray-800/10">
|
|
<th
|
|
scope="col"
|
|
class="px-2.5 py-1.5 cursor-pointer text-left w-8"
|
|
on:click={() => setSortKey(`group_id:${groupId}`)}
|
|
>
|
|
<div class="flex gap-1.5 items-center">
|
|
{$i18n.t('MBR')}
|
|
|
|
{#if orderBy === `group_id:${groupId}`}
|
|
<span class="font-normal"
|
|
>{#if direction === 'asc'}
|
|
<ChevronUp className="size-2" />
|
|
{:else}
|
|
<ChevronDown className="size-2" />
|
|
{/if}
|
|
</span>
|
|
{:else}
|
|
<span class="invisible">
|
|
<ChevronUp className="size-2" />
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</th>
|
|
|
|
<th
|
|
scope="col"
|
|
class="px-2.5 py-1.5 cursor-pointer select-none"
|
|
on:click={() => setSortKey('name')}
|
|
>
|
|
<div class="flex gap-1.5 items-center">
|
|
{$i18n.t('Name')}
|
|
|
|
{#if orderBy === 'name'}
|
|
<span class="font-normal"
|
|
>{#if direction === 'asc'}
|
|
<ChevronUp className="size-2" />
|
|
{:else}
|
|
<ChevronDown className="size-2" />
|
|
{/if}
|
|
</span>
|
|
{:else}
|
|
<span class="invisible">
|
|
<ChevronUp className="size-2" />
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</th>
|
|
<th
|
|
scope="col"
|
|
class="px-2.5 py-1.5 cursor-pointer select-none"
|
|
on:click={() => setSortKey('role')}
|
|
>
|
|
<div class="flex gap-1.5 items-center">
|
|
{$i18n.t('Role')}
|
|
|
|
{#if orderBy === 'role'}
|
|
<span class="font-normal"
|
|
>{#if direction === 'asc'}
|
|
<ChevronUp className="size-2" />
|
|
{:else}
|
|
<ChevronDown className="size-2" />
|
|
{/if}
|
|
</span>
|
|
{:else}
|
|
<span class="invisible">
|
|
<ChevronUp className="size-2" />
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</th>
|
|
|
|
<th
|
|
scope="col"
|
|
class="px-2.5 py-1.5 cursor-pointer select-none"
|
|
on:click={() => setSortKey('last_active_at')}
|
|
>
|
|
<div class="flex gap-1.5 items-center">
|
|
{$i18n.t('Last Active')}
|
|
|
|
{#if orderBy === 'last_active_at'}
|
|
<span class="font-normal"
|
|
>{#if direction === 'asc'}
|
|
<ChevronUp className="size-2" />
|
|
{:else}
|
|
<ChevronDown className="size-2" />
|
|
{/if}
|
|
</span>
|
|
{:else}
|
|
<span class="invisible">
|
|
<ChevronUp className="size-2" />
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="">
|
|
{#each users as user, userIdx (user?.id ?? userIdx)}
|
|
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
|
|
<td class=" px-3 py-1 w-8">
|
|
<div class="flex w-full justify-center">
|
|
<Checkbox
|
|
ariaLabel={user.name}
|
|
state={(user?.group_ids ?? []).includes(groupId) ? 'checked' : 'unchecked'}
|
|
on:change={(e) => {
|
|
toggleMember(user.id, e.detail);
|
|
}}
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td class="px-3 py-1 font-normal text-gray-900 dark:text-white max-w-48">
|
|
<Tooltip content={user.email} placement="top-start">
|
|
<div class="flex items-center gap-2">
|
|
<img
|
|
class="rounded-full w-6 h-6 object-cover flex-shrink-0"
|
|
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
|
|
alt="user"
|
|
/>
|
|
|
|
<div class="font-normal truncate">{user.name}</div>
|
|
</div>
|
|
</Tooltip>
|
|
</td>
|
|
<td class="px-3 py-1 min-w-[5rem] w-20">
|
|
<span class="text-xs font-normal leading-4 capitalize {roleClass(user.role)}">
|
|
{$i18n.t(user.role)}
|
|
</span>
|
|
</td>
|
|
|
|
<td class=" px-3 py-1">
|
|
{dayjs(user.last_active_at * 1000).fromNow()}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{:else}
|
|
<div class="text-gray-500 text-xs text-center py-2 px-10">
|
|
{$i18n.t('No users were found.')}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if total > 30}
|
|
<Pagination bind:page count={total} perPage={30} />
|
|
{/if}
|
|
{/if}
|
|
</div>
|