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,225 @@
|
||||
/*
|
||||
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 { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const rateLimitDialogSchema = z.object({
|
||||
groupName: z.string().min(1, 'Group name is required'),
|
||||
maxRequests: z
|
||||
.number()
|
||||
.min(0, 'Must be ≥ 0')
|
||||
.max(2147483647, 'Must be ≤ 2,147,483,647'),
|
||||
maxSuccess: z
|
||||
.number()
|
||||
.min(1, 'Must be ≥ 1')
|
||||
.max(2147483647, 'Must be ≤ 2,147,483,647'),
|
||||
})
|
||||
|
||||
type RateLimitDialogFormValues = z.infer<typeof rateLimitDialogSchema>
|
||||
|
||||
const RATE_LIMIT_FORM_ID = 'rate-limit-form'
|
||||
|
||||
export type RateLimitEntryData = {
|
||||
groupName: string
|
||||
maxRequests: number
|
||||
maxSuccess: number
|
||||
}
|
||||
|
||||
type RateLimitDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (data: RateLimitEntryData) => void
|
||||
editData?: RateLimitEntryData | null
|
||||
}
|
||||
|
||||
export function RateLimitDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
editData,
|
||||
}: RateLimitDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const isEditMode = !!editData
|
||||
|
||||
const form = useForm<RateLimitDialogFormValues>({
|
||||
resolver: zodResolver(rateLimitDialogSchema),
|
||||
defaultValues: {
|
||||
groupName: '',
|
||||
maxRequests: 0,
|
||||
maxSuccess: 1,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (editData) {
|
||||
form.reset(editData)
|
||||
} else {
|
||||
form.reset({
|
||||
groupName: '',
|
||||
maxRequests: 0,
|
||||
maxSuccess: 1,
|
||||
})
|
||||
}
|
||||
}, [editData, form, open])
|
||||
|
||||
const handleSubmit = (values: RateLimitDialogFormValues) => {
|
||||
onSave(values)
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
isEditMode ? t('Edit group rate limit') : t('Add group rate limit')
|
||||
}
|
||||
description={t(
|
||||
'Configure rate limiting rules for a specific user group.'
|
||||
)}
|
||||
contentClassName='sm:max-w-[500px]'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={RATE_LIMIT_FORM_ID}>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={RATE_LIMIT_FORM_ID}
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='groupName'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Group Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g., default, vip, premium')}
|
||||
{...field}
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{isEditMode
|
||||
? t('Group name cannot be changed when editing.')
|
||||
: t('Unique identifier for this group.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='maxRequests'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Max Requests (including failures)')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
max={2147483647}
|
||||
step={1}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('times')}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Total requests allowed per period. 0 = unlimited.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='maxSuccess'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Max Successful Requests')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
max={2147483647}
|
||||
step={1}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 1)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('times')}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Only successful requests count toward this limit.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
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 { Code2, Palette } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as z from 'zod'
|
||||
|
||||
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 { 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'
|
||||
import { RateLimitVisualEditor } from './rate-limit-visual-editor'
|
||||
|
||||
const isValidJSON = (value: string | undefined) => {
|
||||
if (!value || value.trim() === '') return true
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return false
|
||||
}
|
||||
for (const [, val] of Object.entries(parsed)) {
|
||||
if (!Array.isArray(val) || val.length !== 2) return false
|
||||
if (typeof val[0] !== 'number' || typeof val[1] !== 'number') return false
|
||||
if (val[0] < 0 || val[1] < 1) return false
|
||||
if (val[0] > 2147483647 || val[1] > 2147483647) return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const createRateLimitSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
ModelRequestRateLimitEnabled: z.boolean(),
|
||||
ModelRequestRateLimitDurationMinutes: z.number().min(0),
|
||||
ModelRequestRateLimitCount: z.number().min(0).max(100000000),
|
||||
ModelRequestRateLimitSuccessCount: z.number().min(1).max(100000000),
|
||||
ModelRequestRateLimitGroup: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(isValidJSON, {
|
||||
message: t('Invalid JSON format or values out of allowed range'),
|
||||
}),
|
||||
})
|
||||
|
||||
type RateLimitFormValues = z.infer<ReturnType<typeof createRateLimitSchema>>
|
||||
|
||||
type RateLimitSectionProps = {
|
||||
defaultValues: RateLimitFormValues
|
||||
}
|
||||
|
||||
export function RateLimitSection({ defaultValues }: RateLimitSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const [useVisualEditor, setUseVisualEditor] = useState(true)
|
||||
|
||||
const rateLimitSchema = createRateLimitSchema(t)
|
||||
|
||||
const form = useForm<RateLimitFormValues>({
|
||||
resolver: zodResolver(rateLimitSchema),
|
||||
mode: 'onChange', // Enable real-time validation
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues)
|
||||
}, [defaultValues, form])
|
||||
|
||||
const onSubmit = async (values: RateLimitFormValues) => {
|
||||
const updates = Object.entries(values).filter(
|
||||
([key, value]) =>
|
||||
value !== defaultValues[key as keyof RateLimitFormValues]
|
||||
)
|
||||
|
||||
for (const [key, value] of updates) {
|
||||
await updateOption.mutateAsync({ key, value: value ?? '' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('Rate Limiting')}>
|
||||
<Form {...form}>
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SettingsPageFormActions
|
||||
onSave={form.handleSubmit(onSubmit)}
|
||||
isSaving={updateOption.isPending}
|
||||
saveLabel='Save rate limits'
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ModelRequestRateLimitEnabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Enable rate limiting')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ModelRequestRateLimitDurationMinutes'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Limit period')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
step={1}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('minutes')}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Time window for rate limiting')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ModelRequestRateLimitCount'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Max requests per period')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
max={100000000}
|
||||
step={1}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('times')}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Including failed requests, 0 = unlimited')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ModelRequestRateLimitSuccessCount'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Max successful requests')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
max={100000000}
|
||||
step={1}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 1)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('times')}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Only successful requests')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ModelRequestRateLimitGroup'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='flex items-center justify-between'>
|
||||
<FormLabel>{t('Group-based rate limits')}</FormLabel>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setUseVisualEditor(!useVisualEditor)}
|
||||
>
|
||||
{useVisualEditor ? (
|
||||
<>
|
||||
<Code2 className='mr-2 h-4 w-4' />
|
||||
{t('JSON Mode')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Palette className='mr-2 h-4 w-4' />
|
||||
{t('Visual Mode')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
{useVisualEditor ? (
|
||||
<RateLimitVisualEditor
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
rows={8}
|
||||
placeholder={`{\n "default": [200, 100],\n "vip": [0, 1000]\n}`}
|
||||
className='font-mono text-sm'
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
{!useVisualEditor && (
|
||||
<FormDescription>
|
||||
<div className='space-y-1 text-xs'>
|
||||
<p className='font-semibold'>{t('Format:')}</p>
|
||||
<ul className='list-inside list-disc space-y-0.5 pl-2'>
|
||||
<li>
|
||||
{t('JSON object:')}{' '}
|
||||
{`{"groupName": [maxRequests, maxSuccess]}`}
|
||||
</li>
|
||||
<li>
|
||||
{t('Example:')}{' '}
|
||||
{`{"default": [200, 100], "vip": [0, 1000]}`}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'Group config overrides global limits, shares the same period'
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsForm>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
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, Search } from 'lucide-react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
|
||||
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { safeJsonParseWithValidation } from '../utils/json-parser'
|
||||
import { isObjectRecord } from '../utils/json-validators'
|
||||
import { RateLimitDialog, type RateLimitEntryData } from './rate-limit-dialog'
|
||||
|
||||
type RateLimitVisualEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
type RateLimitEntry = RateLimitEntryData
|
||||
|
||||
export function RateLimitVisualEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: RateLimitVisualEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editData, setEditData] = useState<RateLimitEntry | null>(null)
|
||||
|
||||
const rateLimits = useMemo(() => {
|
||||
if (!value || value.trim() === '') return []
|
||||
|
||||
const parsed = safeJsonParseWithValidation<Record<string, unknown>>(value, {
|
||||
fallback: {},
|
||||
validator: isObjectRecord,
|
||||
validatorMessage: 'Rate limits must be a JSON object',
|
||||
context: 'rate limits',
|
||||
})
|
||||
|
||||
return Object.entries(parsed)
|
||||
.map(([groupName, limits]) => {
|
||||
if (
|
||||
Array.isArray(limits) &&
|
||||
limits.length === 2 &&
|
||||
typeof limits[0] === 'number' &&
|
||||
typeof limits[1] === 'number'
|
||||
) {
|
||||
return {
|
||||
groupName,
|
||||
maxRequests: limits[0],
|
||||
maxSuccess: limits[1],
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((item): item is RateLimitEntry => item !== null)
|
||||
}, [value])
|
||||
|
||||
const filteredRateLimits = useMemo(() => {
|
||||
if (!searchText) return rateLimits
|
||||
const lowerSearch = searchText.toLowerCase()
|
||||
return rateLimits.filter((limit) =>
|
||||
limit.groupName.toLowerCase().includes(lowerSearch)
|
||||
)
|
||||
}, [rateLimits, searchText])
|
||||
|
||||
const handleSave = (data: RateLimitEntryData) => {
|
||||
const parsed = safeJsonParseWithValidation<Record<string, unknown>>(value, {
|
||||
fallback: {},
|
||||
validator: isObjectRecord,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
if (editData && editData.groupName !== data.groupName) {
|
||||
delete parsed[editData.groupName]
|
||||
}
|
||||
|
||||
parsed[data.groupName] = [data.maxRequests, data.maxSuccess]
|
||||
|
||||
onChange(JSON.stringify(parsed, null, 2))
|
||||
}
|
||||
|
||||
const handleDelete = (groupName: string) => {
|
||||
const parsed = safeJsonParseWithValidation<Record<string, unknown>>(value, {
|
||||
fallback: {},
|
||||
validator: isObjectRecord,
|
||||
silent: true,
|
||||
})
|
||||
|
||||
delete parsed[groupName]
|
||||
|
||||
onChange(JSON.stringify(parsed, null, 2))
|
||||
}
|
||||
|
||||
const handleEdit = (limit: RateLimitEntry) => {
|
||||
setEditData(limit)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditData(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='relative flex-1'>
|
||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||
<Input
|
||||
placeholder={t('Search group names...')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className='pl-9'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add group')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<StaticDataTable
|
||||
data={filteredRateLimits}
|
||||
getRowKey={(limit) => limit.groupName}
|
||||
emptyContent={
|
||||
searchText
|
||||
? t('No groups match your search')
|
||||
: t(
|
||||
'No group-based rate limits configured. Click "Add group" to get started.'
|
||||
)
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group Name'),
|
||||
cellClassName: 'font-medium',
|
||||
cell: (limit) => limit.groupName,
|
||||
},
|
||||
{
|
||||
id: 'max-requests',
|
||||
header: t('Max Requests (incl. failures)'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (limit) => (
|
||||
<span className='font-mono'>
|
||||
{limit.maxRequests === 0
|
||||
? t('Unlimited')
|
||||
: limit.maxRequests.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'max-success',
|
||||
header: t('Max Success'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (limit) => (
|
||||
<span className='font-mono'>
|
||||
{limit.maxSuccess.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cellClassName: 'text-right',
|
||||
cell: (limit) => (
|
||||
<StaticRowActions
|
||||
editLabel={t('Edit')}
|
||||
deleteLabel={t('Delete')}
|
||||
menuLabel={t('Open menu')}
|
||||
onEdit={() => handleEdit(limit)}
|
||||
onDelete={() => handleDelete(limit.groupName)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<RateLimitDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSave={handleSave}
|
||||
editData={editData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
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 { 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'
|
||||
|
||||
const sensitiveSchema = z.object({
|
||||
CheckSensitiveEnabled: z.boolean(),
|
||||
CheckSensitiveOnPromptEnabled: z.boolean(),
|
||||
SensitiveWords: z.string().optional(),
|
||||
})
|
||||
|
||||
type SensitiveFormValues = z.infer<typeof sensitiveSchema>
|
||||
|
||||
type SensitiveWordsSectionProps = {
|
||||
defaultValues: SensitiveFormValues
|
||||
}
|
||||
|
||||
export function SensitiveWordsSection({
|
||||
defaultValues,
|
||||
}: SensitiveWordsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const form = useForm<SensitiveFormValues>({
|
||||
resolver: zodResolver(sensitiveSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues)
|
||||
}, [defaultValues, form])
|
||||
|
||||
const onSubmit = async (values: SensitiveFormValues) => {
|
||||
const updates = Object.entries(values).filter(
|
||||
([key, value]) =>
|
||||
value !== defaultValues[key as keyof SensitiveFormValues]
|
||||
)
|
||||
|
||||
for (const [key, value] of updates) {
|
||||
await updateOption.mutateAsync({ key, value: value ?? '' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('Sensitive Words')}>
|
||||
<Form {...form}>
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SettingsPageFormActions
|
||||
onSave={form.handleSubmit(onSubmit)}
|
||||
isSaving={updateOption.isPending}
|
||||
saveLabel='Save sensitive words'
|
||||
/>
|
||||
<div className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='CheckSensitiveEnabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Enable filtering')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Blocks messages when sensitive keywords are detected.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='CheckSensitiveOnPromptEnabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Inspect user prompts')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'When enabled, prompts are scanned before reaching upstream models.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='SensitiveWords'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Blocked keywords')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={12}
|
||||
placeholder={t('Enter one keyword per line')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Each line represents one keyword. Leave blank to disable the list but keep the switch states.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsForm>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/*
|
||||
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'
|
||||
|
||||
const ssrfSchema = z.object({
|
||||
fetch_setting: z.object({
|
||||
enable_ssrf_protection: z.boolean(),
|
||||
allow_private_ip: z.boolean(),
|
||||
domain_filter_mode: z.boolean(),
|
||||
ip_filter_mode: z.boolean(),
|
||||
domain_list: z.string(),
|
||||
ip_list: z.string(),
|
||||
allowed_ports: z.string(),
|
||||
apply_ip_filter_for_domain: z.boolean(),
|
||||
}),
|
||||
})
|
||||
|
||||
type SSRFFormValues = z.output<typeof ssrfSchema>
|
||||
type SSRFFormInput = z.input<typeof ssrfSchema>
|
||||
|
||||
type NormalizedSSRFValues = {
|
||||
'fetch_setting.enable_ssrf_protection': boolean
|
||||
'fetch_setting.allow_private_ip': boolean
|
||||
'fetch_setting.domain_filter_mode': boolean
|
||||
'fetch_setting.ip_filter_mode': boolean
|
||||
'fetch_setting.domain_list': string[]
|
||||
'fetch_setting.ip_list': string[]
|
||||
'fetch_setting.allowed_ports': number[]
|
||||
'fetch_setting.apply_ip_filter_for_domain': boolean
|
||||
}
|
||||
|
||||
type SSRFSectionProps = {
|
||||
defaultValues: {
|
||||
'fetch_setting.enable_ssrf_protection': boolean
|
||||
'fetch_setting.allow_private_ip': boolean
|
||||
'fetch_setting.domain_filter_mode': boolean
|
||||
'fetch_setting.ip_filter_mode': boolean
|
||||
'fetch_setting.domain_list': string[]
|
||||
'fetch_setting.ip_list': string[]
|
||||
'fetch_setting.allowed_ports': number[]
|
||||
'fetch_setting.apply_ip_filter_for_domain': boolean
|
||||
}
|
||||
}
|
||||
|
||||
const splitLines = (value: string) =>
|
||||
value
|
||||
.split('\n')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const parsePorts = (value: string) =>
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => Number.parseInt(item.trim(), 10))
|
||||
.filter((port) => Number.isFinite(port))
|
||||
|
||||
const buildFormDefaults = (
|
||||
defaults: SSRFSectionProps['defaultValues']
|
||||
): SSRFFormInput => ({
|
||||
fetch_setting: {
|
||||
enable_ssrf_protection: defaults['fetch_setting.enable_ssrf_protection'],
|
||||
allow_private_ip: defaults['fetch_setting.allow_private_ip'],
|
||||
domain_filter_mode: defaults['fetch_setting.domain_filter_mode'],
|
||||
ip_filter_mode: defaults['fetch_setting.ip_filter_mode'],
|
||||
domain_list: defaults['fetch_setting.domain_list'].join('\n'),
|
||||
ip_list: defaults['fetch_setting.ip_list'].join('\n'),
|
||||
allowed_ports: defaults['fetch_setting.allowed_ports'].join(','),
|
||||
apply_ip_filter_for_domain:
|
||||
defaults['fetch_setting.apply_ip_filter_for_domain'],
|
||||
},
|
||||
})
|
||||
|
||||
const normalizeDefaults = (
|
||||
defaults: SSRFSectionProps['defaultValues']
|
||||
): NormalizedSSRFValues => ({
|
||||
'fetch_setting.enable_ssrf_protection':
|
||||
defaults['fetch_setting.enable_ssrf_protection'],
|
||||
'fetch_setting.allow_private_ip': defaults['fetch_setting.allow_private_ip'],
|
||||
'fetch_setting.domain_filter_mode':
|
||||
defaults['fetch_setting.domain_filter_mode'],
|
||||
'fetch_setting.ip_filter_mode': defaults['fetch_setting.ip_filter_mode'],
|
||||
'fetch_setting.domain_list': defaults['fetch_setting.domain_list'],
|
||||
'fetch_setting.ip_list': defaults['fetch_setting.ip_list'],
|
||||
'fetch_setting.allowed_ports': defaults['fetch_setting.allowed_ports'],
|
||||
'fetch_setting.apply_ip_filter_for_domain':
|
||||
defaults['fetch_setting.apply_ip_filter_for_domain'],
|
||||
})
|
||||
|
||||
const normalizeFormValues = (values: SSRFFormValues): NormalizedSSRFValues => ({
|
||||
'fetch_setting.enable_ssrf_protection':
|
||||
values.fetch_setting.enable_ssrf_protection,
|
||||
'fetch_setting.allow_private_ip': values.fetch_setting.allow_private_ip,
|
||||
'fetch_setting.domain_filter_mode': values.fetch_setting.domain_filter_mode,
|
||||
'fetch_setting.ip_filter_mode': values.fetch_setting.ip_filter_mode,
|
||||
'fetch_setting.domain_list': splitLines(values.fetch_setting.domain_list),
|
||||
'fetch_setting.ip_list': splitLines(values.fetch_setting.ip_list),
|
||||
'fetch_setting.allowed_ports': parsePorts(values.fetch_setting.allowed_ports),
|
||||
'fetch_setting.apply_ip_filter_for_domain':
|
||||
values.fetch_setting.apply_ip_filter_for_domain,
|
||||
})
|
||||
|
||||
const isEqual = (a: unknown, b: unknown) => {
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
return a === b
|
||||
}
|
||||
|
||||
export function SSRFSection({ defaultValues }: SSRFSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const baselineRef = useRef<NormalizedSSRFValues>(
|
||||
normalizeDefaults(defaultValues)
|
||||
)
|
||||
|
||||
const formDefaults = useMemo(
|
||||
() => buildFormDefaults(defaultValues),
|
||||
[defaultValues]
|
||||
)
|
||||
|
||||
const form = useForm<SSRFFormInput, unknown, SSRFFormValues>({
|
||||
resolver: zodResolver(ssrfSchema),
|
||||
defaultValues: formDefaults,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
baselineRef.current = normalizeDefaults(defaultValues)
|
||||
form.reset(buildFormDefaults(defaultValues))
|
||||
}, [defaultValues, form])
|
||||
|
||||
const onSubmit = async (data: SSRFFormValues) => {
|
||||
const normalized = normalizeFormValues(data)
|
||||
const updates = (
|
||||
Object.keys(normalized) as Array<keyof NormalizedSSRFValues>
|
||||
).filter((key) => !isEqual(normalized[key], baselineRef.current[key]))
|
||||
|
||||
if (updates.length === 0) {
|
||||
toast.info(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const key of updates) {
|
||||
const value = normalized[key]
|
||||
await updateOption.mutateAsync({
|
||||
key,
|
||||
value: Array.isArray(value) ? JSON.stringify(value) : value,
|
||||
})
|
||||
}
|
||||
|
||||
baselineRef.current = normalized
|
||||
}
|
||||
|
||||
const domainFilterMode = form.watch('fetch_setting.domain_filter_mode')
|
||||
const ipFilterMode = form.watch('fetch_setting.ip_filter_mode')
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('SSRF Protection')}>
|
||||
<Form {...form}>
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SettingsPageFormActions
|
||||
onSave={form.handleSubmit(onSubmit)}
|
||||
isSaving={updateOption.isPending}
|
||||
saveLabel='Save SSRF settings'
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='fetch_setting.enable_ssrf_protection'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Enable SSRF Protection')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Prevent server-side request forgery attacks')}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='fetch_setting.allow_private_ip'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Allow Private IPs')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Allow requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)'
|
||||
)}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='fetch_setting.domain_filter_mode'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Domain Filter Mode')}</FormLabel>
|
||||
<Select
|
||||
items={[
|
||||
{
|
||||
value: 'false',
|
||||
label: t('Blacklist (Block listed domains)'),
|
||||
},
|
||||
{
|
||||
value: 'true',
|
||||
label: t('Whitelist (Only allow listed domains)'),
|
||||
},
|
||||
]}
|
||||
onValueChange={(value) => field.onChange(value === 'true')}
|
||||
value={field.value ? 'true' : 'false'}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
<SelectItem value='false'>
|
||||
{t('Blacklist (Block listed domains)')}
|
||||
</SelectItem>
|
||||
<SelectItem value='true'>
|
||||
{t('Whitelist (Only allow listed domains)')}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t('Choose how to filter domains')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='fetch_setting.domain_list'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('Domain')}{' '}
|
||||
{domainFilterMode ? t('Whitelist') : t('Blacklist')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('example.com blocked-site.com')}
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('One domain per line')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='fetch_setting.ip_filter_mode'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('IP Filter Mode')}</FormLabel>
|
||||
<Select
|
||||
items={[
|
||||
{
|
||||
value: 'false',
|
||||
label: t('Blacklist (Block listed IPs)'),
|
||||
},
|
||||
{
|
||||
value: 'true',
|
||||
label: t('Whitelist (Only allow listed IPs)'),
|
||||
},
|
||||
]}
|
||||
onValueChange={(value) => field.onChange(value === 'true')}
|
||||
value={field.value ? 'true' : 'false'}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
<SelectItem value='false'>
|
||||
{t('Blacklist (Block listed IPs)')}
|
||||
</SelectItem>
|
||||
<SelectItem value='true'>
|
||||
{t('Whitelist (Only allow listed IPs)')}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t('Choose how to filter IP addresses')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='fetch_setting.ip_list'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('IP')} {ipFilterMode ? t('Whitelist') : t('Blacklist')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('192.168.1.1 10.0.0.0/8')}
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('One IP or CIDR range per line')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='fetch_setting.allowed_ports'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Allowed Ports')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('80,443,8080')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Comma-separated list of allowed ports (empty = all ports)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='fetch_setting.apply_ip_filter_for_domain'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>
|
||||
{t('Apply IP Filter to Resolved Domains')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Check resolved IPs against IP filters even when accessing by domain'
|
||||
)}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsForm>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
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 { SettingsForm } 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 tokenLimitSchema = z.object({
|
||||
token_setting: z.object({
|
||||
max_user_tokens: z.number().min(1),
|
||||
}),
|
||||
})
|
||||
|
||||
type TokenLimitFormValues = z.output<typeof tokenLimitSchema>
|
||||
type TokenLimitFormInput = z.input<typeof tokenLimitSchema>
|
||||
|
||||
type NormalizedTokenLimitValues = {
|
||||
'token_setting.max_user_tokens': number
|
||||
}
|
||||
|
||||
type TokenLimitSectionProps = {
|
||||
defaultValues: NormalizedTokenLimitValues
|
||||
}
|
||||
|
||||
const buildFormDefaults = (
|
||||
defaults: TokenLimitSectionProps['defaultValues']
|
||||
): TokenLimitFormInput => ({
|
||||
token_setting: {
|
||||
max_user_tokens: defaults['token_setting.max_user_tokens'],
|
||||
},
|
||||
})
|
||||
|
||||
const normalizeFormValues = (
|
||||
values: TokenLimitFormValues
|
||||
): NormalizedTokenLimitValues => ({
|
||||
'token_setting.max_user_tokens': values.token_setting.max_user_tokens,
|
||||
})
|
||||
|
||||
export function TokenLimitSection({ defaultValues }: TokenLimitSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const form = useForm<TokenLimitFormInput, unknown, TokenLimitFormValues>({
|
||||
resolver: zodResolver(tokenLimitSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: buildFormDefaults(defaultValues),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(buildFormDefaults(defaultValues))
|
||||
}, [defaultValues, form])
|
||||
|
||||
const onSubmit = async (values: TokenLimitFormValues) => {
|
||||
const key = 'token_setting.max_user_tokens' as const
|
||||
const normalized = normalizeFormValues(values)
|
||||
const value = normalized[key]
|
||||
if (value !== defaultValues[key]) {
|
||||
await updateOption.mutateAsync({ key, value })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('Token Limits')}>
|
||||
<Form {...form}>
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SettingsPageFormActions
|
||||
onSave={form.handleSubmit(onSubmit)}
|
||||
isSaving={updateOption.isPending}
|
||||
saveLabel='Save token limits'
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='token_setting.max_user_tokens'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Maximum tokens per user')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
step={1}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number.parseInt(e.target.value) || 1)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsForm>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user