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:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
@@ -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&#10;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
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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')
)
},
})
}
@@ -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
View File
@@ -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(/^\/+/, '')}`
}
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