This commit is contained in:
Timothy Jaeryang Baek 2026-10-05 12:49:11 +04:00
parent 24e30d1cbd
commit 5bb1c47080
10 changed files with 152 additions and 91 deletions

View file

@ -92,6 +92,11 @@
toast.error(`${error}`);
return null;
});
if (res?.sessions_revoked) {
localStorage.removeItem('token');
window.location.href = '/auth?state=logout&form=signin';
return false;
}
if (res) {
adminConfig = res;
}
@ -99,11 +104,13 @@
};
const submitHandler = async () => {
const adminSaved = await updateAdminHandler();
const ldapSaved = await updateLdapServerHandler();
const oauthSaved = await updateOAuthHandler();
if (!ldapSaved || !oauthSaved) return;
if (adminSaved && ldapSaved && oauthSaved) {
// Save MFA policy last because changing it revokes the current session.
const adminSaved = await updateAdminHandler();
if (adminSaved) {
toast.success($i18n.t('Settings saved successfully!'));
await config.set(await getBackendConfig());
}
@ -255,6 +262,32 @@
</AdminSettingField>
</AdminSettingSection>
<AdminSettingSection title={$i18n.t('Multi-factor authentication')}>
<AdminSettingRow label={$i18n.t('Require an authenticator for all users')}>
<Switch
bind:state={adminConfig.ENABLE_MFA}
ariaLabel={$i18n.t('Require an authenticator for all users')}
/>
</AdminSettingRow>
<p class="-mt-1 text-[0.6875rem] leading-4 text-gray-400 dark:text-gray-600">
{$i18n.t('Changes sign out all devices. Users enroll on their next sign-in.')}
</p>
{#if adminConfig.ENABLE_MFA}
<AdminSettingRow label={$i18n.t('Allow OAuth sign-in without an authenticator')}
><Switch
bind:state={adminConfig.MFA_ALLOW_OAUTH_BYPASS}
ariaLabel={$i18n.t('Allow OAuth sign-in without an authenticator')}
/></AdminSettingRow
>
<AdminSettingRow label={$i18n.t('Allow trusted-header sign-in without an authenticator')}
><Switch
bind:state={adminConfig.MFA_ALLOW_TRUSTED_HEADER_BYPASS}
ariaLabel={$i18n.t('Allow trusted-header sign-in without an authenticator')}
/></AdminSettingRow
>
{/if}
</AdminSettingSection>
<AdminSettingSection
title={$i18n.t('settings.admin.authentication.sections.pendingAccounts.title')}
>

View file

@ -275,31 +275,7 @@
{/if}
</div>
</AdminSettingSection>
<AdminSettingSection title={$i18n.t('Multi-factor authentication')}>
<AdminSettingRow label={$i18n.t('Require an authenticator for all users')}>
<Switch
bind:state={adminConfig.ENABLE_MFA}
ariaLabel={$i18n.t('Require an authenticator for all users')}
/>
</AdminSettingRow>
<p class="-mt-1 text-[0.6875rem] leading-4 text-gray-400 dark:text-gray-600">
{$i18n.t('Changes sign out all devices. Users enroll on their next sign-in.')}
</p>
{#if adminConfig.ENABLE_MFA}
<AdminSettingRow label={$i18n.t('Allow OAuth sign-in without an authenticator')}
><Switch
bind:state={adminConfig.MFA_ALLOW_OAUTH_BYPASS}
ariaLabel={$i18n.t('Allow OAuth sign-in without an authenticator')}
/></AdminSettingRow
>
<AdminSettingRow label={$i18n.t('Allow trusted-header sign-in without an authenticator')}
><Switch
bind:state={adminConfig.MFA_ALLOW_TRUSTED_HEADER_BYPASS}
ariaLabel={$i18n.t('Allow trusted-header sign-in without an authenticator')}
/></AdminSettingRow
>
{/if}
</AdminSettingSection>
<AdminSettingSection title={$i18n.t('settings.admin.general.sections.features.title')}>
<AdminSettingRow

View file

@ -236,7 +236,7 @@
<div class="flex justify-between pt-3 text-sm font-normal">
<button
type="button"
class="text-sm text-red-500"
class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
on:click={() => {
showRevokeConfirmation = true;
}}>{$i18n.t('Sign out all devices')}</button

View file

@ -73,7 +73,7 @@
/>
{:else}
<div>
<h2 class="text-base font-medium tracking-tight text-gray-900 dark:text-white">
<h2 class="text-base font-medium tracking-tight text-gray-700 dark:text-gray-300">
{$i18n.t(
challenge.next_step === 'enroll'
? 'Set up your authenticator'
@ -103,7 +103,7 @@
/>
<details class="group text-[0.6875rem] text-gray-500 dark:text-gray-400">
<summary
class="flex cursor-pointer list-none items-center gap-1 hover:text-gray-900 dark:hover:text-white [&::-webkit-details-marker]:hidden"
class="flex cursor-pointer list-none items-center gap-1 hover:text-gray-700 dark:hover:text-gray-200 [&::-webkit-details-marker]:hidden"
>
{$i18n.t('Enter the key manually')}
<ChevronRight className="size-2.5 shrink-0 transition-transform group-open:rotate-90" />
@ -118,11 +118,11 @@
</p>
{:else}<button
type="button"
class="text-gray-500 hover:text-gray-900 dark:hover:text-white"
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-200"
on:click={loadSetup}>{$i18n.t('Retry setup')}</button
>{/if}
{/if}
<label class="block text-[0.8125rem] leading-5 font-normal text-left text-black dark:text-white">
<label class="block text-[0.8125rem] leading-5 font-normal text-left text-gray-600 dark:text-gray-400">
{$i18n.t(
challenge.next_step === 'recover'
? 'Operator recovery token'
@ -156,10 +156,10 @@
{#if error}<p role="alert" class="text-xs leading-4 text-red-600 dark:text-red-400">
{error}
</p>{/if}
<div class="flex justify-end text-black dark:text-white">
<div class="flex justify-end text-gray-700 dark:text-gray-300">
<button
type="button"
class="bg-gray-700/5 hover:bg-gray-700/10 dark:bg-gray-100/5 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition w-full rounded-full font-normal text-[0.8125rem] leading-5 py-2.5 disabled:opacity-50 flex justify-center"
class="bg-gray-700/5 hover:bg-gray-700/10 dark:bg-gray-100/5 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-gray-200 transition w-full rounded-full font-normal text-[0.8125rem] leading-5 py-2.5 disabled:opacity-50 flex justify-center"
disabled={busy || !code.trim() || (challenge.next_step === 'enroll' && !setup)}
on:click={verify}>{$i18n.t(busy ? 'Verifying…' : 'Continue')}</button
>
@ -169,13 +169,13 @@
>
<button
type="button"
class="transition-colors hover:text-gray-900 dark:hover:text-white"
class="transition-colors hover:text-gray-700 dark:hover:text-gray-200"
disabled={busy}
on:click={onCancel}>{$i18n.t('Back to sign in')}</button
>
{#if challenge.next_step === 'verify'}<button
type="button"
class="transition-colors hover:text-gray-900 dark:hover:text-white"
class="transition-colors hover:text-gray-700 dark:hover:text-gray-200"
disabled={busy}
on:click={() => {
recovery = !recovery;

View file

@ -17,7 +17,7 @@
<div class="space-y-3 text-left text-xs">
<div>
<h2 class="text-base font-medium tracking-tight text-gray-900 dark:text-white">
<h2 class="text-base font-medium tracking-tight text-gray-700 dark:text-gray-300">
{$i18n.t('Save your recovery codes')}
</h2>
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
@ -31,7 +31,7 @@
</ul>
<button
type="button"
class="text-[0.6875rem] text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
class="text-[0.6875rem] text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
on:click={download}>{$i18n.t('Download recovery codes')}</button
>
<label class="flex items-center gap-2 text-[0.6875rem] text-gray-600 dark:text-gray-400"
@ -41,10 +41,10 @@
bind:checked={saved}
/>{$i18n.t('I have saved my recovery codes')}</label
>
<div class="flex justify-end text-black dark:text-white">
<div class="flex justify-end text-gray-700 dark:text-gray-300">
<button
type="button"
class="bg-gray-700/5 hover:bg-gray-700/10 dark:bg-gray-100/5 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition w-full rounded-full font-normal text-[0.8125rem] leading-5 py-2.5 disabled:opacity-50 flex justify-center"
class="bg-gray-700/5 hover:bg-gray-700/10 dark:bg-gray-100/5 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-gray-200 transition w-full rounded-full font-normal text-[0.8125rem] leading-5 py-2.5 disabled:opacity-50 flex justify-center"
disabled={!saved}
on:click={onContinue}>{$i18n.t('Continue')}</button
>

View file

@ -15,7 +15,7 @@
let reauthenticate = false;
let show = false;
const actionButtonClass =
'text-xs text-gray-500 transition-colors hover:text-gray-900 disabled:opacity-40 dark:text-gray-500 dark:hover:text-white';
'text-xs text-gray-500 transition-colors hover:text-gray-700 disabled:opacity-40 dark:text-gray-500 dark:hover:text-gray-200';
onMount(async () => {
try {
status = await mfaRequest('status', undefined, localStorage.token);

View file

@ -46,6 +46,7 @@
} from '$lib/utils/localizedContent';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import ArrowRight from '../icons/ArrowRight.svelte';
import CheckCircle from '../icons/CheckCircle.svelte';
import Minus from '../icons/Minus.svelte';
import ModelMenu from './Models/ModelMenu.svelte';
@ -80,6 +81,7 @@
let showModelDeleteConfirm = false;
let selectedModel = null;
let updatingModels: Record<string, boolean> = {};
let groupIds = [];
@ -151,6 +153,43 @@
return target instanceof Element && !!target.closest('button, a, input, [role="menu"]');
};
const confirmDeleteModel = (model: any) => {
selectedModel = model;
showModelDeleteConfirm = true;
};
const toggleModelHandler = async (model: any) => {
if (updatingModels[model.id]) return;
const previousState = !model.is_active;
updatingModels = { ...updatingModels, [model.id]: true };
try {
const updatedModel = await toggleModelById(localStorage.token, model.id);
model.is_active = updatedModel.is_active;
models = models;
} catch (error: any) {
model.is_active = previousState;
models = models;
toast.error(`${error?.detail ?? error}`);
return;
} finally {
updatingModels = { ...updatingModels, [model.id]: false };
}
try {
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections
? ($settings?.directConnections ?? null)
: null
)
);
} catch (error: any) {
toast.error(`${error?.detail ?? error}`);
}
};
const getModelList = async () => {
if (!loaded) return;
@ -662,11 +701,10 @@
{#if models !== null}
{#if (models ?? []).length !== 0}
<div class="my-1" id="model-list">
<div
class="flex w-full items-center gap-2 px-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
>
<div class="flex items-center gap-3 px-2 pb-0.5 text-xs text-gray-400 dark:text-gray-500">
<span>{$i18n.t('Sort by')}</span>
<button
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
class="flex items-center gap-1 py-0.5"
type="button"
on:click={() => setSortKey('name')}
>
@ -680,10 +718,8 @@
{/if}
</button>
<div class="hidden w-44 shrink-0 md:block"></div>
<button
class="flex w-36 shrink-0 items-center justify-end gap-1 py-0.5 text-right"
class="flex items-center gap-1 py-0.5"
type="button"
on:click={() => setSortKey('updated_at')}
>
@ -702,7 +738,7 @@
{#each models as model (model.id)}
{@const localizedModelName = resolveLocalizedModelName(model, $i18n.language)}
<div
class="group flex min-h-8 w-full items-center gap-2 overflow-hidden rounded-xl px-2 py-1 text-left {model.write_access
class="group flex min-h-8 w-full items-center gap-1.5 overflow-hidden rounded-xl px-2 py-0.5 text-left {model.write_access
? 'cursor-pointer'
: ''} {model?.meta?.hidden ? 'opacity-50 dark:opacity-50' : ''}"
id="model-item-{model.id}"
@ -720,7 +756,7 @@
}
}}
>
<div class="mr-1 shrink-0 self-center">
<div class="shrink-0 self-center">
<div class="{model.is_active ? '' : 'opacity-50 dark:opacity-50'} bg-transparent">
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
@ -741,18 +777,36 @@
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<Tooltip
content={localizedModelName}
className="min-w-0"
placement="top-start"
>
{#if model.write_access}
<a
href={`/workspace/models/edit?id=${encodeURIComponent(model.id)}`}
class="focus-ring block truncate rounded text-[0.8125rem] leading-5 text-gray-800 group-hover:underline focus-visible:underline dark:text-gray-200"
>{localizedModelName}</a
>
{:else}
<span
class="block truncate text-[0.8125rem] leading-5 text-gray-800 dark:text-gray-200"
>{localizedModelName}</span
>
{/if}
</Tooltip>
<Tooltip
content={$i18n.t('Try in chat')}
className="-ml-0.5 h-5 w-4 shrink-0"
tippyOptions={{ trigger: 'mouseenter focusin' }}
>
<a
href={`/?model=${encodeURIComponent(model.id)}`}
class="block truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
aria-label={`${$i18n.t('Try in chat')}: ${localizedModelName}`}
class="focus-ring flex h-5 w-4 items-center justify-center rounded text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200"
><ArrowRight className="size-3 -rotate-45" strokeWidth="2" /></a
>
{localizedModelName}
</a>
</Tooltip>
<div
@ -761,12 +815,13 @@
{model.id}
</div>
<Tooltip content={dayjs(model.updated_at * 1000).format('LLLL')}>
<div
class="shrink-0 truncate text-[0.6875rem] leading-5 text-gray-400 dark:text-gray-600"
<Tooltip
content={dayjs(model.updated_at * 1000).format('LLLL')}
className="hidden shrink-0 sm:flex"
>
<span class="text-[0.6875rem] leading-5 text-gray-400 dark:text-gray-500"
>{dayjs(model.updated_at * 1000).fromNow()}</span
>
{dayjs(model.updated_at * 1000).fromNow()}
</div>
</Tooltip>
{#if !model.write_access}
@ -811,7 +866,7 @@
</Tooltip>
</div>
<div class="ml-2 flex shrink-0 flex-row items-center self-center">
<div class="ml-1 flex shrink-0 flex-row items-center self-center">
{#if shiftKey && model.write_access}
<Tooltip content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}>
<button
@ -840,14 +895,14 @@
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
deleteModelHandler(model);
confirmDeleteModel(model);
}}
>
<GarbageBin className="size-4" />
</button>
</Tooltip>
{:else}
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
<div class="flex shrink-0 flex-row items-center gap-1 self-center">
<ModelMenu
user={$user}
{model}
@ -874,8 +929,7 @@
copyLinkHandler(model);
}}
deleteHandler={() => {
selectedModel = model;
showModelDeleteConfirm = true;
confirmDeleteModel(model);
}}
onClose={() => {}}
>
@ -887,32 +941,20 @@
</ModelMenu>
{#if model.write_access}
<button
class="flex h-6 items-center"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
<Tooltip
content={updatingModels[model.id]
? $i18n.t('Saving...')
: model.is_active
? $i18n.t('Enabled')
: $i18n.t('Disabled')}
>
<Tooltip
content={model.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={model.is_active}
on:change={async () => {
toggleModelById(localStorage.token, model.id);
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null)
)
);
}}
/>
</Tooltip>
</button>
<Switch
bind:state={model.is_active}
disabled={updatingModels[model.id] ?? false}
ariaLabel={`${$i18n.t('Enabled')}: ${localizedModelName}`}
on:change={() => toggleModelHandler(model)}
/>
</Tooltip>
{/if}
</div>
{/if}

View file

@ -17,8 +17,9 @@
import { config, user as currentUser, pinnedModels, settings } from '$lib/stores';
import Link from '$lib/components/icons/Link.svelte';
import { resolveLocalizedModelName } from '$lib/utils/localizedContent';
const i18n = getContext('i18n');
const i18n = getContext<any>('i18n');
export let user;
export let model;
@ -54,6 +55,11 @@
>
<Tooltip content={$i18n.t('More')}>
<button
type="button"
class="focus-ring rounded-lg"
aria-label={`${$i18n.t('More')}: ${resolveLocalizedModelName(model, $i18n.language)}`}
aria-haspopup="menu"
aria-expanded={show}
on:click={(e) => {
e.stopPropagation();
show = !show;

View file

@ -3128,6 +3128,7 @@
"Sonar Pro": "",
"Sonar Reasoning": "",
"Sonar Reasoning Pro": "",
"Sort by": "",
"Sort": "",
"Source": "",
"Specific users or groups": "",
@ -3326,6 +3327,7 @@
"Transformers": "",
"Translation key": "",
"Trial License": "",
"Try in chat": "",
"Try adjusting your search or filter to find what you are looking for.": "",
"Try Again": "",
"TTS Voice": "",

View file

@ -274,7 +274,9 @@
/>
</div>
{:else if pendingApproval}
<p class="my-auto">{$i18n.t('Your account is awaiting administrator approval.')}</p>
<p class="my-auto text-sm text-gray-500 dark:text-gray-400">
{$i18n.t('Your account is awaiting administrator approval.')}
</p>
{:else if ($config?.features.auth_trusted_header ?? false) || $config?.features.auth === false}
<div class=" my-auto pb-10 w-full sm:max-w-md">
<div