feat: better admin permissions (#5755)

* feat: add casbin admin permissions

* feat: improve audit logging to associate logs with actual operators and target users

* feat: enhance admin permissions and UI interactions for sensitive actions

* Refactor authz RBAC and tighten channel permissions

* Split channel authz field policy

* Address channel authz review findings
This commit is contained in:
Calcium-Ion
2026-06-27 17:01:59 +08:00
committed by GitHub
parent 6c35e1ef26
commit 4aee5f7d5a
52 changed files with 2778 additions and 255 deletions
+30
View File
@@ -138,6 +138,36 @@ export async function updateChannel(
return res.data
}
/**
* Update channel enabled/disabled status.
*/
export async function updateChannelStatus(
id: number,
status: number
): Promise<{ success: boolean; message?: string; data?: boolean }> {
const res = await api.post(
`/api/channel/${id}/status`,
{ status },
channelActionConfig()
)
return res.data
}
/**
* Batch update channel enabled/disabled status.
*/
export async function batchUpdateChannelStatus(
ids: number[],
status: number
): Promise<{ success: boolean; message?: string; data?: number }> {
const res = await api.post(
'/api/channel/status/batch',
{ ids, status },
channelActionConfig()
)
return res.data
}
/**
* Delete single channel
*/
@@ -32,6 +32,12 @@ import {
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import {
DropdownMenu,
DropdownMenuContent,
@@ -43,6 +49,11 @@ import {
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
handleDeleteAllDisabled,
@@ -65,6 +76,12 @@ export function ChannelsPrimaryButtons() {
} = useChannels()
const queryClient = useQueryClient()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
const handleTagModeToggle = (checked: boolean) => {
localStorage.setItem('enable-tag-mode', String(checked))
@@ -105,17 +122,28 @@ export function ChannelsPrimaryButtons() {
</div>
{/* Create Channel */}
<Button
onClick={() => {
setCurrentRow(null)
setOpen('create-channel')
}}
size='sm'
>
<Plus className='h-4 w-4' />
<span className='max-sm:hidden'>{t('Create Channel')}</span>
<span className='sm:hidden'>{t('Create')}</span>
</Button>
<Tooltip>
<TooltipTrigger render={<span className='inline-flex' />}>
<Button
onClick={() => {
if (!canEditSensitive) return
setCurrentRow(null)
setOpen('create-channel')
}}
size='sm'
disabled={!canEditSensitive}
>
<Plus className='h-4 w-4' />
<span className='max-sm:hidden'>{t('Create Channel')}</span>
<span className='sm:hidden'>{t('Create')}</span>
</Button>
</TooltipTrigger>
{!canEditSensitive && (
<TooltipContent>
{t('No permission to perform this action')}
</TooltipContent>
)}
</Tooltip>
{/* More Actions */}
<DropdownMenu>
@@ -209,8 +237,10 @@ export function ChannelsPrimaryButtons() {
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
if (!canEditSensitive) return
setShowDeleteDialog(true)
}}
disabled={!canEditSensitive}
className='text-destructive focus:text-destructive'
>
{t('Delete All Disabled')}
@@ -231,6 +261,7 @@ export function ChannelsPrimaryButtons() {
)}
destructive
handleConfirm={() => {
if (!canEditSensitive) return
handleDeleteAllDisabled(queryClient, (_count) => {
// eslint-disable-next-line no-console
console.log(`Deleted ${_count} channels`)
@@ -24,6 +24,13 @@ import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useAuthStore } from '@/stores/auth-store'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { cn } from '@/lib/utils'
import {
Tooltip,
TooltipContent,
@@ -51,6 +58,12 @@ export function DataTableBulkActions<TData>({
const [showTagDialog, setShowTagDialog] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [tagValue, setTagValue] = useState('')
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedIds = selectedRows.reduce<number[]>((ids, row) => {
@@ -76,6 +89,7 @@ export function DataTableBulkActions<TData>({
}
const handleDeleteAll = () => {
if (!canEditSensitive) return
handleBatchDelete(selectedIds, queryClient, () => {
setShowDeleteConfirm(false)
handleClearSelection()
@@ -164,10 +178,21 @@ export function DataTableBulkActions<TData>({
<Button
variant='destructive'
size='icon'
onClick={() => setShowDeleteConfirm(true)}
className='size-8'
onClick={() => {
if (!canEditSensitive) return
setShowDeleteConfirm(true)
}}
aria-disabled={!canEditSensitive}
className={cn(
'size-8',
!canEditSensitive && 'cursor-not-allowed opacity-50'
)}
aria-label={t('Delete selected channels')}
title={t('Delete selected channels')}
title={
canEditSensitive
? t('Delete selected channels')
: t('No permission to perform this action')
}
/>
}
>
@@ -175,7 +200,11 @@ export function DataTableBulkActions<TData>({
<span className='sr-only'>{t('Delete selected channels')}</span>
</TooltipTrigger>
<TooltipContent>
<p>{t('Delete selected channels')}</p>
<p>
{canEditSensitive
? t('Delete selected channels')
: t('No permission to perform this action')}
</p>
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
@@ -243,7 +272,11 @@ export function DataTableBulkActions<TData>({
>
{t('Cancel')}
</Button>
<Button variant='destructive' onClick={handleDeleteAll}>
<Button
variant='destructive'
onClick={handleDeleteAll}
disabled={!canEditSensitive}
>
{t('Delete')}
</Button>
</>
@@ -39,6 +39,12 @@ import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import {
DropdownMenu,
DropdownMenuContent,
@@ -77,12 +83,18 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const channel = row.original
const { setOpen, setCurrentRow, upstream } = useChannels()
const queryClient = useQueryClient()
const currentUser = useAuthStore((s) => s.auth.user)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [isTesting, setIsTesting] = useState(false)
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
const isEnabled = isChannelEnabled(channel)
const isMultiKey = isMultiKeyChannel(channel)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
const handleEdit = () => {
setCurrentRow(channel)
@@ -314,12 +326,20 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<DropdownMenuSeparator />
{/* Copy Channel */}
<DropdownMenuItem onClick={handleCopy}>
<DropdownMenuItem
disabled={!canEditSensitive}
onClick={canEditSensitive ? handleCopy : undefined}
>
{t('Copy Channel')}
<DropdownMenuShortcut>
<Copy size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{!canEditSensitive && (
<DropdownMenuItem disabled className='text-xs normal-case'>
{t('No permission to perform this action')}
</DropdownMenuItem>
)}
{/* Manage Keys (only for multi-key channels) */}
{isMultiKey && (
@@ -335,8 +355,10 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
{/* Delete */}
<DropdownMenuItem
disabled={!canEditSensitive}
onSelect={(e) => {
e.preventDefault()
if (!canEditSensitive) return
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
@@ -360,6 +382,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
confirmText={t('Delete')}
destructive
handleConfirm={() => {
if (!canEditSensitive) return
handleDeleteChannel(channel.id, queryClient)
setDeleteConfirmOpen(false)
}}
@@ -21,6 +21,12 @@ import { useQueryClient } from '@tanstack/react-query'
import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import { Button } from '@/components/ui/button'
import {
Select,
@@ -69,6 +75,12 @@ export function MultiKeyManageDialog({
const { t } = useTranslation()
const { currentRow } = useChannels()
const queryClient = useQueryClient()
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
// Data state
const [isLoading, setIsLoading] = useState(false)
@@ -148,6 +160,14 @@ export function MultiKeyManageDialog({
const performAction = async () => {
if (!confirmAction || !currentRow) return
if (
!canEditSensitive &&
(confirmAction.type === 'delete' ||
confirmAction.type === 'delete-disabled')
) {
setConfirmAction(null)
return
}
setIsPerformingAction(true)
try {
@@ -331,7 +351,16 @@ export function MultiKeyManageDialog({
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'delete-disabled' })}
onClick={() => {
if (!canEditSensitive) return
setConfirmAction({ type: 'delete-disabled' })
}}
disabled={!canEditSensitive}
title={
canEditSensitive
? undefined
: t('No permission to perform this action')
}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
@@ -339,6 +368,11 @@ export function MultiKeyManageDialog({
)}
</div>
</div>
{!canEditSensitive && (
<p className='text-muted-foreground text-xs'>
{t('No permission to perform this action')}
</p>
)}
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
@@ -392,6 +426,7 @@ export function MultiKeyManageDialog({
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
canDelete={canEditSensitive}
onAction={setConfirmAction}
/>
),
@@ -23,12 +23,14 @@ import type { MultiKeyConfirmAction } from '../../types'
type MultiKeyTableRowActionsProps = {
keyIndex: number
status: number
canDelete: boolean
onAction: (action: MultiKeyConfirmAction) => void
}
export function MultiKeyTableRowActions({
keyIndex,
status,
canDelete,
onAction,
}: MultiKeyTableRowActionsProps) {
const { t } = useTranslation()
@@ -56,7 +58,16 @@ export function MultiKeyTableRowActions({
<Button
variant='destructive'
size='sm'
onClick={() => onAction({ type: 'delete', keyIndex })}
onClick={() => {
if (!canDelete) return
onAction({ type: 'delete', keyIndex })
}}
disabled={!canDelete}
title={
canDelete
? undefined
: t('No permission to perform this action')
}
>
{t('Delete')}
</Button>
@@ -47,7 +47,13 @@ import {
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { getLobeIcon } from '@/lib/lobe-icon'
import { ROLE } from '@/lib/roles'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock'
import { Alert, AlertDescription } from '@/components/ui/alert'
@@ -104,6 +110,7 @@ import {
SecureVerificationDialog,
useSecureVerification,
} from '@/features/auth/secure-verification'
import { useAuthStore } from '@/stores/auth-store'
import {
fetchModels,
getAllModels,
@@ -198,6 +205,40 @@ const MODEL_MAPPING_PREVIEW_FALLBACK: Array<{
const ADVANCED_SETTINGS_EXPANDED_KEY = 'channel-advanced-settings-expanded'
const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8
const SENSITIVE_FORM_FIELDS = [
'type',
'base_url',
'key',
'openai_organization',
'other',
'key_mode',
'param_override',
'header_override',
'settings',
'setting',
'advanced_custom',
'is_enterprise_account',
'vertex_key_type',
'aws_key_type',
'azure_responses_version',
'force_format',
'thinking_to_content',
'proxy',
'pass_through_body_enabled',
'system_prompt',
'system_prompt_override',
'allow_service_tier',
'disable_store',
'allow_safety_identifier',
'allow_include_obfuscation',
'allow_inference_geo',
'allow_speed',
'claude_beta_query',
'disable_task_polling_sleep',
'upstream_model_update_check_enabled',
'upstream_model_update_auto_sync_enabled',
'upstream_model_update_ignored_models',
] satisfies (keyof ChannelFormValues)[]
function readAdvancedSettingsPreference(): boolean {
if (typeof window === 'undefined') return false
@@ -280,6 +321,13 @@ export function ChannelMutateDrawer({
const { t } = useTranslation()
const queryClient = useQueryClient()
const { setOpen } = useChannels()
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
const canRevealChannelKey = currentUser?.role === ROLE.SUPER_ADMIN
const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
const [channelKey, setChannelKey] = useState<string | null>(null)
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
@@ -307,6 +355,7 @@ export function ChannelMutateDrawer({
const isEditing = Boolean(currentRow)
const channelId = currentRow?.id ?? null
const sensitiveLocked = isEditing && !canEditSensitive
// Fetch channel details if editing
const { data: channelData, isLoading: isChannelLoading } = useQuery({
@@ -388,7 +437,7 @@ export function ChannelMutateDrawer({
reset: resetDoubaoApiUnlock,
} = useHiddenClickUnlock({
requiredClicks: 10,
disabled: currentType !== 45,
disabled: currentType !== 45 || sensitiveLocked,
onUnlock: () => {
toast.info(t('Doubao custom API address editing unlocked'))
},
@@ -783,6 +832,11 @@ export function ChannelMutateDrawer({
return
}
if (!isEditing && !canEditSensitive) {
toast.error(t("You don't have necessary permission"))
return
}
// For creation mode, validate key before opening dialog
if (!isEditing) {
const key = form.getValues('key')
@@ -793,9 +847,12 @@ export function ChannelMutateDrawer({
}
setFetchModelsDialogOpen(true)
}, [isEditing, form, t])
}, [isEditing, canEditSensitive, form, t])
const createModeFetcher = useCallback(async (): Promise<string[]> => {
if (!canEditSensitive) {
throw new Error(t("You don't have necessary permission"))
}
const response = await fetchModels({
type: form.getValues('type'),
key: form.getValues('key'),
@@ -805,7 +862,7 @@ export function ChannelMutateDrawer({
return response.data
}
throw new Error(response.message || 'No models fetched from upstream')
}, [form])
}, [canEditSensitive, form, t])
// Handle model operations
const handleFillRelatedModels = useCallback(() => {
@@ -963,6 +1020,21 @@ export function ChannelMutateDrawer({
return
}
if (sensitiveLocked) {
const dirtyFields = form.formState.dirtyFields as Partial<
Record<keyof ChannelFormValues, unknown>
>
const hasSensitiveChanges = SENSITIVE_FORM_FIELDS.some((field) =>
Boolean(dirtyFields[field])
)
if (hasSensitiveChanges) {
toast.error(
t('You do not have permission to edit sensitive channel settings.')
)
return
}
}
// Validate status_code_mapping entries
if (data.status_code_mapping?.trim()) {
const invalidEntries = collectInvalidStatusCodeEntries(
@@ -1038,6 +1110,7 @@ export function ChannelMutateDrawer({
},
[
isEditing,
sensitiveLocked,
form,
confirmMissingModelMappings,
confirmStatusCodeRisk,
@@ -1105,6 +1178,17 @@ export function ChannelMutateDrawer({
</SheetDescription>
</SheetHeader>
{sensitiveLocked && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
<AlertDescription>
{t('Sensitive channel settings are read-only for your account.')}{' '}
{t(
'You can still edit non-sensitive operations fields such as models, groups, priority, and weight.'
)}
</AlertDescription>
</Alert>
)}
<Form {...form}>
<form
id='channel-form'
@@ -1135,78 +1219,103 @@ export function ChannelMutateDrawer({
)}
/>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type *')}</FormLabel>
<FormControl>
<Combobox
options={channelTypeOptions}
value={String(field.value)}
onValueChange={(value) => {
const nextType = Number(value)
if (
Number.isInteger(nextType) &&
nextType > 0
) {
field.onChange(nextType)
}
}}
placeholder={t('Select channel type')}
searchPlaceholder={t('Search channel type...')}
emptyText={t('No channel type found.')}
allowCustomValue
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<fieldset
disabled={sensitiveLocked}
className='min-w-0 disabled:opacity-60'
>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type *')}</FormLabel>
<FormControl>
<Combobox
options={channelTypeOptions}
value={String(field.value)}
onValueChange={(value) => {
const nextType = Number(value)
if (
Number.isInteger(nextType) &&
nextType > 0
) {
field.onChange(nextType)
}
}}
placeholder={t('Select channel type')}
searchPlaceholder={t(
'Search channel type...'
)}
emptyText={t('No channel type found.')}
allowCustomValue
/>
</FormControl>
{sensitiveLocked && (
<FormDescription>
{t(
'No permission to perform this action'
)}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<FormField
control={form.control}
name='status'
render={({ field }) => (
<FormItem className={sideDrawerSwitchItemClassName()}>
<div className='flex flex-col gap-0.5'>
<FormLabel>{t('Enabled')}</FormLabel>
<FormDescription className='text-xs'>
{t('Enable or disable this channel')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value === 1}
onCheckedChange={(checked) =>
field.onChange(checked ? 1 : 2)
}
/>
</FormControl>
</FormItem>
)}
/>
{currentType === 1 && (
{!isEditing && (
<FormField
control={form.control}
name='openai_organization'
name='status'
render={({ field }) => (
<FormItem>
<FormLabel>{t('OpenAI Organization')}</FormLabel>
<FormItem className={sideDrawerSwitchItemClassName()}>
<div className='flex flex-col gap-0.5'>
<FormLabel>{t('Enabled')}</FormLabel>
<FormDescription className='text-xs'>
{t('Enable or disable this channel')}
</FormDescription>
</div>
<FormControl>
<Input placeholder={t('org-...')} {...field} />
<Switch
checked={field.value === 1}
onCheckedChange={(checked) =>
field.onChange(checked ? 1 : 2)
}
/>
</FormControl>
<FormDescription>
{t(FIELD_DESCRIPTIONS.OPENAI_ORG)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{currentType === 1 && (
<fieldset
disabled={sensitiveLocked}
className='disabled:opacity-60'
>
<FormField
control={form.control}
name='openai_organization'
render={({ field }) => (
<FormItem>
<FormLabel>{t('OpenAI Organization')}</FormLabel>
<FormControl>
<Input placeholder={t('org-...')} {...field} />
</FormControl>
<FormDescription>
{sensitiveLocked
? t(
'No permission to perform this action'
)
: t(FIELD_DESCRIPTIONS.OPENAI_ORG)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
)}
</ChannelBasicSection>
{/* ── API Access ── */}
@@ -1219,6 +1328,20 @@ export function ChannelMutateDrawer({
</Alert>
)}
{sensitiveLocked && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
<AlertDescription>
{t(
'No permission to perform this action'
)}
</AlertDescription>
</Alert>
)}
<fieldset
disabled={sensitiveLocked}
className='space-y-4 disabled:opacity-60'
>
{/* Azure (type 3) */}
{currentType === 3 && (
<>
@@ -2004,7 +2127,7 @@ export function ChannelMutateDrawer({
)}
</div>
</FormDescription>
{isEditing && (
{isEditing && canRevealChannelKey && (
<div className='border-border/60 mt-4 flex flex-col gap-3 border-y border-dashed py-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
@@ -2081,7 +2204,10 @@ export function ChannelMutateDrawer({
variant='outline'
size='sm'
onClick={handleRefreshCodexCredential}
disabled={isCodexCredentialRefreshing}
disabled={
sensitiveLocked ||
isCodexCredentialRefreshing
}
>
{isCodexCredentialRefreshing ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
@@ -2207,6 +2333,7 @@ export function ChannelMutateDrawer({
/>
)}
</ChannelAuthSection>
</fieldset>
</ChannelApiAccessSection>
{/* ── Models & Groups ── */}
@@ -2324,18 +2451,28 @@ export function ChannelMutateDrawer({
{t('Fill All Models')}
</Button>
{MODEL_FETCHABLE_TYPES.has(currentType) && (
<Button
type='button'
variant='outline'
size='sm'
onClick={handleFetchModels}
>
<Sparkles
className='mr-2 h-4 w-4'
aria-hidden='true'
/>
{t('Fetch from Upstream')}
</Button>
<>
<Button
type='button'
variant='outline'
size='sm'
onClick={handleFetchModels}
disabled={!isEditing && !canEditSensitive}
>
<Sparkles
className='mr-2 h-4 w-4'
aria-hidden='true'
/>
{t('Fetch from Upstream')}
</Button>
{!isEditing && !canEditSensitive && (
<span className='text-muted-foreground basis-full text-xs'>
{t(
'No permission to perform this action'
)}
</span>
)}
</>
)}
<Button
type='button'
@@ -2752,6 +2889,15 @@ export function ChannelMutateDrawer({
)}
/>
{sensitiveLocked && (
<p className='text-muted-foreground text-xs'>
{t('No permission to perform this action')}
</p>
)}
<fieldset
disabled={sensitiveLocked}
className='space-y-4 disabled:opacity-60'
>
<FormField
control={form.control}
name='param_override'
@@ -2827,7 +2973,7 @@ export function ChannelMutateDrawer({
<Textarea
value={field.value || ''}
onChange={field.onChange}
disabled={isSubmitting}
disabled={sensitiveLocked || isSubmitting}
rows={8}
placeholder={t(
'Override request parameters. Cannot override stream parameter.'
@@ -2923,7 +3069,7 @@ export function ChannelMutateDrawer({
rows={6}
value={field.value || ''}
onChange={field.onChange}
disabled={isSubmitting}
disabled={sensitiveLocked || isSubmitting}
placeholder={t(
'Enter JSON to override request headers'
)}
@@ -2944,6 +3090,7 @@ export function ChannelMutateDrawer({
</FormItem>
)}
/>
</fieldset>
</div>
</div>
@@ -2953,6 +3100,19 @@ export function ChannelMutateDrawer({
title={t('Channel Extra Settings')}
icon={<Settings className='h-4 w-4' />}
/>
{sensitiveLocked && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
<AlertDescription>
{t(
'No permission to perform this action'
)}
</AlertDescription>
</Alert>
)}
<fieldset
disabled={sensitiveLocked}
className='space-y-4 disabled:opacity-60'
>
{(currentType === 1 || currentType === 14) && (
<div className='border-border/60 flex flex-col gap-3 border-y py-4'>
<SubHeading
@@ -3468,6 +3628,7 @@ export function ChannelMutateDrawer({
</div>
</div>
)}
</fieldset>
</div>
</ChannelAdvancedSection>
</>
@@ -3491,7 +3652,7 @@ export function ChannelMutateDrawer({
</SheetContent>
</Sheet>
{paramOverrideEditorOpen && (
{paramOverrideEditorOpen && !sensitiveLocked && (
<ParamOverrideEditorDialog
open={paramOverrideEditorOpen}
value={form.watch('param_override') || ''}
@@ -3505,7 +3666,7 @@ export function ChannelMutateDrawer({
/>
)}
{advancedCustomEditorOpen && (
{advancedCustomEditorOpen && !sensitiveLocked && (
<AdvancedCustomEditorDialog
open={advancedCustomEditorOpen}
value={form.watch('advanced_custom') || ''}
@@ -19,6 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { useAuthStore } from '@/stores/auth-store'
import { createChannel, updateChannel } from '../api'
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import {
@@ -35,6 +41,18 @@ type UseChannelMutateFormParams = {
onSuccess: () => void
}
const SENSITIVE_UPDATE_FIELDS = [
'type',
'key',
'base_url',
'openai_organization',
'param_override',
'header_override',
'setting',
'settings',
'other',
] satisfies (keyof Channel)[]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
@@ -62,6 +80,12 @@ function getErrorMessage(error: unknown): string | undefined {
export function useChannelMutateForm(props: UseChannelMutateFormParams) {
const { t } = useTranslation()
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
return useMutation({
mutationFn: async (data: ChannelFormValues): Promise<string> => {
@@ -70,8 +94,19 @@ export function useChannelMutateForm(props: UseChannelMutateFormParams) {
data,
props.currentRow.id
)
if (!data.key?.trim()) {
delete payload.key
}
if (!canEditSensitive) {
for (const field of SENSITIVE_UPDATE_FIELDS) {
delete payload[field]
}
}
const payloadWithKeyMode =
props.isMultiKeyChannel && data.key_mode
canEditSensitive &&
props.isMultiKeyChannel &&
data.key?.trim() &&
data.key_mode
? {
...payload,
key_mode: data.key_mode,
+23 -24
View File
@@ -25,6 +25,8 @@ import {
deleteChannel,
testChannel,
updateChannel,
updateChannelStatus,
batchUpdateChannelStatus,
batchDeleteChannels,
batchSetChannelTag,
enableTagChannels,
@@ -119,7 +121,7 @@ export async function handleEnableChannel(
onSuccess?: () => void
): Promise<void> {
try {
const response = await updateChannel(id, { status: CHANNEL_STATUS.ENABLED })
const response = await updateChannelStatus(id, CHANNEL_STATUS.ENABLED)
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.ENABLED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
@@ -141,9 +143,10 @@ export async function handleDisableChannel(
onSuccess?: () => void
): Promise<void> {
try {
const response = await updateChannel(id, {
status: CHANNEL_STATUS.MANUAL_DISABLED,
})
const response = await updateChannelStatus(
id,
CHANNEL_STATUS.MANUAL_DISABLED
)
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.DISABLED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
@@ -441,16 +444,12 @@ export async function handleBatchEnable(
}
try {
// Update each channel individually
const promises = ids.map((id) =>
updateChannel(id, { status: CHANNEL_STATUS.ENABLED })
const response = await batchUpdateChannelStatus(
ids,
CHANNEL_STATUS.ENABLED
)
const results = await Promise.allSettled(promises)
const successCount = results.filter(
(r) => r.status === 'fulfilled' && r.value.success
).length
const failCount = results.length - successCount
const successCount = response.success ? response.data || 0 : 0
const failCount = ids.length - successCount
if (successCount > 0) {
toast.success(
@@ -460,7 +459,9 @@ export async function handleBatchEnable(
onSuccess?.()
}
if (failCount > 0) {
if (!response.success) {
toast.error(response.message || i18next.t('Failed to enable channels'))
} else if (failCount > 0) {
toast.error(
i18next.t('{{count}} channel(s) failed to enable', { count: failCount })
)
@@ -484,16 +485,12 @@ export async function handleBatchDisable(
}
try {
// Update each channel individually
const promises = ids.map((id) =>
updateChannel(id, { status: CHANNEL_STATUS.MANUAL_DISABLED })
const response = await batchUpdateChannelStatus(
ids,
CHANNEL_STATUS.MANUAL_DISABLED
)
const results = await Promise.allSettled(promises)
const successCount = results.filter(
(r) => r.status === 'fulfilled' && r.value.success
).length
const failCount = results.length - successCount
const successCount = response.success ? response.data || 0 : 0
const failCount = ids.length - successCount
if (successCount > 0) {
toast.success(
@@ -503,7 +500,9 @@ export async function handleBatchDisable(
onSuccess?.()
}
if (failCount > 0) {
if (!response.success) {
toast.error(response.message || i18next.t('Failed to disable channels'))
} else if (failCount > 0) {
toast.error(
i18next.t('{{count}} channel(s) failed to disable', {
count: failCount,
-1
View File
@@ -702,7 +702,6 @@ export function transformFormDataToUpdatePayload(
weight: formData.weight ?? 0,
test_model: formData.test_model || null,
auto_ban: formData.auto_ban ?? 1,
status: formData.status,
status_code_mapping: formData.status_code_mapping || null,
tag: formData.tag || null,
remark: formData.remark || '',
+13
View File
@@ -17,6 +17,7 @@ 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 { PermissionCatalog } from '@/lib/admin-permissions'
import type {
User,
GetUsersParams,
@@ -149,6 +150,18 @@ export async function getGroups(): Promise<ApiResponse<string[]>> {
return res.data
}
/**
* Get the permission catalog (resources, actions, and role baselines).
* Source of truth lives in the backend authz package.
*/
export async function getPermissionCatalog(): Promise<PermissionCatalog> {
const res = await api.get('/api/authz/catalog')
return {
resources: res.data?.data?.resources ?? [],
roles: res.data?.data?.roles ?? [],
}
}
// ============================================================================
// Admin Binding Management APIs
// ============================================================================
@@ -23,9 +23,19 @@ import { useQuery } from '@tanstack/react-query'
import { Pencil } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
EMPTY_PERMISSION_CATALOG,
hasPermission,
normalizeAdminPermissions,
} from '@/lib/admin-permissions'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
import { ROLE } from '@/lib/roles'
import { useAuthStore } from '@/stores/auth-store'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Form,
FormControl,
@@ -62,7 +72,13 @@ import {
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { createUser, updateUser, getUser, getGroups } from '../api'
import {
createUser,
updateUser,
getUser,
getGroups,
getPermissionCatalog,
} from '../api'
import { BINDING_FIELDS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import {
userFormSchema,
@@ -89,6 +105,7 @@ export function UsersMutateDrawer({
const { t } = useTranslation()
const isUpdate = !!currentRow
const { triggerRefresh } = useUsers()
const currentUser = useAuthStore((s) => s.auth.user)
const [isSubmitting, setIsSubmitting] = useState(false)
const [quotaDialogOpen, setQuotaDialogOpen] = useState(false)
@@ -101,6 +118,13 @@ export function UsersMutateDrawer({
const groups = groupsData?.data || []
// Permission catalog is owned by the backend; fetched once and reused.
const { data: permissionCatalog = EMPTY_PERMISSION_CATALOG } = useQuery({
queryKey: ['admin-permission-catalog'],
queryFn: getPermissionCatalog,
staleTime: 5 * 60 * 1000,
})
const form = useForm<UserFormValues>({
resolver: zodResolver(userFormSchema),
defaultValues: USER_FORM_DEFAULT_VALUES,
@@ -126,6 +150,9 @@ export function UsersMutateDrawer({
const tokensOnly = currencyMeta.kind === 'tokens'
const currentQuotaRaw = form.watch('quota_dollars') || 0
const selectedRole = form.watch('role')
const canEditAdminPermissions = currentUser?.role === ROLE.SUPER_ADMIN
const targetIsAdmin = (selectedRole ?? currentRow?.role ?? 0) >= ROLE.ADMIN
const onSubmit = async (data: UserFormValues) => {
if (!isUpdate) {
@@ -141,7 +168,11 @@ export function UsersMutateDrawer({
setIsSubmitting(true)
try {
const payload = transformFormDataToPayload(data, currentRow?.id)
const payload = transformFormDataToPayload(
data,
currentRow?.id,
permissionCatalog
)
const result = isUpdate
? await updateUser(payload as typeof payload & { id: number })
: await createUser(payload)
@@ -417,6 +448,92 @@ export function UsersMutateDrawer({
</SideDrawerSection>
)}
{canEditAdminPermissions &&
targetIsAdmin &&
permissionCatalog.resources.length > 0 && (
<SideDrawerSection>
<h3 className='text-sm font-medium'>
{t('Admin Permissions')}
</h3>
<p className='text-muted-foreground text-xs'>
{t(
'Default administrator permissions can be overridden for this user.'
)}
</p>
<FormField
control={form.control}
name='admin_permissions'
render={({ field }) => {
const selected = normalizeAdminPermissions(
field.value,
permissionCatalog
)
return (
<FormItem>
<div className='space-y-3'>
{permissionCatalog.resources.map((resource) => (
<div
key={resource.resource}
className='space-y-2 rounded-md border p-3'
>
<div className='text-sm font-medium'>
{t(resource.label_key)}
</div>
<div className='space-y-2'>
{resource.actions.map((option) => (
<label
key={option.action}
className='flex items-start gap-3'
>
<Checkbox
checked={
selected[resource.resource]?.[
option.action
] === true
}
onCheckedChange={(checked) => {
field.onChange({
...selected,
[resource.resource]: {
...selected[resource.resource],
[option.action]: checked === true,
},
})
}}
/>
<span className='flex flex-col gap-1'>
<span className='text-sm font-medium'>
{t(option.label_key)}
</span>
<span className='text-muted-foreground text-xs'>
{t(option.description_key)}
</span>
</span>
</label>
))}
</div>
</div>
))}
</div>
<FormMessage />
</FormItem>
)
}}
/>
{currentUser && (
<p className='text-muted-foreground text-xs'>
{hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
? t('Your account can edit sensitive channel settings.')
: t('Your account cannot edit sensitive channel settings.')}
</p>
)}
</SideDrawerSection>
)}
{/* Binding Information (Read-only) */}
{isUpdate && (
<SideDrawerSection>
+28 -3
View File
@@ -18,6 +18,12 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { z } from 'zod'
import { quotaUnitsToDollars } from '@/lib/format'
import {
type PermissionCatalog,
type AdminPermissionMatrix,
normalizeAdminPermissions,
} from '@/lib/admin-permissions'
import { ROLE } from '@/lib/roles'
import { DEFAULT_GROUP } from '../constants'
import { type UserFormData, type User } from '../types'
@@ -33,6 +39,7 @@ export const userFormSchema = z.object({
quota_dollars: z.number().min(0).optional(),
group: z.string().optional(),
remark: z.string().optional(),
admin_permissions: z.record(z.string(), z.record(z.string(), z.boolean())).optional(),
})
export type UserFormValues = z.infer<typeof userFormSchema>
@@ -49,6 +56,8 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = {
quota_dollars: 0,
group: DEFAULT_GROUP,
remark: '',
// Filled against the backend catalog at render time; see UsersMutateDrawer.
admin_permissions: {},
}
// ============================================================================
@@ -60,7 +69,8 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = {
*/
export function transformFormDataToPayload(
data: UserFormValues,
userId?: number
userId?: number,
catalog?: PermissionCatalog
): UserFormData & { id?: number } {
const payload: UserFormData & { id?: number } = {
username: data.username,
@@ -68,9 +78,21 @@ export function transformFormDataToPayload(
password: data.password || undefined,
}
const role = userId === undefined ? data.role || 1 : (data.role ?? 0)
// Only send the permission matrix when the target is an admin and the catalog
// is available; without the catalog we cannot build a full matrix, so we omit
// the field (the backend then leaves existing permissions untouched).
if (role >= ROLE.ADMIN && catalog) {
payload.admin_permissions = normalizeAdminPermissions(
data.admin_permissions as AdminPermissionMatrix | undefined,
catalog
)
}
// For create: only send required fields
if (userId === undefined) {
payload.role = data.role || 1 // Default to common user
payload.role = role
} else {
// For update: quota is adjusted atomically via /api/user/manage, not sent here
payload.group = data.group
@@ -82,7 +104,9 @@ export function transformFormDataToPayload(
}
/**
* Transform user data to form defaults
* Transform user data to form defaults. The admin permission matrix is passed
* through as-is (the backend already returns a full matrix); it is filled against
* the catalog at render time in UsersMutateDrawer.
*/
export function transformUserToFormDefaults(user: User): UserFormValues {
return {
@@ -93,5 +117,6 @@ export function transformUserToFormDefaults(user: User): UserFormValues {
quota_dollars: quotaUnitsToDollars(user.quota),
group: user.group || DEFAULT_GROUP,
remark: user.remark || '',
admin_permissions: user.admin_permissions ?? {},
}
}
+3
View File
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { z } from 'zod'
import type { AdminPermissionMatrix } from '@/lib/admin-permissions'
// ============================================================================
// User Schema & Types
@@ -57,6 +58,7 @@ export const userSchema = z.object({
last_login_at: z.number().optional(),
DeletedAt: z.any().nullable().optional(),
remark: z.string().optional(),
admin_permissions: z.record(z.string(), z.record(z.string(), z.boolean())).optional(),
})
export type User = z.infer<typeof userSchema>
@@ -106,6 +108,7 @@ export interface UserFormData {
quota?: number // Only used when updating user
group?: string // Only used when updating user
remark?: string // Only used when updating user
admin_permissions?: AdminPermissionMatrix
}
export type ManageUserAction =