/* 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 ( <>
{t('GitHub')} {t('Discord')} {t('OIDC')} {t('Telegram')} {t('LinuxDO')} {t('WeChat')} ( {t('Enable GitHub OAuth')} {t('Allow users to sign in with GitHub')} )} /> ( {t('Client ID')} )} /> ( {t('Client Secret')} )} /> ( {t('Enable Discord OAuth')} {t('Allow users to sign in with Discord')} )} /> ( {t('Client ID')} )} /> ( {t('Client Secret')} )} /> ( {t('Enable OIDC')} {t('Allow users to sign in with OpenID Connect')} )} /> ( {t('Client ID')} )} /> ( {t('Client Secret')} )} /> ( {t('Well-Known URL')} {t('Auto-discovers endpoints from the provider')} )} /> ( {t('Authorization Endpoint (Optional)')} )} /> ( {t('Token Endpoint (Optional)')} )} /> ( {t('User Info Endpoint (Optional)')} )} /> ( {t('Enable Telegram OAuth')} {t('Allow users to sign in with Telegram')} )} /> ( {t('Bot Token')} )} /> ( {t('Bot Name')} )} /> ( {t('Enable LinuxDO OAuth')} {t('Allow users to sign in with LinuxDO')} )} /> ( {t('Client ID')} )} /> ( {t('Client Secret')} )} /> ( {t('Minimum Trust Level')} {t('Minimum LinuxDO trust level required')} )} /> ( {t('Enable WeChat Auth')} {t('Allow users to sign in with WeChat')} )} /> ( {t('Server Address')} )} /> ( {t('Server Token')} )} /> ( {t('QR Code Image URL')} )} />
) }