/*
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 { useState } from 'react'
import * as z from 'zod'
import axios from 'axios'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { FormDirtyIndicator } from '../components/form-dirty-indicator'
import { FormNavigationGuard } from '../components/form-navigation-guard'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
const oauthSchema = z.object({
GitHubOAuthEnabled: z.boolean(),
GitHubClientId: z.string().optional(),
GitHubClientSecret: z.string().optional(),
'discord.enabled': z.boolean(),
'discord.client_id': z.string().optional(),
'discord.client_secret': z.string().optional(),
'oidc.enabled': z.boolean(),
'oidc.client_id': z.string().optional(),
'oidc.client_secret': z.string().optional(),
'oidc.well_known': z.string().optional(),
'oidc.authorization_endpoint': z.string().optional(),
'oidc.token_endpoint': z.string().optional(),
'oidc.user_info_endpoint': z.string().optional(),
TelegramOAuthEnabled: z.boolean(),
TelegramBotToken: z.string().optional(),
TelegramBotName: z.string().optional(),
LinuxDOOAuthEnabled: z.boolean(),
LinuxDOClientId: z.string().optional(),
LinuxDOClientSecret: z.string().optional(),
LinuxDOMinimumTrustLevel: z.string().optional(),
WeChatAuthEnabled: z.boolean(),
WeChatServerAddress: z.string().optional(),
WeChatServerToken: z.string().optional(),
WeChatAccountQRCodeImageURL: z.string().optional(),
})
const oauthTabContentClassName =
'grid min-w-0 gap-x-5 gap-y-6 lg:grid-cols-2 [&>[data-slot=form-item]]:min-w-0 lg:[&>[data-slot=form-item]:has([data-slot=switch])]:col-span-2'
type OAuthFormValues = z.infer
type OAuthSectionProps = {
defaultValues: OAuthFormValues
}
export function OAuthSection({ defaultValues }: OAuthSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const [activeTab, setActiveTab] = useState('github')
// Normalize empty strings for optional fields (only at mount)
const normalizedDefaults: OAuthFormValues = {
...defaultValues,
GitHubClientId: defaultValues.GitHubClientId ?? '',
GitHubClientSecret: defaultValues.GitHubClientSecret ?? '',
'discord.client_id': defaultValues['discord.client_id'] ?? '',
'discord.client_secret': defaultValues['discord.client_secret'] ?? '',
'oidc.client_id': defaultValues['oidc.client_id'] ?? '',
'oidc.client_secret': defaultValues['oidc.client_secret'] ?? '',
'oidc.well_known': defaultValues['oidc.well_known'] ?? '',
'oidc.authorization_endpoint':
defaultValues['oidc.authorization_endpoint'] ?? '',
'oidc.token_endpoint': defaultValues['oidc.token_endpoint'] ?? '',
'oidc.user_info_endpoint': defaultValues['oidc.user_info_endpoint'] ?? '',
TelegramBotToken: defaultValues.TelegramBotToken ?? '',
TelegramBotName: defaultValues.TelegramBotName ?? '',
LinuxDOClientId: defaultValues.LinuxDOClientId ?? '',
LinuxDOClientSecret: defaultValues.LinuxDOClientSecret ?? '',
LinuxDOMinimumTrustLevel: defaultValues.LinuxDOMinimumTrustLevel ?? '',
WeChatServerAddress: defaultValues.WeChatServerAddress ?? '',
WeChatServerToken: defaultValues.WeChatServerToken ?? '',
WeChatAccountQRCodeImageURL:
defaultValues.WeChatAccountQRCodeImageURL ?? '',
}
const form = useForm({
resolver: zodResolver(oauthSchema),
defaultValues: normalizedDefaults,
})
const onSubmit = async () => {
// Get raw form values directly
// React Hook Form treats "oidc.xxx" as nested paths, so we need to flatten
const rawData = form.getValues() as Record
// Flatten nested oidc object back to dot notation keys
const flattenedData: Record = {}
Object.entries(rawData).forEach(([key, value]) => {
if (
(key === 'oidc' || key === 'discord') &&
typeof value === 'object' &&
value !== null
) {
// React Hook Form auto-nested these fields, flatten them back
Object.entries(value as Record).forEach(
([nestedKey, nestedValue]) => {
flattenedData[`${key}.${nestedKey}`] = nestedValue
}
)
} else {
flattenedData[key] = value
}
})
const finalData = flattenedData as OAuthFormValues
if (finalData['oidc.well_known'] && finalData['oidc.well_known'] !== '') {
if (
!finalData['oidc.well_known'].startsWith('http://') &&
!finalData['oidc.well_known'].startsWith('https://')
) {
toast.error(t('Well-Known URL must start with http:// or https://'))
return
}
try {
const res = await axios.create().get(finalData['oidc.well_known'])
const authEndpoint = res.data['authorization_endpoint'] || ''
const tokenEndpoint = res.data['token_endpoint'] || ''
const userInfoEndpoint = res.data['userinfo_endpoint'] || ''
finalData['oidc.authorization_endpoint'] = authEndpoint
finalData['oidc.token_endpoint'] = tokenEndpoint
finalData['oidc.user_info_endpoint'] = userInfoEndpoint
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form.setValue('oidc.authorization_endpoint' as any, authEndpoint)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form.setValue('oidc.token_endpoint' as any, tokenEndpoint)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form.setValue('oidc.user_info_endpoint' as any, userInfoEndpoint)
toast.success(t('OIDC configuration fetched successfully'))
} catch (err) {
// eslint-disable-next-line no-console
console.error(err)
toast.error(
t(
'Failed to fetch OIDC configuration. Please check the URL and network status'
)
)
return
}
}
// Find changed fields by comparing to initial values
const updates = Object.entries(finalData).filter(
([key, value]) =>
value !== normalizedDefaults[key as keyof OAuthFormValues]
)
if (updates.length === 0) {
toast.info(t('No changes to save'))
return
}
// Save all changed fields
for (const [key, value] of updates) {
await updateOption.mutateAsync({ key, value: value ?? '' })
}
// Reset form dirty state after successful save
form.reset(finalData)
}
const handleReset = () => {
// React Hook Form auto-nests 'oidc.xxx' fields into { oidc: { xxx: value } }
// So we need to pass the same structure when resetting
const currentValues = form.getValues() as Record
// Create reset values matching RHF's internal structure
const resetValues = { ...currentValues }
// Update nested oidc fields
if (resetValues.oidc && typeof resetValues.oidc === 'object') {
Object.keys(resetValues.oidc as Record).forEach(
(key) => {
const flatKey = `oidc.${key}` as keyof typeof normalizedDefaults
if (flatKey in normalizedDefaults) {
;(resetValues.oidc as Record)[key] =
normalizedDefaults[flatKey]
}
}
)
}
// Update nested discord fields
if (resetValues.discord && typeof resetValues.discord === 'object') {
Object.keys(resetValues.discord as Record).forEach(
(key) => {
const flatKey = `discord.${key}` as keyof typeof normalizedDefaults
if (flatKey in normalizedDefaults) {
;(resetValues.discord as Record)[key] =
normalizedDefaults[flatKey]
}
}
)
}
// Update top-level fields
Object.keys(resetValues).forEach((key) => {
if (key !== 'oidc' && key in normalizedDefaults) {
resetValues[key] =
normalizedDefaults[key as keyof typeof normalizedDefaults]
}
})
form.reset(resetValues, {
keepDirty: false,
keepDirtyValues: false,
keepErrors: false,
})
toast.success(t('Form reset to saved values'))
}
return (
<>
>
)
}