diff --git a/web/src/features/system-settings/auth/__tests__/oauth-settings.test.tsx b/web/src/features/system-settings/auth/__tests__/oauth-settings.test.tsx
new file mode 100644
index 000000000..b7d0bde33
--- /dev/null
+++ b/web/src/features/system-settings/auth/__tests__/oauth-settings.test.tsx
@@ -0,0 +1,339 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRouter,
+ RouterProvider,
+} from '@tanstack/react-router'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import axios from 'axios'
+import { createInstance, type i18n } from 'i18next'
+import { useState, type ComponentProps } from 'react'
+import { I18nextProvider } from 'react-i18next'
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import zhTW from '@/i18n/locales/zh-TW.json'
+import zh from '@/i18n/locales/zh.json'
+import { api } from '@/lib/api'
+
+import { SettingsPageProvider } from '../../components/settings-page-context'
+import { OAuthSection } from '../oauth-section'
+
+const defaults: ComponentProps['defaultValues'] = {
+ GitHubOAuthEnabled: false,
+ GitHubClientId: '',
+ GitHubClientSecret: '',
+ 'discord.enabled': false,
+ 'discord.client_id': '',
+ 'discord.client_secret': '',
+ 'oidc.enabled': false,
+ 'oidc.display_name': '',
+ 'oidc.client_id': '',
+ 'oidc.client_secret': '',
+ 'oidc.well_known': '',
+ 'oidc.authorization_endpoint': '',
+ 'oidc.token_endpoint': '',
+ 'oidc.user_info_endpoint': '',
+ TelegramOAuthEnabled: false,
+ 'telegram.client_id': '',
+ 'telegram.client_secret': '',
+ LinuxDOOAuthEnabled: false,
+ LinuxDOClientId: '',
+ LinuxDOClientSecret: '',
+ LinuxDOMinimumTrustLevel: '',
+ WeChatAuthEnabled: false,
+ WeChatServerAddress: '',
+ WeChatServerToken: '',
+ WeChatAccountQRCodeImageURL: '',
+}
+
+let testI18n: i18n
+
+function Fixture(props: { values?: Partial }) {
+ const [container, setContainer] = useState(null)
+ const [client] = useState(
+ () => new QueryClient({ defaultOptions: { mutations: { retry: false } } })
+ )
+
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+async function renderSettings(values?: Partial) {
+ const route = createRootRoute({
+ component: () => ,
+ })
+ const router = createRouter({
+ routeTree: route,
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ await router.load()
+ render()
+}
+
+beforeEach(async () => {
+ testI18n = createInstance()
+ await testI18n.init({
+ lng: 'en',
+ fallbackLng: 'en',
+ resources: { en: { translation: {} }, zh, 'zh-TW': zhTW },
+ interpolation: { escapeValue: false },
+ })
+ vi.spyOn(api, 'put').mockResolvedValue({ data: { success: true } })
+})
+
+describe('OAuth settings saves', () => {
+ it.each([
+ [false, 'provider.example.com/discovery'],
+ [true, 'provider.example.com/discovery'],
+ [true, 'https://provider.example.com/discovery'],
+ ])(
+ 'saves GitHub independently of unchanged OIDC settings (enabled: %s, URL: %s)',
+ async (enabled, url) => {
+ const discoveryClient = axios.create()
+ vi.spyOn(discoveryClient, 'get').mockRejectedValue(
+ new Error('OIDC unavailable')
+ )
+ const discovery = vi
+ .spyOn(axios, 'create')
+ .mockReturnValue(discoveryClient)
+ const errorToast = vi.spyOn(toast, 'error')
+ const user = userEvent.setup()
+ await renderSettings({ 'oidc.enabled': enabled, 'oidc.well_known': url })
+ await user.type(
+ screen.getByRole('textbox', { name: 'Client ID' }),
+ 'github-client'
+ )
+ await user.click(screen.getByRole('button', { name: 'Save Changes' }))
+
+ await waitFor(() =>
+ expect(api.put).toHaveBeenCalledWith('/api/option/', {
+ key: 'GitHubClientId',
+ value: 'github-client',
+ })
+ )
+ expect(api.put).toHaveBeenCalledTimes(1)
+ expect(discovery).not.toHaveBeenCalled()
+ expect(errorToast).not.toHaveBeenCalled()
+ expect(screen.getByRole('tab', { name: 'GitHub' })).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ )
+ expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
+ }
+ )
+
+ it('does not discover OIDC when saving an unchanged form', async () => {
+ const discovery = vi.spyOn(axios, 'create')
+ const infoToast = vi.spyOn(toast, 'info')
+ const user = userEvent.setup()
+ await renderSettings({
+ 'oidc.well_known': 'provider.example.com/discovery',
+ })
+ await user.click(screen.getByRole('button', { name: 'Save Changes' }))
+
+ expect(infoToast).toHaveBeenCalledWith('No changes to save')
+ expect(discovery).not.toHaveBeenCalled()
+ expect(api.put).not.toHaveBeenCalled()
+ })
+
+ it('allows disabling OIDC even when its saved discovery URL is invalid', async () => {
+ const user = userEvent.setup()
+ await renderSettings({
+ 'oidc.enabled': true,
+ 'oidc.well_known': 'provider.example.com/discovery',
+ })
+ await user.click(screen.getByRole('tab', { name: 'OIDC' }))
+ await user.click(screen.getByRole('switch', { name: 'Enable OIDC' }))
+ await user.click(screen.getByRole('button', { name: 'Save Changes' }))
+
+ await waitFor(() =>
+ expect(api.put).toHaveBeenCalledWith('/api/option/', {
+ key: 'oidc.enabled',
+ value: false,
+ })
+ )
+ expect(api.put).toHaveBeenCalledTimes(1)
+ })
+
+ it('opens OIDC and marks an edited invalid URL even when saving from the GitHub tab', async () => {
+ const errorToast = vi.spyOn(toast, 'error')
+ const user = userEvent.setup()
+ await renderSettings()
+ await user.click(screen.getByRole('tab', { name: 'OIDC' }))
+ await user.type(
+ screen.getByRole('textbox', { name: 'Well-Known URL' }),
+ 'provider.example.com/discovery'
+ )
+ await user.click(screen.getByRole('tab', { name: 'GitHub' }))
+ await user.click(screen.getByRole('button', { name: 'Save Changes' }))
+
+ const url = await screen.findByRole('textbox', { name: 'Well-Known URL' })
+ expect(url).toHaveAttribute('aria-invalid', 'true')
+ expect(url).toHaveAccessibleDescription(
+ expect.stringContaining(
+ 'Well-Known URL must start with http:// or https://'
+ )
+ )
+ expect(api.put).not.toHaveBeenCalled()
+ expect(errorToast).not.toHaveBeenCalled()
+ })
+
+ it('validates the saved discovery URL before enabling OIDC', async () => {
+ const user = userEvent.setup()
+ await renderSettings({
+ 'oidc.well_known': 'provider.example.com/discovery',
+ })
+ await user.click(screen.getByRole('tab', { name: 'OIDC' }))
+ await user.click(screen.getByRole('switch', { name: 'Enable OIDC' }))
+ await user.click(screen.getByRole('button', { name: 'Save Changes' }))
+
+ expect(
+ screen.getByRole('textbox', { name: 'Well-Known URL' })
+ ).toHaveAttribute('aria-invalid', 'true')
+ expect(api.put).not.toHaveBeenCalled()
+ })
+
+ it('keeps Save disabled during discovery and persists the discovered endpoints', async () => {
+ const client = axios.create()
+ const response = {
+ data: {
+ authorization_endpoint: 'https://provider.example.com/authorize',
+ token_endpoint: 'https://provider.example.com/token',
+ userinfo_endpoint: 'https://provider.example.com/userinfo',
+ },
+ }
+ let finishDiscovery!: (value: typeof response) => void
+ const get = vi.spyOn(client, 'get').mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishDiscovery = resolve
+ })
+ )
+ vi.spyOn(axios, 'create').mockReturnValue(client)
+ const user = userEvent.setup()
+ await renderSettings()
+ await user.click(screen.getByRole('tab', { name: 'OIDC' }))
+ await user.type(
+ screen.getByRole('textbox', { name: 'Well-Known URL' }),
+ 'https://provider.example.com/discovery'
+ )
+ await user.click(screen.getByRole('button', { name: 'Save Changes' }))
+
+ await waitFor(() =>
+ expect(get).toHaveBeenCalledWith('https://provider.example.com/discovery')
+ )
+ expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
+ expect(api.put).not.toHaveBeenCalled()
+ finishDiscovery(response)
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
+ )
+ await waitFor(() => expect(api.put).toHaveBeenCalledTimes(4))
+ expect(api.put).toHaveBeenCalledWith('/api/option/', {
+ key: 'oidc.authorization_endpoint',
+ value: response.data.authorization_endpoint,
+ })
+ expect(api.put).toHaveBeenCalledWith('/api/option/', {
+ key: 'oidc.token_endpoint',
+ value: response.data.token_endpoint,
+ })
+ expect(api.put).toHaveBeenCalledWith('/api/option/', {
+ key: 'oidc.user_info_endpoint',
+ value: response.data.userinfo_endpoint,
+ })
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Save Changes' })).toBeEnabled()
+ )
+ })
+
+ it.each(['zh', 'zh-TW'])(
+ 'uses the Well-Known technical term in %s validation',
+ async (language) => {
+ await testI18n.changeLanguage(language)
+ const user = userEvent.setup()
+ await renderSettings()
+ await user.click(screen.getByRole('tab', { name: 'OIDC' }))
+ await user.type(
+ screen.getByRole('textbox', { name: 'Well-Known URL' }),
+ 'invalid'
+ )
+ await user.click(
+ screen.getByRole('button', { name: testI18n.t('Save Changes') })
+ )
+
+ const url = screen.getByRole('textbox', { name: 'Well-Known URL' })
+ expect(url).toHaveAccessibleDescription(
+ expect.stringContaining('Well-Known URL')
+ )
+ expect(url).not.toHaveAccessibleDescription(
+ expect.stringContaining('知名')
+ )
+ }
+ )
+
+ it('keeps edits available for retry when OIDC discovery fails', async () => {
+ const client = axios.create()
+ const get = vi
+ .spyOn(client, 'get')
+ .mockRejectedValue(new Error('OIDC unavailable'))
+ vi.spyOn(axios, 'create').mockReturnValue(client)
+ const errorToast = vi.spyOn(toast, 'error')
+ const user = userEvent.setup()
+ await renderSettings()
+ await user.click(screen.getByRole('tab', { name: 'OIDC' }))
+ await user.type(
+ screen.getByRole('textbox', { name: 'Well-Known URL' }),
+ 'https://provider.example.com/discovery'
+ )
+ await user.click(screen.getByRole('button', { name: 'Save Changes' }))
+
+ await waitFor(() => expect(errorToast).toHaveBeenCalledTimes(1))
+ expect(api.put).not.toHaveBeenCalled()
+ expect(screen.getByRole('button', { name: 'Save Changes' })).toBeEnabled()
+ expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled()
+ expect(screen.getByRole('textbox', { name: 'Well-Known URL' })).toHaveValue(
+ 'https://provider.example.com/discovery'
+ )
+ get.mockResolvedValue({ data: {} })
+ await user.click(screen.getByRole('button', { name: 'Save Changes' }))
+ await waitFor(() =>
+ expect(api.put).toHaveBeenCalledWith('/api/option/', {
+ key: 'oidc.well_known',
+ value: 'https://provider.example.com/discovery',
+ })
+ )
+ })
+})
diff --git a/web/src/features/system-settings/auth/oauth-section.tsx b/web/src/features/system-settings/auth/oauth-section.tsx
index 2f146db30..62f379ad0 100644
--- a/web/src/features/system-settings/auth/oauth-section.tsx
+++ b/web/src/features/system-settings/auth/oauth-section.tsx
@@ -298,14 +298,23 @@ export function OAuthSection(props: OAuthSectionProps) {
const onSubmit = async (values: OAuthFormValues) => {
let finalValues = values
+ const wellKnown = values.oidc.well_known.trim()
+ const discoveryUrlChanged =
+ wellKnown !== (baselineRef.current['oidc.well_known'] ?? '').trim()
+ const enablingOIDC =
+ values.oidc.enabled && !baselineRef.current['oidc.enabled']
- if (values.oidc.well_known && values.oidc.well_known.trim() !== '') {
- const wellKnown = values.oidc.well_known.trim()
+ // Other providers and disabling OIDC must not depend on OIDC discovery.
+ if (wellKnown && (discoveryUrlChanged || enablingOIDC)) {
if (
!wellKnown.startsWith('http://') &&
!wellKnown.startsWith('https://')
) {
- toast.error(t('Well-Known URL must start with http:// or https://'))
+ setActiveTab('oidc')
+ form.setError('oidc.well_known', {
+ type: 'validate',
+ message: t('Well-Known URL must start with http:// or https://'),
+ })
return
}
@@ -319,6 +328,7 @@ export function OAuthSection(props: OAuthSectionProps) {
...values,
oidc: {
...values.oidc,
+ well_known: wellKnown,
authorization_endpoint: authEndpoint,
token_endpoint: tokenEndpoint,
user_info_endpoint: userInfoEndpoint,
@@ -378,7 +388,7 @@ export function OAuthSection(props: OAuthSectionProps) {
diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json
index 7176f9d5c..fc621704f 100644
--- a/web/src/i18n/locales/fr.json
+++ b/web/src/i18n/locales/fr.json
@@ -6607,8 +6607,8 @@
"Weighted by request count": "Pondéré par le nombre de requêtes",
"Welcome back!": "Bienvenue de retour !",
"Welcome to our New API...": "Bienvenue sur notre New API...",
- "Well-Known URL": "URL bien connue",
- "Well-Known URL must start with http:// or https://": "L'URL bien connue doit commencer par http:// ou https://",
+ "Well-Known URL": "URL Well-Known",
+ "Well-Known URL must start with http:// or https://": "L’URL Well-Known doit commencer par http:// ou https://",
"What would you like to know?": "Que voulez-vous savoir ?",
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Quand un jeton utilise le groupe auto, le système essaie les groupes de haut en bas jusqu’à trouver un groupe disponible.",
"When billed as {{group}}": "Facturé sous {{group}}",
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index 7f9371e91..8b08f1675 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -6607,7 +6607,7 @@
"Weighted by request count": "リクエスト数で加重",
"Welcome back!": "おかえりなさい!",
"Welcome to our New API...": "New API へようこそ...",
- "Well-Known URL": "よく知られたURL",
+ "Well-Known URL": "Well-Known URL",
"Well-Known URL must start with http:// or https://": "Well-Known URL は http:// または https:// で始まる必要があります",
"What would you like to know?": "何を知りたいですか?",
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "トークンが auto グループを使用すると、システムは上から順に利用可能なグループを探します。",
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index 2746de658..c77e3872d 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -6607,7 +6607,7 @@
"Weighted by request count": "Взвешено по количеству запросов",
"Welcome back!": "Добро пожаловать обратно!",
"Welcome to our New API...": "Добро пожаловать в наш New API...",
- "Well-Known URL": "Известный эксперт",
+ "Well-Known URL": "Well-Known URL",
"Well-Known URL must start with http:// or https://": "Well-Known URL должен начинаться с http:// или https://",
"What would you like to know?": "Что вы хотели бы узнать?",
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Когда токен использует группу auto, система перебирает группы сверху вниз, пока не найдёт доступную.",
diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json
index 51404035a..7601e20ae 100644
--- a/web/src/i18n/locales/vi.json
+++ b/web/src/i18n/locales/vi.json
@@ -6607,7 +6607,7 @@
"Weighted by request count": "Có trọng số theo số yêu cầu",
"Welcome back!": "Chào mừng trở lại!",
"Welcome to our New API...": "Chào mừng bạn đến với API mới của chúng tôi...",
- "Well-Known URL": "URL đã biết",
+ "Well-Known URL": "URL Well-Known",
"Well-Known URL must start with http:// or https://": "URL Well-Known phải bắt đầu bằng http:// hoặc https://",
"What would you like to know?": "Bạn muốn biết gì?",
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Khi token dùng nhóm auto, hệ thống thử các nhóm từ trên xuống dưới cho đến khi tìm được nhóm khả dụng.",
diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json
index badf6155a..9230f16eb 100644
--- a/web/src/i18n/locales/zh-TW.json
+++ b/web/src/i18n/locales/zh-TW.json
@@ -6608,7 +6608,7 @@
"Welcome back!": "歡迎回來!",
"Welcome to our New API...": "歡迎使用我們的 New API...",
"Well-Known URL": "Well-Known URL",
- "Well-Known URL must start with http:// or https://": "知名 URL 必須以 http:// 或 https:// 開頭",
+ "Well-Known URL must start with http:// or https://": "Well-Known URL 必須以 http:// 或 https:// 開頭",
"What would you like to know?": "您想了解什麼?",
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "當令牌使用 auto 分組時,系統會按從上到下的順序嘗試,直到找到可用分組。",
"When billed as {{group}}": "按 {{group}} 收費時",
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 4a2e6b507..108e00f2a 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -6608,7 +6608,7 @@
"Welcome back!": "欢迎回来!",
"Welcome to our New API...": "欢迎使用我们的 New API...",
"Well-Known URL": "Well-Known URL",
- "Well-Known URL must start with http:// or https://": "知名 URL 必须以 http:// 或 https:// 开头",
+ "Well-Known URL must start with http:// or https://": "Well-Known URL 必须以 http:// 或 https:// 开头",
"What would you like to know?": "您想了解什么?",
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "当令牌使用 auto 分组时,系统会按从上到下的顺序尝试,直到找到可用分组。",
"When billed as {{group}}": "按 {{group}} 计费时",