Skip to content

Commit d061ac7

Browse files
authored
feat(channels): add manual disable/enable API key in channel edit panel (#1990)
1 parent 395af3c commit d061ac7

3 files changed

Lines changed: 150 additions & 43 deletions

File tree

frontend/src/features/channels/components/channels-action-dialog.tsx

Lines changed: 144 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { z } from 'zod';
55
import { useForm } from 'react-hook-form';
66
import { zodResolver } from '@hookform/resolvers/zod';
77
import { useVirtualizer } from '@tanstack/react-virtual';
8-
import { X, RefreshCw, Search, ChevronLeft, ChevronRight, PanelLeft, Plus, Trash2, Eye, EyeOff, Copy, Play, Info } from 'lucide-react';
8+
import { X, RefreshCw, Search, ChevronLeft, ChevronRight, PanelLeft, Plus, Trash2, Eye, EyeOff, Copy, Play, Info, Ban } from 'lucide-react';
99
import { useTranslation } from 'react-i18next';
1010
import { toast } from 'sonner';
1111
import { Badge } from '@/components/ui/badge';
@@ -34,6 +34,8 @@ import {
3434
useAllChannelNames,
3535
useAllChannelTags,
3636
useChannelDisabledAPIKeys,
37+
useDisableChannelAPIKey,
38+
useEnableChannelAPIKey,
3739
useSyncChannelModels,
3840
} from '../data/channels';
3941
import { claudecodeOAuthExchange, claudecodeOAuthStart } from '../data/claudecode';
@@ -356,6 +358,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
356358
const [selectedKeysToRemove, setSelectedKeysToRemove] = useState<Set<string>>(new Set());
357359
const [confirmRemoveSelectedOpen, setConfirmRemoveSelectedOpen] = useState(false);
358360
const [confirmRemoveKey, setConfirmRemoveKey] = useState<string | null>(null);
361+
const [confirmDisableKey, setConfirmDisableKey] = useState<string | null>(null);
359362
const [showOpenCodeGoAuthCookie, setShowOpenCodeGoAuthCookie] = useState(false);
360363
const [authMode, setAuthMode] = useState<'official' | 'auth-json' | 'third-party'>('official');
361364
const [codexAuthJSONText, setCodexAuthJSONText] = useState('');
@@ -515,6 +518,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
515518
setSelectedKeysToRemove(new Set());
516519
setConfirmRemoveSelectedOpen(false);
517520
setConfirmRemoveKey(null);
521+
setConfirmDisableKey(null);
518522
setShowOpenCodeGoAuthCookie(false);
519523
setPatternError(null);
520524
setFetchedModels([]);
@@ -718,12 +722,21 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
718722
const apiKeysCount = useMemo(() => (apiKeys || []).filter((k) => k.trim().length > 0).length, [apiKeys]);
719723
const isSubmitting = createChannel.isPending || duplicateChannel.isPending || updateChannel.isPending;
720724

721-
const { data: disabledKeys = [] } = useChannelDisabledAPIKeys(currentRow?.id || '', {
725+
const { data: disabledKeys = [], isFetching: isFetchingDisabledKeys } = useChannelDisabledAPIKeys(currentRow?.id || '', {
722726
enabled: isEdit && !!currentRow?.id && showApiKeysPanel,
723727
});
724728

725729
const disabledKeySet = useMemo(() => new Set(disabledKeys.map((dk) => dk.key)), [disabledKeys]);
726730

731+
// Keys that exist in the backend (used to hide disable/enable button for unsaved new keys)
732+
const savedAPIKeySet = useMemo(
733+
() => new Set((currentRow?.credentials?.apiKeys || []).filter((k) => k.trim().length > 0)),
734+
[currentRow?.credentials?.apiKeys]
735+
);
736+
737+
const disableAPIKey = useDisableChannelAPIKey();
738+
const enableAPIKey = useEnableChannelAPIKey();
739+
727740
useEffect(() => {
728741
if (!open || !isDuplicate || !duplicateFromRow) return;
729742
if (!allChannelNamesLoaded) return;
@@ -1565,6 +1578,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
15651578
setSelectedKeysToRemove(new Set());
15661579
setConfirmRemoveSelectedOpen(false);
15671580
setConfirmRemoveKey(null);
1581+
setConfirmDisableKey(null);
15681582
}, []);
15691583

15701584
const removeApiKeys = useCallback(
@@ -1665,6 +1679,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
16651679
setSelectedKeysToRemove(new Set());
16661680
setConfirmRemoveSelectedOpen(false);
16671681
setConfirmRemoveKey(null);
1682+
setConfirmDisableKey(null);
16681683
setShowApiKey(false);
16691684
setShowOpenCodeGoAuthCookie(false);
16701685
// Reset proxy state
@@ -2947,6 +2962,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
29472962
{(() => {
29482963
const validKeys = (apiKeys || []).map((k) => k.trim()).filter((k) => k.length > 0);
29492964
const isLastKey = validKeys.length <= 1;
2965+
const enabledKeysCount = validKeys.filter((k) => savedAPIKeySet.has(k) && !disabledKeySet.has(k)).length;
29502966
return validKeys
29512967
.filter((k) => {
29522968
if (!apiKeysSearch.trim()) return true;
@@ -2956,6 +2972,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
29562972
.map((key) => {
29572973
const isSelected = selectedKeysToRemove.has(key);
29582974
const isDisabled = disabledKeySet.has(key);
2975+
const isSavedKey = savedAPIKeySet.has(key);
29592976
const masked = key.length > 8 ? `${key.slice(0, 4)}****${key.slice(-4)}` : `****${key.slice(-4)}`;
29602977

29612978
return (
@@ -2993,50 +3010,133 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
29933010
</div>
29943011
</div>
29953012

2996-
{isLastKey ? (
2997-
<Tooltip>
2998-
<TooltipTrigger asChild>
2999-
<span className='inline-flex'>
3000-
<Button
3001-
type='button'
3002-
variant='ghost'
3003-
size='sm'
3004-
className='text-muted-foreground h-7 w-7 p-0'
3005-
disabled
3006-
>
3007-
<Trash2 className='h-4 w-4' />
3008-
</Button>
3009-
</span>
3010-
</TooltipTrigger>
3011-
<TooltipContent>
3012-
<p>{t('channels.dialogs.fields.apiKey.mustKeepOne')}</p>
3013-
</TooltipContent>
3014-
</Tooltip>
3015-
) : (
3016-
<Popover
3017-
open={confirmRemoveKey === key}
3018-
onOpenChange={(isOpen) => setConfirmRemoveKey(isOpen ? key : null)}
3019-
>
3020-
<PopoverTrigger asChild>
3021-
<Button type='button' variant='ghost' size='sm' className='text-destructive h-7 w-7 p-0'>
3022-
<Trash2 className='h-4 w-4' />
3023-
</Button>
3024-
</PopoverTrigger>
3025-
<PopoverContent className='w-72'>
3026-
<div className='flex flex-col gap-3'>
3027-
<p className='text-sm'>{t('channels.dialogs.fields.apiKey.confirmRemoveSingle')}</p>
3028-
<div className='flex justify-end gap-2'>
3029-
<Button size='sm' variant='outline' onClick={() => setConfirmRemoveKey(null)}>
3030-
{t('common.buttons.cancel')}
3013+
<div className='flex items-center gap-1'>
3014+
{/* Disable / Enable button — only for keys saved in backend */}
3015+
{isSavedKey &&
3016+
(isDisabled ? (
3017+
<Popover
3018+
open={confirmDisableKey === key}
3019+
onOpenChange={(isOpen) => setConfirmDisableKey(isOpen ? key : null)}
3020+
>
3021+
<PopoverTrigger asChild>
3022+
<Button type='button' variant='ghost' size='sm' className='h-7 w-7 p-0'>
3023+
<RefreshCw className='h-4 w-4' />
30313024
</Button>
3032-
<Button size='sm' variant='destructive' onClick={() => removeApiKeys([key])}>
3033-
{t('common.buttons.confirm')}
3025+
</PopoverTrigger>
3026+
<PopoverContent className='w-72'>
3027+
<div className='flex flex-col gap-3'>
3028+
<p className='text-sm'>{t('channels.dialogs.fields.apiKey.confirmEnable')}</p>
3029+
<div className='flex justify-end gap-2'>
3030+
<Button size='sm' variant='outline' onClick={() => setConfirmDisableKey(null)}>
3031+
{t('common.buttons.cancel')}
3032+
</Button>
3033+
<Button
3034+
size='sm'
3035+
onClick={async () => {
3036+
if (!currentRow?.id) return;
3037+
await enableAPIKey.mutateAsync({ channelID: currentRow.id, key });
3038+
setConfirmDisableKey(null);
3039+
}}
3040+
>
3041+
{t('common.buttons.confirm')}
3042+
</Button>
3043+
</div>
3044+
</div>
3045+
</PopoverContent>
3046+
</Popover>
3047+
) : enabledKeysCount <= 1 ? (
3048+
<Tooltip>
3049+
<TooltipTrigger asChild>
3050+
<span className='inline-flex'>
3051+
<Button type='button' variant='ghost' size='sm' className='text-muted-foreground h-7 w-7 p-0' disabled>
3052+
<Ban className='h-4 w-4' />
3053+
</Button>
3054+
</span>
3055+
</TooltipTrigger>
3056+
<TooltipContent>
3057+
<p>{t('channels.dialogs.fields.apiKey.mustKeepOneEnabled')}</p>
3058+
</TooltipContent>
3059+
</Tooltip>
3060+
) : (
3061+
<Popover
3062+
open={confirmDisableKey === key}
3063+
onOpenChange={(isOpen) => setConfirmDisableKey(isOpen ? key : null)}
3064+
>
3065+
<PopoverTrigger asChild>
3066+
<Button type='button' variant='ghost' size='sm' className='text-orange-500 h-7 w-7 p-0' disabled={disableAPIKey.isPending || isFetchingDisabledKeys}>
3067+
<Ban className='h-4 w-4' />
30343068
</Button>
3069+
</PopoverTrigger>
3070+
<PopoverContent className='w-72'>
3071+
<div className='flex flex-col gap-3'>
3072+
<p className='text-sm'>{t('channels.dialogs.fields.apiKey.confirmDisable')}</p>
3073+
<div className='flex justify-end gap-2'>
3074+
<Button size='sm' variant='outline' onClick={() => setConfirmDisableKey(null)}>
3075+
{t('common.buttons.cancel')}
3076+
</Button>
3077+
<Button
3078+
size='sm'
3079+
disabled={disableAPIKey.isPending || isFetchingDisabledKeys}
3080+
onClick={async () => {
3081+
if (!currentRow?.id) return;
3082+
await disableAPIKey.mutateAsync({ channelID: currentRow.id, key });
3083+
setConfirmDisableKey(null);
3084+
}}
3085+
>
3086+
{t('common.buttons.confirm')}
3087+
</Button>
3088+
</div>
3089+
</div>
3090+
</PopoverContent>
3091+
</Popover>
3092+
))}
3093+
3094+
{/* Delete button */}
3095+
{isLastKey ? (
3096+
<Tooltip>
3097+
<TooltipTrigger asChild>
3098+
<span className='inline-flex'>
3099+
<Button
3100+
type='button'
3101+
variant='ghost'
3102+
size='sm'
3103+
className='text-muted-foreground h-7 w-7 p-0'
3104+
disabled
3105+
>
3106+
<Trash2 className='h-4 w-4' />
3107+
</Button>
3108+
</span>
3109+
</TooltipTrigger>
3110+
<TooltipContent>
3111+
<p>{t('channels.dialogs.fields.apiKey.mustKeepOne')}</p>
3112+
</TooltipContent>
3113+
</Tooltip>
3114+
) : (
3115+
<Popover
3116+
open={confirmRemoveKey === key}
3117+
onOpenChange={(isOpen) => setConfirmRemoveKey(isOpen ? key : null)}
3118+
>
3119+
<PopoverTrigger asChild>
3120+
<Button type='button' variant='ghost' size='sm' className='text-destructive h-7 w-7 p-0'>
3121+
<Trash2 className='h-4 w-4' />
3122+
</Button>
3123+
</PopoverTrigger>
3124+
<PopoverContent className='w-72'>
3125+
<div className='flex flex-col gap-3'>
3126+
<p className='text-sm'>{t('channels.dialogs.fields.apiKey.confirmRemoveSingle')}</p>
3127+
<div className='flex justify-end gap-2'>
3128+
<Button size='sm' variant='outline' onClick={() => setConfirmRemoveKey(null)}>
3129+
{t('common.buttons.cancel')}
3130+
</Button>
3131+
<Button size='sm' variant='destructive' onClick={() => removeApiKeys([key])}>
3132+
{t('common.buttons.confirm')}
3133+
</Button>
3134+
</div>
30353135
</div>
3036-
</div>
3037-
</PopoverContent>
3038-
</Popover>
3039-
)}
3136+
</PopoverContent>
3137+
</Popover>
3138+
)}
3139+
</div>
30403140
</div>
30413141
);
30423142
});
@@ -3079,6 +3179,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe
30793179
setSelectedKeysToRemove(new Set());
30803180
setConfirmRemoveSelectedOpen(false);
30813181
setConfirmRemoveKey(null);
3182+
setConfirmDisableKey(null);
30823183
}}
30833184
disabled={selectedKeysToRemove.size === 0}
30843185
>

frontend/src/locales/en/channels.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,9 @@
323323
"channels.dialogs.fields.apiKey.confirmRemoveSingle": "Remove this API key from the list? (Save to apply)",
324324
"channels.dialogs.fields.apiKey.confirmRemoveSelected": "Remove {{count}} selected API key(s) from the list? (Save to apply)",
325325
"channels.dialogs.fields.apiKey.mustKeepOne": "At least one API key must be retained",
326+
"channels.dialogs.fields.apiKey.mustKeepOneEnabled": "At least one enabled key is required",
327+
"channels.dialogs.fields.apiKey.confirmDisable": "Are you sure you want to disable this API key? It will no longer be used for request routing.",
328+
"channels.dialogs.fields.apiKey.confirmEnable": "Are you sure you want to enable this API key?",
326329
"channels.dialogs.fields.opencodeGoQuota.workspaceId.label": "Workspace ID",
327330
"channels.dialogs.fields.opencodeGoQuota.workspaceId.placeholder": "workspace-id",
328331
"channels.dialogs.fields.opencodeGoQuota.workspaceId.description": "OpenCode Go workspace ID from the workspace dashboard URL.",

frontend/src/locales/zh-CN/channels.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,9 @@
320320
"channels.dialogs.fields.apiKey.confirmRemoveSingle": "确认从列表中删除该 API Key 吗?(保存后才会生效)",
321321
"channels.dialogs.fields.apiKey.confirmRemoveSelected": "确认从列表中删除 {{count}} 个选中的 API Key 吗?(保存后才会生效)",
322322
"channels.dialogs.fields.apiKey.mustKeepOne": "至少需要保留一个 API Key",
323+
"channels.dialogs.fields.apiKey.mustKeepOneEnabled": "至少需要保留一个可用 API Key",
324+
"channels.dialogs.fields.apiKey.confirmDisable": "确定要禁用这个 API Key 吗?禁用后该密钥将不再参与请求分发。",
325+
"channels.dialogs.fields.apiKey.confirmEnable": "确定要启用这个 API Key 吗?",
323326
"channels.dialogs.fields.opencodeGoQuota.workspaceId.label": "Workspace ID",
324327
"channels.dialogs.fields.opencodeGoQuota.workspaceId.placeholder": "workspace-id",
325328
"channels.dialogs.fields.opencodeGoQuota.workspaceId.description": "OpenCode Go 工作区仪表盘 URL 中的 workspace ID。",

0 commit comments

Comments
 (0)