refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMemo } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as z from 'zod'
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import {
|
||||
SettingsForm,
|
||||
SettingsSwitchContent,
|
||||
SettingsSwitchItem,
|
||||
} from '../components/settings-form-layout'
|
||||
import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
const basicAuthSchema = z.object({
|
||||
PasswordLoginEnabled: z.boolean(),
|
||||
PasswordRegisterEnabled: z.boolean(),
|
||||
EmailVerificationEnabled: z.boolean(),
|
||||
RegisterEnabled: z.boolean(),
|
||||
EmailDomainRestrictionEnabled: z.boolean(),
|
||||
EmailAliasRestrictionEnabled: z.boolean(),
|
||||
EmailDomainWhitelist: z.string(),
|
||||
})
|
||||
|
||||
type BasicAuthFormValues = z.infer<typeof basicAuthSchema>
|
||||
|
||||
type BasicAuthSectionProps = {
|
||||
defaultValues: BasicAuthFormValues
|
||||
}
|
||||
|
||||
export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const formDefaults = useMemo<BasicAuthFormValues>(
|
||||
() => ({
|
||||
...defaultValues,
|
||||
EmailDomainWhitelist: defaultValues.EmailDomainWhitelist.split(',')
|
||||
.map((domain) => domain.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
}),
|
||||
[defaultValues]
|
||||
)
|
||||
|
||||
const form = useForm<BasicAuthFormValues>({
|
||||
resolver: zodResolver(basicAuthSchema),
|
||||
defaultValues: formDefaults,
|
||||
})
|
||||
|
||||
useResetForm(form, formDefaults)
|
||||
|
||||
const onSubmit = async (data: BasicAuthFormValues) => {
|
||||
const updates: Array<{ key: string; value: string | boolean }> = []
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (key === 'EmailDomainWhitelist') {
|
||||
if (typeof value !== 'string') return
|
||||
const domains = value
|
||||
.split('\n')
|
||||
.map((domain) => domain.trim())
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
if (domains !== defaultValues.EmailDomainWhitelist) {
|
||||
updates.push({ key, value: domains })
|
||||
}
|
||||
} else if (value !== defaultValues[key as keyof typeof defaultValues]) {
|
||||
updates.push({ key, value })
|
||||
}
|
||||
})
|
||||
|
||||
for (const update of updates) {
|
||||
await updateOption.mutateAsync(update)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('Basic Authentication')}>
|
||||
<Form {...form}>
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SettingsPageFormActions
|
||||
onSave={form.handleSubmit(onSubmit)}
|
||||
isSaving={updateOption.isPending}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='PasswordLoginEnabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Password Login')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to log in with password')}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='RegisterEnabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Registration Enabled')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow new users to register')}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='PasswordRegisterEnabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Password Registration')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow registration with password')}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='EmailVerificationEnabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Email Verification')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Require email verification for new accounts')}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='EmailDomainRestrictionEnabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Email Domain Restriction')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Only allow specific email domains')}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='EmailAliasRestrictionEnabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Email Alias Restriction')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Block email aliases (e.g., user+alias@domain.com)')}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='EmailDomainWhitelist'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Email Domain Whitelist')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('example.com company.com')}
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'One domain per line (only used when domain restriction is enabled)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsForm>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as z from 'zod'
|
||||
|
||||
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 {
|
||||
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 botProtectionSchema = z.object({
|
||||
TurnstileCheckEnabled: z.boolean(),
|
||||
TurnstileSiteKey: z.string().optional(),
|
||||
TurnstileSecretKey: z.string().optional(),
|
||||
})
|
||||
|
||||
type BotProtectionFormValues = z.infer<typeof botProtectionSchema>
|
||||
|
||||
type BotProtectionSectionProps = {
|
||||
defaultValues: BotProtectionFormValues
|
||||
}
|
||||
|
||||
export function BotProtectionSection({
|
||||
defaultValues,
|
||||
}: BotProtectionSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const form = useForm<BotProtectionFormValues>({
|
||||
resolver: zodResolver(botProtectionSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues)
|
||||
}, [defaultValues, form])
|
||||
|
||||
const onSubmit = async (data: BotProtectionFormValues) => {
|
||||
const updates = Object.entries(data).filter(
|
||||
([key, value]) =>
|
||||
value !== defaultValues[key as keyof BotProtectionFormValues]
|
||||
)
|
||||
|
||||
for (const [key, value] of updates) {
|
||||
await updateOption.mutateAsync({ key, value: value ?? '' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('Bot Protection')}>
|
||||
<Form {...form}>
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSubmit)} autoComplete='off'>
|
||||
<SettingsPageFormActions
|
||||
onSave={form.handleSubmit(onSubmit)}
|
||||
isSaving={updateOption.isPending}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='TurnstileCheckEnabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Enable Turnstile')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Protect login and registration with Cloudflare Turnstile'
|
||||
)}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='TurnstileSiteKey'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Site Key')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Your Turnstile site key')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='TurnstileSecretKey'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Secret Key')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('Your Turnstile secret key')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsForm>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
import type { CustomOAuthProvider, DiscoveryResponse } from './types'
|
||||
|
||||
// ============================================================================
|
||||
// Response Types
|
||||
// ============================================================================
|
||||
|
||||
interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Custom OAuth Provider APIs
|
||||
// ============================================================================
|
||||
|
||||
export async function getCustomOAuthProviders(): Promise<
|
||||
ApiResponse<CustomOAuthProvider[]>
|
||||
> {
|
||||
const res = await api.get('/api/custom-oauth-provider/')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getCustomOAuthProvider(
|
||||
id: number
|
||||
): Promise<ApiResponse<CustomOAuthProvider>> {
|
||||
const res = await api.get(`/api/custom-oauth-provider/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createCustomOAuthProvider(
|
||||
data: Omit<CustomOAuthProvider, 'id'>
|
||||
): Promise<ApiResponse<CustomOAuthProvider>> {
|
||||
const res = await api.post('/api/custom-oauth-provider/', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateCustomOAuthProvider(
|
||||
id: number,
|
||||
data: Partial<CustomOAuthProvider>
|
||||
): Promise<ApiResponse<CustomOAuthProvider>> {
|
||||
const res = await api.put(`/api/custom-oauth-provider/${id}`, data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteCustomOAuthProvider(
|
||||
id: number
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.delete(`/api/custom-oauth-provider/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function discoverOIDCEndpoints(
|
||||
wellKnownUrl: string
|
||||
): Promise<DiscoveryResponse> {
|
||||
const res = await api.post('/api/custom-oauth-provider/discovery', {
|
||||
well_known_url: wellKnownUrl,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Search } from 'lucide-react'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { useDiscoverEndpoints } from '../hooks/use-custom-oauth-mutations'
|
||||
import type { CustomOAuthFormValues } from '../types'
|
||||
|
||||
type DiscoveryButtonProps = {
|
||||
form: UseFormReturn<CustomOAuthFormValues>
|
||||
}
|
||||
|
||||
export function DiscoveryButton(props: DiscoveryButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const discover = useDiscoverEndpoints()
|
||||
|
||||
const handleDiscover = async () => {
|
||||
const wellKnown = props.form.getValues('well_known')
|
||||
if (!wellKnown) {
|
||||
toast.error(t('Please enter a Well-Known URL first'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!wellKnown.startsWith('http://') && !wellKnown.startsWith('https://')) {
|
||||
toast.error(t('Well-Known URL must start with http:// or https://'))
|
||||
return
|
||||
}
|
||||
|
||||
const res = await discover.mutateAsync(wellKnown)
|
||||
if (res.success && res.data?.discovery) {
|
||||
const disc = res.data.discovery
|
||||
if (disc.authorization_endpoint) {
|
||||
props.form.setValue(
|
||||
'authorization_endpoint',
|
||||
disc.authorization_endpoint,
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
if (disc.token_endpoint) {
|
||||
props.form.setValue('token_endpoint', disc.token_endpoint, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
if (disc.userinfo_endpoint) {
|
||||
props.form.setValue('user_info_endpoint', disc.userinfo_endpoint, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
if (disc.scopes_supported && disc.scopes_supported.length > 0) {
|
||||
const currentScopes = props.form.getValues('scopes')
|
||||
if (!currentScopes) {
|
||||
const defaultScopes = disc.scopes_supported
|
||||
.filter((s) => ['openid', 'profile', 'email'].includes(s))
|
||||
.join(' ')
|
||||
if (defaultScopes) {
|
||||
props.form.setValue('scopes', defaultScopes, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleDiscover}
|
||||
disabled={discover.isPending}
|
||||
>
|
||||
<Search className='mr-1.5 h-3.5 w-3.5' />
|
||||
{discover.isPending ? t('Discovering...') : t('Auto-discover')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
import { SettingsControlGroup } from '../../../components/settings-form-layout'
|
||||
import { OAUTH_PRESETS, type CustomOAuthFormValues } from '../types'
|
||||
|
||||
type PresetSelectorProps = {
|
||||
form: UseFormReturn<CustomOAuthFormValues>
|
||||
}
|
||||
|
||||
export function PresetSelector(props: PresetSelectorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('')
|
||||
const [baseUrl, setBaseUrl] = useState<string>('')
|
||||
|
||||
const handlePresetChange = (presetKey: string) => {
|
||||
setSelectedPreset(presetKey)
|
||||
const preset = OAUTH_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return
|
||||
|
||||
// Auto-fill name, slug, icon, and field mappings immediately
|
||||
props.form.setValue('name', preset.name, { shouldDirty: true })
|
||||
props.form.setValue(
|
||||
'slug',
|
||||
presetKey.toLowerCase().replaceAll(/\s+/g, '-'),
|
||||
{
|
||||
shouldDirty: true,
|
||||
}
|
||||
)
|
||||
props.form.setValue('icon', preset.icon, { shouldDirty: true })
|
||||
props.form.setValue('scopes', preset.scopes, { shouldDirty: true })
|
||||
props.form.setValue('user_id_field', preset.user_id_field, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
props.form.setValue('username_field', preset.username_field, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
props.form.setValue('display_name_field', preset.display_name_field, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
props.form.setValue('email_field', preset.email_field, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
|
||||
// Apply base URL if already entered
|
||||
if (baseUrl) {
|
||||
applyEndpoints(preset, baseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBaseUrlChange = (url: string) => {
|
||||
setBaseUrl(url)
|
||||
if (!selectedPreset) return
|
||||
|
||||
const preset = OAUTH_PRESETS.find((p) => p.key === selectedPreset)
|
||||
if (!preset) return
|
||||
|
||||
applyEndpoints(preset, url)
|
||||
}
|
||||
|
||||
const applyEndpoints = (
|
||||
preset: (typeof OAUTH_PRESETS)[number],
|
||||
url: string
|
||||
) => {
|
||||
const cleanUrl = url.replace(/\/+$/, '')
|
||||
props.form.setValue(
|
||||
'authorization_endpoint',
|
||||
cleanUrl + preset.authorization_endpoint,
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
props.form.setValue('token_endpoint', cleanUrl + preset.token_endpoint, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
props.form.setValue(
|
||||
'user_info_endpoint',
|
||||
cleanUrl + preset.user_info_endpoint,
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsControlGroup className='space-y-3 border-dashed'>
|
||||
<p className='text-sm font-medium'>{t('Quick Setup from Preset')}</p>
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>{t('Preset Template')}</Label>
|
||||
<Select
|
||||
items={OAUTH_PRESETS.map((preset) => ({
|
||||
value: preset.key,
|
||||
label: preset.name,
|
||||
}))}
|
||||
value={selectedPreset}
|
||||
onValueChange={(v) => v !== null && handlePresetChange(v)}
|
||||
>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder={t('Select a preset...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{OAUTH_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.key} value={preset.key}>
|
||||
{preset.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>{t('Base URL')}</Label>
|
||||
<Input
|
||||
placeholder={t('https://your-server.example.com')}
|
||||
value={baseUrl}
|
||||
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsControlGroup>
|
||||
)
|
||||
}
|
||||
+648
@@ -0,0 +1,648 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useEffect } from 'react'
|
||||
import { type Resolver, useForm, useWatch } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import {
|
||||
SettingsForm,
|
||||
SettingsSwitchContent,
|
||||
SettingsSwitchItem,
|
||||
} from '../../../components/settings-form-layout'
|
||||
import { buildOAuthCallbackUrl } from '../../oauth-callback-url'
|
||||
import {
|
||||
useCreateProvider,
|
||||
useUpdateProvider,
|
||||
} from '../hooks/use-custom-oauth-mutations'
|
||||
import {
|
||||
customOAuthFormSchema,
|
||||
AUTH_STYLE_OPTIONS,
|
||||
type CustomOAuthProvider,
|
||||
type CustomOAuthFormValues,
|
||||
} from '../types'
|
||||
import { DiscoveryButton } from './discovery-button'
|
||||
import { PresetSelector } from './preset-selector'
|
||||
|
||||
type ProviderFormDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
provider?: CustomOAuthProvider | null
|
||||
serverAddress: string
|
||||
}
|
||||
|
||||
const PROVIDER_FORM_ID = 'custom-oauth-provider-form'
|
||||
|
||||
export function ProviderFormDialog(props: ProviderFormDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const isEditing = !!props.provider
|
||||
const createProvider = useCreateProvider()
|
||||
const updateProvider = useUpdateProvider()
|
||||
|
||||
const form = useForm<CustomOAuthFormValues>({
|
||||
resolver: zodResolver(
|
||||
customOAuthFormSchema
|
||||
) as unknown as Resolver<CustomOAuthFormValues>,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
slug: '',
|
||||
icon: '',
|
||||
enabled: true,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
authorization_endpoint: '',
|
||||
token_endpoint: '',
|
||||
user_info_endpoint: '',
|
||||
scopes: '',
|
||||
user_id_field: '',
|
||||
username_field: '',
|
||||
display_name_field: '',
|
||||
email_field: '',
|
||||
well_known: '',
|
||||
auth_style: 0,
|
||||
access_policy: '',
|
||||
access_denied_message: '',
|
||||
},
|
||||
})
|
||||
const watchedSlug = useWatch({ control: form.control, name: 'slug' })
|
||||
const callbackPath = watchedSlug?.trim() || '{slug}'
|
||||
const callbackUrl = buildOAuthCallbackUrl(
|
||||
props.serverAddress,
|
||||
callbackPath,
|
||||
t('Site URL')
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open && props.provider) {
|
||||
form.reset({
|
||||
name: props.provider.name,
|
||||
slug: props.provider.slug,
|
||||
icon: props.provider.icon || '',
|
||||
enabled: props.provider.enabled,
|
||||
client_id: props.provider.client_id,
|
||||
client_secret: props.provider.client_secret || '',
|
||||
authorization_endpoint: props.provider.authorization_endpoint,
|
||||
token_endpoint: props.provider.token_endpoint,
|
||||
user_info_endpoint: props.provider.user_info_endpoint,
|
||||
scopes: props.provider.scopes || '',
|
||||
user_id_field: props.provider.user_id_field,
|
||||
username_field: props.provider.username_field || '',
|
||||
display_name_field: props.provider.display_name_field || '',
|
||||
email_field: props.provider.email_field || '',
|
||||
well_known: props.provider.well_known || '',
|
||||
auth_style: props.provider.auth_style ?? 0,
|
||||
access_policy: props.provider.access_policy || '',
|
||||
access_denied_message: props.provider.access_denied_message || '',
|
||||
})
|
||||
} else if (props.open && !props.provider) {
|
||||
form.reset({
|
||||
name: '',
|
||||
slug: '',
|
||||
icon: '',
|
||||
enabled: true,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
authorization_endpoint: '',
|
||||
token_endpoint: '',
|
||||
user_info_endpoint: '',
|
||||
scopes: '',
|
||||
user_id_field: '',
|
||||
username_field: '',
|
||||
display_name_field: '',
|
||||
email_field: '',
|
||||
well_known: '',
|
||||
auth_style: 0,
|
||||
access_policy: '',
|
||||
access_denied_message: '',
|
||||
})
|
||||
}
|
||||
}, [props.open, props.provider, form])
|
||||
|
||||
const onSubmit = async (values: CustomOAuthFormValues) => {
|
||||
if (isEditing && props.provider) {
|
||||
const res = await updateProvider.mutateAsync({
|
||||
id: props.provider.id,
|
||||
data: values,
|
||||
})
|
||||
if (res.success) {
|
||||
props.onOpenChange(false)
|
||||
}
|
||||
} else {
|
||||
const res = await createProvider.mutateAsync(
|
||||
values as Omit<CustomOAuthProvider, 'id'>
|
||||
)
|
||||
if (res.success) {
|
||||
props.onOpenChange(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createProvider.isPending || updateProvider.isPending
|
||||
let submitLabel = t('Create Provider')
|
||||
if (isPending) {
|
||||
submitLabel = t('Saving...')
|
||||
} else if (isEditing) {
|
||||
submitLabel = t('Update Provider')
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={isEditing ? t('Edit OAuth Provider') : t('Add OAuth Provider')}
|
||||
description={
|
||||
isEditing
|
||||
? t('Update the configuration for this custom OAuth provider.')
|
||||
: t('Configure a new custom OAuth provider for user authentication.')
|
||||
}
|
||||
contentClassName='max-h-[85vh] overflow-y-auto sm:max-w-2xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={PROVIDER_FORM_ID} disabled={isPending}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<SettingsForm
|
||||
id={PROVIDER_FORM_ID}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
{/* Preset Selector (only for creating) */}
|
||||
{!isEditing && <PresetSelector form={form} />}
|
||||
|
||||
<Alert>
|
||||
<AlertTitle>{t('OAuth callback URL')}</AlertTitle>
|
||||
<AlertDescription className='space-y-3 text-sm'>
|
||||
<p>
|
||||
{t(
|
||||
'This callback URL updates from the slug field and is the value to register with your provider.'
|
||||
)}
|
||||
</p>
|
||||
<div className='flex min-w-0 flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-muted-foreground shrink-0'>
|
||||
{t('Authorization callback URL')}
|
||||
</span>
|
||||
<span className='flex min-w-0 items-center gap-2'>
|
||||
<code className='bg-muted text-foreground min-w-0 rounded px-1.5 py-0.5 text-xs break-all'>
|
||||
{callbackUrl}
|
||||
</code>
|
||||
<CopyButton
|
||||
value={callbackUrl}
|
||||
size='icon'
|
||||
className='size-7'
|
||||
tooltip={t('Copy callback URL')}
|
||||
aria-label={t('Copy callback URL')}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Basic Info')}</h4>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Enabled')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to sign in with this provider')}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Provider Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g. My GitLab')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='slug'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Slug')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g. my-gitlab')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Used in URLs and API routes')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='icon'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Icon identifier (e.g. github, gitlab)')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Optional icon identifier for the login button')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Credentials */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Credentials')}</h4>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='client_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('OAuth Client ID')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='client_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client Secret')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('OAuth Client Secret')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='auth_style'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Auth Style')}</FormLabel>
|
||||
<Select
|
||||
items={AUTH_STYLE_OPTIONS.map((option) => ({
|
||||
value: String(option.value),
|
||||
label: t(option.labelKey),
|
||||
}))}
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(Number(val))}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{AUTH_STYLE_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
>
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t('How client credentials are sent to the token endpoint')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Endpoints */}
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h4 className='text-sm font-medium'>{t('Endpoints')}</h4>
|
||||
<DiscoveryButton form={form} />
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='well_known'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Well-Known URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'https://provider.com/.well-known/openid-configuration'
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'OIDC discovery URL. Click "Auto-discover" to fetch endpoints automatically.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='authorization_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Authorization Endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='https://provider.com/oauth/authorize'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='token_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Token Endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='https://provider.com/oauth/token'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='user_info_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('User Info Endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='https://provider.com/api/user'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='scopes'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Scopes')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g. openid profile email')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Space-separated OAuth scopes')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Field Mapping */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Field Mapping')}</h4>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).'
|
||||
)}
|
||||
</FormDescription>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='user_id_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('User ID Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='id' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='login' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='display_name_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Display Name Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Email Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='email' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Advanced */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Advanced')}</h4>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='access_policy'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Access Policy (JSON)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Optional JSON policy to restrict access based on user info fields'
|
||||
)}
|
||||
className='min-h-[80px] font-mono text-xs'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON-based access control rules. Leave empty to allow all users.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='access_denied_message'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Access Denied Message')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'Custom message shown when access is denied'
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsForm>
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { BadgeCell } from '@/components/data-table/core/badge-cell'
|
||||
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
|
||||
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { useDeleteProvider } from '../hooks/use-custom-oauth-mutations'
|
||||
import type { CustomOAuthProvider } from '../types'
|
||||
|
||||
type ProviderTableProps = {
|
||||
providers: CustomOAuthProvider[]
|
||||
onEdit: (provider: CustomOAuthProvider) => void
|
||||
onCreate: () => void
|
||||
}
|
||||
|
||||
export function ProviderTable(props: ProviderTableProps) {
|
||||
const { t } = useTranslation()
|
||||
const deleteProvider = useDeleteProvider()
|
||||
const [deleteTarget, setDeleteTarget] = useState<CustomOAuthProvider | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
await deleteProvider.mutateAsync(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center justify-between'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Manage custom OAuth providers for user authentication')}
|
||||
</p>
|
||||
<Button size='sm' onClick={props.onCreate}>
|
||||
<Plus className='mr-1.5 h-4 w-4' />
|
||||
{t('Add Provider')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<StaticDataTable
|
||||
data={props.providers}
|
||||
getRowKey={(provider) => provider.id}
|
||||
emptyClassName='text-sm'
|
||||
emptyContent={t('No custom OAuth providers configured yet.')}
|
||||
columns={[
|
||||
{
|
||||
id: 'icon',
|
||||
header: t('Icon'),
|
||||
cell: (provider) =>
|
||||
provider.icon ? (
|
||||
<span className='text-lg'>{provider.icon}</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>--</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: t('Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (provider) => provider.name,
|
||||
},
|
||||
{
|
||||
id: 'slug',
|
||||
header: t('Slug'),
|
||||
cell: (provider) => (
|
||||
<BadgeCell>
|
||||
<StatusBadge
|
||||
label={provider.slug}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</BadgeCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('Status'),
|
||||
cell: (provider) => (
|
||||
<BadgeCell>
|
||||
<StatusBadge
|
||||
label={provider.enabled ? t('Enabled') : t('Disabled')}
|
||||
variant={provider.enabled ? 'success' : 'neutral'}
|
||||
copyable={false}
|
||||
/>
|
||||
</BadgeCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'client-id',
|
||||
header: t('Client ID'),
|
||||
cellClassName:
|
||||
'text-muted-foreground max-w-[120px] truncate font-mono',
|
||||
cell: (provider) => provider.client_id,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (provider) => (
|
||||
<StaticRowActions
|
||||
editLabel={t('Edit')}
|
||||
deleteLabel={t('Delete')}
|
||||
menuLabel={t('Open menu')}
|
||||
onEdit={() => props.onEdit(provider)}
|
||||
onDelete={() => setDeleteTarget(provider)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
title={t('Delete Provider')}
|
||||
desc={t(
|
||||
'Are you sure you want to delete "{{name}}"? Users who authenticated with this provider will no longer be able to log in.',
|
||||
{ name: deleteTarget?.name || '' }
|
||||
)}
|
||||
confirmText={t('Delete')}
|
||||
destructive
|
||||
handleConfirm={handleDelete}
|
||||
isLoading={deleteProvider.isPending}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
|
||||
import { SettingsSection } from '../../components/settings-section'
|
||||
import { buildOAuthCallbackUrl } from '../oauth-callback-url'
|
||||
import { ProviderFormDialog } from './components/provider-form-dialog'
|
||||
import { ProviderTable } from './components/provider-table'
|
||||
import { useCustomOAuthProviders } from './hooks/use-custom-oauth-providers'
|
||||
import type { CustomOAuthProvider } from './types'
|
||||
|
||||
type CustomOAuthSectionProps = {
|
||||
serverAddress: string
|
||||
}
|
||||
|
||||
export function CustomOAuthSection(props: CustomOAuthSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const { data: providers = [], isLoading } = useCustomOAuthProviders()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingProvider, setEditingProvider] =
|
||||
useState<CustomOAuthProvider | null>(null)
|
||||
const callbackFormat = buildOAuthCallbackUrl(
|
||||
props.serverAddress,
|
||||
'{slug}',
|
||||
t('Site URL')
|
||||
)
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingProvider(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleEdit = (provider: CustomOAuthProvider) => {
|
||||
setEditingProvider(provider)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDialogChange = (open: boolean) => {
|
||||
setDialogOpen(open)
|
||||
if (!open) {
|
||||
setEditingProvider(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsSection title={t('Custom OAuth Providers')}>
|
||||
<div className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{t('Loading...')}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('Custom OAuth Providers')}>
|
||||
<Alert>
|
||||
<AlertTitle>{t('Callback URL format')}</AlertTitle>
|
||||
<AlertDescription className='space-y-3 text-sm'>
|
||||
<p>
|
||||
{t(
|
||||
'Use this callback URL pattern when registering a custom OAuth provider.'
|
||||
)}
|
||||
</p>
|
||||
<div className='flex min-w-0 flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-muted-foreground shrink-0'>
|
||||
{t('OAuth callback URL')}
|
||||
</span>
|
||||
<span className='flex min-w-0 items-center gap-2'>
|
||||
<code className='bg-muted text-foreground min-w-0 rounded px-1.5 py-0.5 text-xs break-all'>
|
||||
{callbackFormat}
|
||||
</code>
|
||||
<CopyButton
|
||||
value={callbackFormat}
|
||||
size='icon'
|
||||
className='size-7'
|
||||
tooltip={t('Copy callback URL')}
|
||||
aria-label={t('Copy callback URL')}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ProviderTable
|
||||
providers={providers}
|
||||
onEdit={handleEdit}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
|
||||
<ProviderFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={handleDialogChange}
|
||||
provider={editingProvider}
|
||||
serverAddress={props.serverAddress}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
createCustomOAuthProvider,
|
||||
updateCustomOAuthProvider,
|
||||
deleteCustomOAuthProvider,
|
||||
discoverOIDCEndpoints,
|
||||
} from '../api'
|
||||
import type { CustomOAuthProvider, DiscoveryResponse } from '../types'
|
||||
|
||||
function useInvalidateOnSuccess() {
|
||||
const queryClient = useQueryClient()
|
||||
return {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['custom-oauth-providers'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['status'] })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useCreateProvider() {
|
||||
const invalidate = useInvalidateOnSuccess()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: Omit<CustomOAuthProvider, 'id'>) =>
|
||||
createCustomOAuthProvider(data),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
toast.success(i18next.t('Provider created successfully'))
|
||||
invalidate.onSuccess()
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || i18next.t('Failed to create provider'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateProvider() {
|
||||
const invalidate = useInvalidateOnSuccess()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: number
|
||||
data: Partial<CustomOAuthProvider>
|
||||
}) => updateCustomOAuthProvider(id, data),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
toast.success(i18next.t('Provider updated successfully'))
|
||||
invalidate.onSuccess()
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || i18next.t('Failed to update provider'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteProvider() {
|
||||
const invalidate = useInvalidateOnSuccess()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteCustomOAuthProvider(id),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
toast.success(i18next.t('Provider deleted successfully'))
|
||||
invalidate.onSuccess()
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || i18next.t('Failed to delete provider'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDiscoverEndpoints() {
|
||||
return useMutation({
|
||||
mutationFn: (wellKnownUrl: string) => discoverOIDCEndpoints(wellKnownUrl),
|
||||
onSuccess: (res: DiscoveryResponse) => {
|
||||
if (res.success) {
|
||||
toast.success(i18next.t('OIDC endpoints discovered successfully'))
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(
|
||||
error.message || i18next.t('Failed to discover OIDC endpoints')
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { getCustomOAuthProviders } from '../api'
|
||||
|
||||
export function useCustomOAuthProviders() {
|
||||
return useQuery({
|
||||
queryKey: ['custom-oauth-providers'],
|
||||
queryFn: async () => {
|
||||
const res = await getCustomOAuthProviders()
|
||||
return res.data ?? []
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as z from 'zod'
|
||||
|
||||
// ============================================================================
|
||||
// Custom OAuth Provider Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CustomOAuthProvider {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
client_id: string
|
||||
client_secret: string
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
user_info_endpoint: string
|
||||
scopes: string
|
||||
user_id_field: string
|
||||
username_field: string
|
||||
display_name_field: string
|
||||
email_field: string
|
||||
well_known: string
|
||||
auth_style: number // 0=auto, 1=params, 2=header
|
||||
access_policy: string
|
||||
access_denied_message: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Schema
|
||||
// ============================================================================
|
||||
|
||||
export const customOAuthFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, 'Slug is required')
|
||||
.regex(
|
||||
/^[a-z0-9-]+$/,
|
||||
'Slug must only contain lowercase letters, numbers, and hyphens'
|
||||
),
|
||||
icon: z.string().optional().default(''),
|
||||
enabled: z.boolean().default(true),
|
||||
client_id: z.string().min(1, 'Client ID is required'),
|
||||
client_secret: z.string().optional().default(''),
|
||||
authorization_endpoint: z
|
||||
.string()
|
||||
.min(1, 'Authorization endpoint is required'),
|
||||
token_endpoint: z.string().min(1, 'Token endpoint is required'),
|
||||
user_info_endpoint: z.string().min(1, 'User info endpoint is required'),
|
||||
scopes: z.string().optional().default(''),
|
||||
user_id_field: z.string().min(1, 'User ID field is required'),
|
||||
username_field: z.string().optional().default(''),
|
||||
display_name_field: z.string().optional().default(''),
|
||||
email_field: z.string().optional().default(''),
|
||||
well_known: z.string().optional().default(''),
|
||||
auth_style: z.number().int().min(0).max(2).default(0),
|
||||
access_policy: z.string().optional().default(''),
|
||||
access_denied_message: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export type CustomOAuthFormValues = z.infer<typeof customOAuthFormSchema>
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Discovery
|
||||
// ============================================================================
|
||||
|
||||
export interface DiscoveryResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
well_known_url?: string
|
||||
discovery?: {
|
||||
authorization_endpoint?: string
|
||||
token_endpoint?: string
|
||||
userinfo_endpoint?: string
|
||||
scopes_supported?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Preset Templates
|
||||
// ============================================================================
|
||||
|
||||
export interface OAuthPreset {
|
||||
key: string
|
||||
name: string
|
||||
icon: string
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
user_info_endpoint: string
|
||||
scopes: string
|
||||
user_id_field: string
|
||||
username_field: string
|
||||
display_name_field: string
|
||||
email_field: string
|
||||
needsBaseUrl: boolean
|
||||
}
|
||||
|
||||
export const OAUTH_PRESETS: OAuthPreset[] = [
|
||||
{
|
||||
key: 'github-enterprise',
|
||||
name: 'GitHub Enterprise',
|
||||
icon: 'github',
|
||||
authorization_endpoint: '/login/oauth/authorize',
|
||||
token_endpoint: '/login/oauth/access_token',
|
||||
user_info_endpoint: '/api/v3/user',
|
||||
scopes: 'user:email',
|
||||
user_id_field: 'id',
|
||||
username_field: 'login',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'gitlab',
|
||||
name: 'GitLab',
|
||||
icon: 'gitlab',
|
||||
authorization_endpoint: '/oauth/authorize',
|
||||
token_endpoint: '/oauth/token',
|
||||
user_info_endpoint: '/api/v4/user',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'id',
|
||||
username_field: 'username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'gitea',
|
||||
name: 'Gitea',
|
||||
icon: 'gitea',
|
||||
authorization_endpoint: '/login/oauth/authorize',
|
||||
token_endpoint: '/login/oauth/access_token',
|
||||
user_info_endpoint: '/api/v1/user',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'id',
|
||||
username_field: 'login',
|
||||
display_name_field: 'full_name',
|
||||
email_field: 'email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'nextcloud',
|
||||
name: 'Nextcloud',
|
||||
icon: 'nextcloud',
|
||||
authorization_endpoint: '/apps/oauth2/authorize',
|
||||
token_endpoint: '/apps/oauth2/api/v1/token',
|
||||
user_info_endpoint: '/ocs/v2.php/cloud/user?format=json',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'ocs.data.id',
|
||||
username_field: 'ocs.data.id',
|
||||
display_name_field: 'ocs.data.displayname',
|
||||
email_field: 'ocs.data.email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'keycloak',
|
||||
name: 'Keycloak',
|
||||
icon: 'keycloak',
|
||||
authorization_endpoint: '/realms/{realm}/protocol/openid-connect/auth',
|
||||
token_endpoint: '/realms/{realm}/protocol/openid-connect/token',
|
||||
user_info_endpoint: '/realms/{realm}/protocol/openid-connect/userinfo',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'sub',
|
||||
username_field: 'preferred_username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'authentik',
|
||||
name: 'Authentik',
|
||||
icon: 'authentik',
|
||||
authorization_endpoint: '/application/o/authorize/',
|
||||
token_endpoint: '/application/o/token/',
|
||||
user_info_endpoint: '/application/o/userinfo/',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'sub',
|
||||
username_field: 'preferred_username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
{
|
||||
key: 'ory',
|
||||
name: 'ORY Hydra',
|
||||
icon: 'openid',
|
||||
authorization_endpoint: '/oauth2/auth',
|
||||
token_endpoint: '/oauth2/token',
|
||||
user_info_endpoint: '/userinfo',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'sub',
|
||||
username_field: 'preferred_username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
needsBaseUrl: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const AUTH_STYLE_OPTIONS = [
|
||||
{ value: 0, labelKey: 'Auto Detect' },
|
||||
{ value: 1, labelKey: 'Params (in body)' },
|
||||
{ value: 2, labelKey: 'Header (Basic Auth)' },
|
||||
] as const
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { SettingsPage } from '../components/settings-page'
|
||||
import type { AuthSettings } from '../types'
|
||||
import {
|
||||
AUTH_DEFAULT_SECTION,
|
||||
getAuthSectionContent,
|
||||
getAuthSectionMeta,
|
||||
} from './section-registry.tsx'
|
||||
|
||||
const defaultAuthSettings: AuthSettings = {
|
||||
PasswordLoginEnabled: true,
|
||||
PasswordRegisterEnabled: true,
|
||||
EmailVerificationEnabled: false,
|
||||
RegisterEnabled: true,
|
||||
EmailDomainRestrictionEnabled: false,
|
||||
EmailAliasRestrictionEnabled: false,
|
||||
EmailDomainWhitelist: '',
|
||||
ServerAddress: '',
|
||||
GitHubOAuthEnabled: false,
|
||||
GitHubClientId: '',
|
||||
GitHubClientSecret: '',
|
||||
'discord.enabled': false,
|
||||
'discord.client_id': '',
|
||||
'discord.client_secret': '',
|
||||
'oidc.enabled': false,
|
||||
'oidc.client_id': '',
|
||||
'oidc.client_secret': '',
|
||||
'oidc.well_known': '',
|
||||
'oidc.authorization_endpoint': '',
|
||||
'oidc.token_endpoint': '',
|
||||
'oidc.user_info_endpoint': '',
|
||||
TelegramOAuthEnabled: false,
|
||||
TelegramBotToken: '',
|
||||
TelegramBotName: '',
|
||||
LinuxDOOAuthEnabled: false,
|
||||
LinuxDOClientId: '',
|
||||
LinuxDOClientSecret: '',
|
||||
LinuxDOMinimumTrustLevel: '0',
|
||||
WeChatAuthEnabled: false,
|
||||
WeChatServerAddress: '',
|
||||
WeChatServerToken: '',
|
||||
WeChatAccountQRCodeImageURL: '',
|
||||
TurnstileCheckEnabled: false,
|
||||
TurnstileSiteKey: '',
|
||||
TurnstileSecretKey: '',
|
||||
'passkey.enabled': false,
|
||||
'passkey.rp_display_name': '',
|
||||
'passkey.rp_id': '',
|
||||
'passkey.origins': '',
|
||||
'passkey.allow_insecure_origin': false,
|
||||
'passkey.user_verification': 'preferred',
|
||||
'passkey.attachment_preference': '',
|
||||
}
|
||||
|
||||
export function AuthSettings() {
|
||||
return (
|
||||
<SettingsPage
|
||||
routePath='/_authenticated/system-settings/auth/$section'
|
||||
defaultSettings={defaultAuthSettings}
|
||||
defaultSection={AUTH_DEFAULT_SECTION}
|
||||
getSectionContent={getAuthSectionContent}
|
||||
getSectionMeta={getAuthSectionMeta}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function resolveOAuthSiteUrl(
|
||||
serverAddress: string,
|
||||
fallback: string
|
||||
): string {
|
||||
const normalized = serverAddress.trim().replace(/\/+$/, '')
|
||||
return normalized || fallback
|
||||
}
|
||||
|
||||
export function buildOAuthCallbackUrl(
|
||||
serverAddress: string,
|
||||
callbackPath: string,
|
||||
fallback: string
|
||||
): string {
|
||||
const siteUrl = resolveOAuthSiteUrl(serverAddress, fallback)
|
||||
return `${siteUrl}/oauth/${callbackPath.replace(/^\/+/, '')}`
|
||||
}
|
||||
+1074
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import * as z from 'zod'
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
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'
|
||||
|
||||
type AttachmentPreference = '' | 'platform' | 'cross-platform'
|
||||
type AttachmentSelectValue = 'none' | 'platform' | 'cross-platform'
|
||||
|
||||
/**
|
||||
* Use a nested object so the dotted FormField `name` props line up with
|
||||
* react-hook-form's path semantics. Flat keys with dots cause the form state
|
||||
* to silently diverge from what zod validates on submit.
|
||||
*/
|
||||
const passkeySchema = z.object({
|
||||
passkey: z.object({
|
||||
enabled: z.boolean(),
|
||||
rp_display_name: z.string(),
|
||||
rp_id: z.string(),
|
||||
origins: z.string(),
|
||||
allow_insecure_origin: z.boolean(),
|
||||
user_verification: z.enum(['required', 'preferred', 'discouraged']),
|
||||
attachment_preference: z.enum(['none', 'platform', 'cross-platform']),
|
||||
}),
|
||||
})
|
||||
|
||||
type PasskeyFormInput = z.input<typeof passkeySchema>
|
||||
type PasskeyFormValues = z.output<typeof passkeySchema>
|
||||
|
||||
type FlatPasskeyDefaults = {
|
||||
'passkey.enabled': boolean
|
||||
'passkey.rp_display_name': string
|
||||
'passkey.rp_id': string
|
||||
'passkey.origins': string
|
||||
'passkey.allow_insecure_origin': boolean
|
||||
'passkey.user_verification': 'required' | 'preferred' | 'discouraged'
|
||||
'passkey.attachment_preference': AttachmentPreference
|
||||
}
|
||||
|
||||
const toAttachmentSelectValue = (
|
||||
value: AttachmentPreference
|
||||
): AttachmentSelectValue => (value === '' ? 'none' : value)
|
||||
|
||||
const fromAttachmentSelectValue = (
|
||||
value: AttachmentSelectValue
|
||||
): AttachmentPreference => (value === 'none' ? '' : value)
|
||||
|
||||
const buildFormDefaults = (
|
||||
defaults: FlatPasskeyDefaults
|
||||
): PasskeyFormInput => ({
|
||||
passkey: {
|
||||
enabled: defaults['passkey.enabled'],
|
||||
rp_display_name: defaults['passkey.rp_display_name'] ?? '',
|
||||
rp_id: defaults['passkey.rp_id'] ?? '',
|
||||
origins: (defaults['passkey.origins'] ?? '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
allow_insecure_origin: defaults['passkey.allow_insecure_origin'],
|
||||
user_verification: defaults['passkey.user_verification'],
|
||||
attachment_preference: toAttachmentSelectValue(
|
||||
defaults['passkey.attachment_preference']
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
const normalizeFormValues = (
|
||||
values: PasskeyFormValues
|
||||
): FlatPasskeyDefaults => ({
|
||||
'passkey.enabled': values.passkey.enabled,
|
||||
'passkey.rp_display_name': values.passkey.rp_display_name,
|
||||
'passkey.rp_id': values.passkey.rp_id,
|
||||
'passkey.origins': values.passkey.origins
|
||||
.split('\n')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
.join(','),
|
||||
'passkey.allow_insecure_origin': values.passkey.allow_insecure_origin,
|
||||
'passkey.user_verification': values.passkey.user_verification,
|
||||
'passkey.attachment_preference': fromAttachmentSelectValue(
|
||||
values.passkey.attachment_preference
|
||||
),
|
||||
})
|
||||
|
||||
interface PasskeySectionProps {
|
||||
defaultValues: FlatPasskeyDefaults
|
||||
}
|
||||
|
||||
export function PasskeySection(props: PasskeySectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
|
||||
const formDefaults = useMemo(
|
||||
() => buildFormDefaults(props.defaultValues),
|
||||
[props.defaultValues]
|
||||
)
|
||||
|
||||
const form = useForm<PasskeyFormInput, unknown, PasskeyFormValues>({
|
||||
resolver: zodResolver(passkeySchema),
|
||||
defaultValues: formDefaults,
|
||||
})
|
||||
|
||||
const baselineRef = useRef<FlatPasskeyDefaults>(props.defaultValues)
|
||||
const baselineSerializedRef = useRef<string>(
|
||||
JSON.stringify(props.defaultValues)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const serialized = JSON.stringify(props.defaultValues)
|
||||
if (serialized === baselineSerializedRef.current) return
|
||||
baselineRef.current = props.defaultValues
|
||||
baselineSerializedRef.current = serialized
|
||||
form.reset(buildFormDefaults(props.defaultValues))
|
||||
}, [props.defaultValues, form])
|
||||
|
||||
const onSubmit = async (values: PasskeyFormValues) => {
|
||||
const normalized = normalizeFormValues(values)
|
||||
const changedKeys = (
|
||||
Object.keys(normalized) as Array<keyof FlatPasskeyDefaults>
|
||||
).filter((key) => normalized[key] !== baselineRef.current[key])
|
||||
|
||||
if (changedKeys.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const key of changedKeys) {
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value: normalized[key],
|
||||
})
|
||||
}
|
||||
|
||||
baselineRef.current = normalized
|
||||
baselineSerializedRef.current = JSON.stringify(normalized)
|
||||
form.reset(buildFormDefaults(normalized))
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('Passkey Authentication')}>
|
||||
<Form {...form}>
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SettingsPageFormActions
|
||||
onSave={form.handleSubmit(onSubmit)}
|
||||
isSaving={updateOption.isPending}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.enabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Enable Passkey')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Allow users to register and sign in with Passkey (WebAuthn)'
|
||||
)}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.rp_display_name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Relying Party Display Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g. New API Console')}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Human-readable name shown to users during Passkey prompts.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.rp_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Relying Party ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g. example.com')}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'The effective domain for Passkey registration. Must match the current domain or be its parent domain.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.user_verification'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('User Verification')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
items={[
|
||||
{ value: 'required', label: t('Required') },
|
||||
{ value: 'preferred', label: t('Recommended') },
|
||||
{ value: 'discouraged', label: t('Discouraged') },
|
||||
]}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select requirement')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
<SelectItem value='required'>
|
||||
{t('Required')}
|
||||
</SelectItem>
|
||||
<SelectItem value='preferred'>
|
||||
{t('Recommended')}
|
||||
</SelectItem>
|
||||
<SelectItem value='discouraged'>
|
||||
{t('Discouraged')}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Controls whether user verification (biometrics/PIN) is required during Passkey flows.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.attachment_preference'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Device Type Preference')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
items={[
|
||||
{ value: 'none', label: t('Unlimited') },
|
||||
{ value: 'platform', label: t('Built-in Device') },
|
||||
{ value: 'cross-platform', label: t('External Device') },
|
||||
]}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('No preference')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
<SelectItem value='none'>{t('Unlimited')}</SelectItem>
|
||||
<SelectItem value='platform'>
|
||||
{t('Built-in Device')}
|
||||
</SelectItem>
|
||||
<SelectItem value='cross-platform'>
|
||||
{t('External Device')}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Built-in: phone fingerprint/face, or Windows Hello; External: USB security key'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.allow_insecure_origin'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Allow Insecure Origins')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Permit Passkey registration on non-HTTPS origins (only recommended for development)'
|
||||
)}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='passkey.origins'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Allowed Origins')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder={t('https://example.com')}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'List of origins (one per line) allowed for Passkey registration and authentication.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsForm>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { AuthSettings } from '../types'
|
||||
import { createSectionRegistry } from '../utils/section-registry'
|
||||
import { BasicAuthSection } from './basic-auth-section'
|
||||
import { BotProtectionSection } from './bot-protection-section'
|
||||
import { CustomOAuthSection } from './custom-oauth/custom-oauth-section'
|
||||
import { OAuthSection } from './oauth-section'
|
||||
import { PasskeySection } from './passkey-section'
|
||||
|
||||
const AUTH_SECTIONS = [
|
||||
{
|
||||
id: 'basic-auth',
|
||||
titleKey: 'Basic Authentication',
|
||||
build: (settings: AuthSettings) => (
|
||||
<BasicAuthSection
|
||||
defaultValues={{
|
||||
PasswordLoginEnabled: settings.PasswordLoginEnabled,
|
||||
PasswordRegisterEnabled: settings.PasswordRegisterEnabled,
|
||||
EmailVerificationEnabled: settings.EmailVerificationEnabled,
|
||||
RegisterEnabled: settings.RegisterEnabled,
|
||||
EmailDomainRestrictionEnabled: settings.EmailDomainRestrictionEnabled,
|
||||
EmailAliasRestrictionEnabled: settings.EmailAliasRestrictionEnabled,
|
||||
EmailDomainWhitelist: settings.EmailDomainWhitelist,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'oauth',
|
||||
titleKey: 'OAuth Integrations',
|
||||
build: (settings: AuthSettings) => (
|
||||
<OAuthSection
|
||||
serverAddress={settings.ServerAddress}
|
||||
defaultValues={{
|
||||
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
|
||||
GitHubClientId: settings.GitHubClientId,
|
||||
GitHubClientSecret: settings.GitHubClientSecret,
|
||||
'discord.enabled': settings['discord.enabled'],
|
||||
'discord.client_id': settings['discord.client_id'],
|
||||
'discord.client_secret': settings['discord.client_secret'],
|
||||
'oidc.enabled': settings['oidc.enabled'],
|
||||
'oidc.client_id': settings['oidc.client_id'],
|
||||
'oidc.client_secret': settings['oidc.client_secret'],
|
||||
'oidc.well_known': settings['oidc.well_known'],
|
||||
'oidc.authorization_endpoint':
|
||||
settings['oidc.authorization_endpoint'],
|
||||
'oidc.token_endpoint': settings['oidc.token_endpoint'],
|
||||
'oidc.user_info_endpoint': settings['oidc.user_info_endpoint'],
|
||||
TelegramOAuthEnabled: settings.TelegramOAuthEnabled,
|
||||
TelegramBotToken: settings.TelegramBotToken,
|
||||
TelegramBotName: settings.TelegramBotName,
|
||||
LinuxDOOAuthEnabled: settings.LinuxDOOAuthEnabled,
|
||||
LinuxDOClientId: settings.LinuxDOClientId,
|
||||
LinuxDOClientSecret: settings.LinuxDOClientSecret,
|
||||
LinuxDOMinimumTrustLevel: settings.LinuxDOMinimumTrustLevel,
|
||||
WeChatAuthEnabled: settings.WeChatAuthEnabled,
|
||||
WeChatServerAddress: settings.WeChatServerAddress,
|
||||
WeChatServerToken: settings.WeChatServerToken,
|
||||
WeChatAccountQRCodeImageURL: settings.WeChatAccountQRCodeImageURL,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'passkey',
|
||||
titleKey: 'Passkey Authentication',
|
||||
build: (settings: AuthSettings) => (
|
||||
<PasskeySection
|
||||
defaultValues={{
|
||||
'passkey.enabled': settings['passkey.enabled'],
|
||||
'passkey.rp_display_name': settings['passkey.rp_display_name'],
|
||||
'passkey.rp_id': settings['passkey.rp_id'],
|
||||
'passkey.origins': settings['passkey.origins'],
|
||||
'passkey.allow_insecure_origin':
|
||||
settings['passkey.allow_insecure_origin'],
|
||||
'passkey.user_verification': settings['passkey.user_verification'] as
|
||||
| 'required'
|
||||
| 'preferred'
|
||||
| 'discouraged',
|
||||
'passkey.attachment_preference':
|
||||
settings['passkey.attachment_preference'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bot-protection',
|
||||
titleKey: 'Bot Protection',
|
||||
build: (settings: AuthSettings) => (
|
||||
<BotProtectionSection
|
||||
defaultValues={{
|
||||
TurnstileCheckEnabled: settings.TurnstileCheckEnabled,
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
TurnstileSecretKey: settings.TurnstileSecretKey,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'custom-oauth',
|
||||
titleKey: 'Custom OAuth',
|
||||
build: (settings: AuthSettings) => (
|
||||
<CustomOAuthSection serverAddress={settings.ServerAddress} />
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export type AuthSectionId = (typeof AUTH_SECTIONS)[number]['id']
|
||||
|
||||
const authRegistry = createSectionRegistry<AuthSectionId, AuthSettings>({
|
||||
sections: AUTH_SECTIONS,
|
||||
defaultSection: 'basic-auth',
|
||||
basePath: '/system-settings/auth',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const AUTH_SECTION_IDS = authRegistry.sectionIds
|
||||
export const AUTH_DEFAULT_SECTION = authRegistry.defaultSection
|
||||
export const getAuthSectionNavItems = authRegistry.getSectionNavItems
|
||||
export const getAuthSectionContent = authRegistry.getSectionContent
|
||||
export const getAuthSectionMeta = authRegistry.getSectionMeta
|
||||
Reference in New Issue
Block a user