2
0
Эх сурвалжийг харах

fix: prevent disabling all keys in edit dialog

- Add form submission validation to ensure at least one key remains enabled
- Disable Switch and show tooltip when trying to disable the last enabled key
- Add i18n translations for all locales (zh-CN, en, ja, ru, zh-TW)

Related to issue #431 and key deletion protection improvements
NightYu 1 сар өмнө
parent
commit
7b9562461c

+ 3 - 1
messages/en/dashboard.json

@@ -1157,6 +1157,7 @@
       "keySaveFailed": "Failed to save key",
       "keyDeleteFailed": "Failed to delete key",
       "saveSuccess": "Changes saved successfully",
+      "atLeastOneKeyEnabled": "At least one key must be enabled",
       "operationFailed": "Operation failed",
       "userDisabled": "User has been disabled",
       "userEnabled": "User has been enabled",
@@ -1475,7 +1476,8 @@
         },
         "enableStatus": {
           "label": "Enable Status",
-          "description": "Disabled keys cannot be used"
+          "description": "Disabled keys cannot be used",
+          "cannotDisableTooltip": "Cannot disable the last enabled key"
         },
         "balanceQueryPage": {
           "label": "Independent Personal Usage Page",

+ 3 - 1
messages/ja/dashboard.json

@@ -1119,6 +1119,7 @@
       "keySaveFailed": "キーの保存に失敗しました",
       "keyDeleteFailed": "キーの削除に失敗しました",
       "saveSuccess": "変更が保存されました",
+      "atLeastOneKeyEnabled": "少なくとも1つのキーを有効にする必要があります",
       "operationFailed": "操作に失敗しました",
       "userDisabled": "ユーザーが無効化されました",
       "userEnabled": "ユーザーが有効化されました",
@@ -1428,7 +1429,8 @@
         },
         "enableStatus": {
           "label": "有効状態",
-          "description": "無効化されたキーは使用できません"
+          "description": "無効化されたキーは使用できません",
+          "cannotDisableTooltip": "最後の有効なキーを無効にできません"
         },
         "balanceQueryPage": {
           "label": "独立した個人使用量ページ",

+ 3 - 1
messages/ru/dashboard.json

@@ -1130,6 +1130,7 @@
       "keySaveFailed": "Не удалось сохранить ключ",
       "keyDeleteFailed": "Не удалось удалить ключ",
       "saveSuccess": "Изменения сохранены",
+      "atLeastOneKeyEnabled": "Необходимо оставить хотя бы один активный ключ",
       "operationFailed": "Операция не удалась",
       "userDisabled": "Пользователь отключен",
       "userEnabled": "Пользователь активирован",
@@ -1441,7 +1442,8 @@
         },
         "enableStatus": {
           "label": "Статус включения",
-          "description": "Отключённые ключи не могут использоваться"
+          "description": "Отключённые ключи не могут использоваться",
+          "cannotDisableTooltip": "Невозможно отключить последний активный ключ"
         },
         "balanceQueryPage": {
           "label": "Независимая страница использования",

+ 3 - 1
messages/zh-CN/dashboard.json

@@ -1158,6 +1158,7 @@
       "keySaveFailed": "保存密钥失败",
       "keyDeleteFailed": "删除密钥失败",
       "saveSuccess": "保存成功",
+      "atLeastOneKeyEnabled": "至少需要保留一个启用的密钥",
       "operationFailed": "操作失败",
       "userDisabled": "用户已禁用",
       "userEnabled": "用户已启用",
@@ -1474,7 +1475,8 @@
         },
         "enableStatus": {
           "label": "启用状态",
-          "description": "禁用后此密钥将无法使用。禁用后仅管理员可启用。"
+          "description": "禁用后此密钥将无法使用。禁用后仅管理员可启用。",
+          "cannotDisableTooltip": "无法禁用最后一个启用的密钥"
         },
         "balanceQueryPage": {
           "label": "独立个人用量页面",

+ 3 - 1
messages/zh-TW/dashboard.json

@@ -1131,6 +1131,7 @@
       "keySaveFailed": "儲存金鑰失敗",
       "keyDeleteFailed": "刪除金鑰失敗",
       "saveSuccess": "儲存成功",
+      "atLeastOneKeyEnabled": "至少需要保留一個啟用的金鑰",
       "operationFailed": "操作失敗",
       "userDisabled": "使用者已停用",
       "userEnabled": "使用者已啟用",
@@ -1440,7 +1441,8 @@
         },
         "enableStatus": {
           "label": "啟用狀態",
-          "description": "停用後此金鑰將無法使用。停用後僅管理員可啟用。"
+          "description": "停用後此金鑰將無法使用。停用後僅管理員可啟用。",
+          "cannotDisableTooltip": "無法停用最後一個啟用的金鑰"
         },
         "balanceQueryPage": {
           "label": "獨立個人用量頁面",

+ 29 - 6
src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx

@@ -16,6 +16,7 @@ import {
   SelectValue,
 } from "@/components/ui/select";
 import { Switch } from "@/components/ui/switch";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { cn } from "@/lib/utils";
 import { type DailyResetMode, LimitRulePicker, type LimitType } from "./limit-rule-picker";
@@ -44,6 +45,8 @@ export interface KeyEditSectionProps {
   };
   /** Admin 可自由编辑 providerGroup */
   isAdmin?: boolean;
+  /** 是否是最后一个启用的 key (用于禁用 Switch 防止全部禁用) */
+  isLastEnabledKey?: boolean;
   userProviderGroup?: string;
   onChange: {
     (field: string, value: any): void;
@@ -74,7 +77,11 @@ export interface KeyEditSectionProps {
         noGroupHint?: string;
       };
       cacheTtl: { label: string; options: Record<string, string> };
-      enableStatus?: { label: string; description: string };
+      enableStatus?: {
+        label: string;
+        description: string;
+        cannotDisableTooltip?: string;
+      };
     };
     limitRules: any;
     quickExpire: any;
@@ -127,6 +134,7 @@ const TTL_ORDER = ["inherit", "5m", "1h"] as const;
 export function KeyEditSection({
   keyData,
   isAdmin = false,
+  isLastEnabledKey = false,
   userProviderGroup,
   onChange,
   scrollRef,
@@ -339,11 +347,26 @@ export function KeyEditSection({
               {translations.fields.enableStatus?.description || "Disabled keys cannot be used"}
             </p>
           </div>
-          <Switch
-            id={`key-enable-${keyData.id}`}
-            checked={keyData.isEnabled ?? true}
-            onCheckedChange={(checked) => onChange("isEnabled", checked)}
-          />
+          <Tooltip>
+            <TooltipTrigger asChild>
+              <div className="flex items-center">
+                <Switch
+                  id={`key-enable-${keyData.id}`}
+                  checked={keyData.isEnabled ?? true}
+                  disabled={isLastEnabledKey}
+                  onCheckedChange={(checked) => onChange("isEnabled", checked)}
+                />
+              </div>
+            </TooltipTrigger>
+            {isLastEnabledKey && (
+              <TooltipContent>
+                <p className="text-xs">
+                  {translations.fields.enableStatus?.cannotDisableTooltip ||
+                    "Cannot disable the last enabled key"}
+                </p>
+              </TooltipContent>
+            )}
+          </Tooltip>
         </div>
       </section>
 

+ 13 - 0
src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx

@@ -299,6 +299,15 @@ function UnifiedEditDialogInner({
     onSubmit: async (data) => {
       startTransition(async () => {
         try {
+          // 验证: 编辑模式下,至少需要一个启用的 key(防止用户禁用所有 key)
+          if (mode === "edit") {
+            const enabledKeyCount = data.keys.filter((k) => k.isEnabled).length;
+            if (enabledKeyCount === 0) {
+              toast.error(t("editDialog.atLeastOneKeyEnabled"));
+              return;
+            }
+          }
+
           if (mode === "create") {
             if (isKeyOnlyMode) {
               const targetUserId = user?.id ?? currentUser?.id;
@@ -916,6 +925,9 @@ function UnifiedEditDialogInner({
                 const isExpanded =
                   mode === "create" || keys.length === 1 || expandedKeyIds.has(key.id);
                 const showCollapseButton = mode === "edit" && keys.length > 1;
+                // 计算当前是否是最后一个启用的 key
+                const enabledKeysCount = keys.filter((k) => k.isEnabled).length;
+                const isLastEnabledKey = key.isEnabled && enabledKeysCount === 1;
 
                 return (
                   <div
@@ -1003,6 +1015,7 @@ function UnifiedEditDialogInner({
                             limitConcurrentSessions: key.limitConcurrentSessions ?? 0,
                           }}
                           isAdmin={isAdmin}
+                          isLastEnabledKey={isLastEnabledKey}
                           userProviderGroup={user?.providerGroup ?? undefined}
                           onChange={
                             ((fieldOrBatch: string | Record<string, any>, value?: any) =>