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:
+30
@@ -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
|
||||
*/
|
||||
|
||||
+42
-11
@@ -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)
|
||||
}}
|
||||
|
||||
+36
-1
@@ -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}
|
||||
/>
|
||||
),
|
||||
|
||||
+12
-1
@@ -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>
|
||||
|
||||
+243
-82
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user