Browse Source

feat(user-management): add quick renewal dialog and relocate enable toggle

- Add QuickRenewDialog component for fast user expiration renewal
  - Quick selection buttons: 7/30/90 days, 1 year
  - Custom date picker with DatePickerField
  - Option to enable disabled users during renewal
  - Returns { ok: boolean } to prevent premature dialog closing on errors

- Move enable/disable toggle from DangerZone to UserEditSection
  - Enable/disable is reversible, not a "dangerous" operation
  - DangerZone now only handles irreversible delete action
  - Added Switch component with AlertDialog confirmation

- Enhance search/filter with multi-tag providerGroup support
  - splitTags() function for comma-separated tag parsing
  - Consistent with backend normalizeProviderGroup pattern

- Update i18n translations for all 5 languages (en, ja, ru, zh-CN, zh-TW)
  - Added quickRenew section with all required keys
  - Added expiresAtHint for table column tooltip
  - Added enableStatus fields for UserEditSection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
ding113 2 months ago
parent
commit
1d6bc73af3

+ 1 - 3
CLAUDE.md

@@ -1,4 +1,2 @@
 @.env.example
[email protected]
[email protected]
-@docs/product-brief-claude-code-hub-2025-11-29.md
[email protected]

+ 29 - 1
messages/en/dashboard.json

@@ -828,7 +828,7 @@
     "title": "User Management",
     "description": "Showing {count} users",
     "toolbar": {
-      "searchPlaceholder": "Search by username...",
+      "searchPlaceholder": "Search name, note, tags, keys...",
       "groupFilter": "Filter by Group",
       "allGroups": "All Groups",
       "tagFilter": "Filter by Tag",
@@ -956,6 +956,7 @@
         "username": "Username",
         "note": "Note",
         "expiresAt": "Expires at",
+        "expiresAtHint": "Click to quick renew",
         "limit5h": "5h limit",
         "limitDaily": "Daily limit",
         "limitWeekly": "Weekly limit",
@@ -1004,6 +1005,26 @@
       "totalCalls": "Total Calls",
       "totalCost": "Total Cost"
     },
+    "quickRenew": {
+      "title": "Quick Renew",
+      "description": "Set a new expiration date for user {userName}",
+      "currentExpiry": "Current Expiration",
+      "neverExpires": "Never expires",
+      "expired": "Expired",
+      "quickOptions": {
+        "7days": "7 Days",
+        "30days": "30 Days",
+        "90days": "90 Days",
+        "1year": "1 Year"
+      },
+      "customDate": "Custom Date",
+      "enableOnRenew": "Also enable user",
+      "cancel": "Cancel",
+      "confirm": "Confirm Renewal",
+      "confirming": "Renewing...",
+      "success": "Renewal successful",
+      "failed": "Renewal failed"
+    },
     "editDialog": {
       "title": "Edit user and keys",
       "description": "Edit user information and API key settings",
@@ -1201,6 +1222,13 @@
           "label": "Model Restrictions",
           "placeholder": "Enter model name or select from dropdown",
           "description": "Restrict which AI models this user can access. Empty = no restriction."
+        },
+        "enableStatus": {
+          "label": "Enable Status",
+          "enabledDescription": "Currently enabled. Disabling will prevent this user and their keys from being used.",
+          "disabledDescription": "Currently disabled. Enabling will restore normal access for this user and their keys.",
+          "confirmDisable": "Confirm Disable",
+          "confirmEnable": "Confirm Enable"
         }
       },
       "presetClients": {

+ 35 - 10
messages/ja/dashboard.json

@@ -422,10 +422,7 @@
     },
     "labels": {
       "byName": "名前順",
-      "byUsageRate": "使用率順",
-      "all": "すべて",
-      "warning": "制限に近い (>60%)",
-      "exceeded": "超過 (≥100%)"
+      "byUsageRate": "使用率順"
     },
     "users": {
       "title": "ユーザー クォータ統計",
@@ -574,13 +571,13 @@
     "todayUsage": "本日の使用量",
     "allowedModels": {
       "label": "許可モデル",
-      "noRestrictions": "許可モデル:制限なし"
+      "noRestrictions": "許可されたクライアント:制限なし"
     },
     "expiresAt": "有効期限",
     "proxyStatus": {
-      "loading": "プロキシステータス読み込み中",
-      "fetchFailed": "プロキシステータスの取得に失敗しました",
-      "noStatus": "プロキシステータスなし",
+      "loading": "プロキシステータス読み込み中",
+      "fetchFailed": "プロキシステータスの取得に失敗しました",
+      "noStatus": "プロキシステータスなし",
       "activeRequests": "アクティブリクエスト",
       "lastRequest": "最新リクエスト",
       "noRecord": "記録なし",
@@ -676,7 +673,7 @@
     },
     "limitConcurrentSessions": {
       "label": "同時セッション上限",
-      "placeholder": "0 は無制限を意味します",
+      "placeholder": "0は無制限を意味します",
       "description": "同時に実行される会話の数"
     },
     "errors": {
@@ -800,7 +797,7 @@
     "title": "ユーザー管理",
     "description": "{count} 人のユーザーを表示中",
     "toolbar": {
-      "searchPlaceholder": "ユーザー名で検索...",
+      "searchPlaceholder": "名前、メモ、タグ、キーで検索...",
       "groupFilter": "グループでフィルター",
       "allGroups": "すべてのグループ",
       "tagFilter": "タグでフィルター",
@@ -928,6 +925,7 @@
         "username": "ユーザー名",
         "note": "メモ",
         "expiresAt": "有効期限",
+        "expiresAtHint": "クリックで期限を延長",
         "limit5h": "5時間上限",
         "limitDaily": "日次上限",
         "limitWeekly": "週次上限",
@@ -976,6 +974,26 @@
       "totalCalls": "総呼び出し数",
       "totalCost": "総消費"
     },
+    "quickRenew": {
+      "title": "クイック更新",
+      "description": "ユーザー {userName} の新しい有効期限を設定",
+      "currentExpiry": "現在の有効期限",
+      "neverExpires": "無期限",
+      "expired": "期限切れ",
+      "quickOptions": {
+        "7days": "7 日",
+        "30days": "30 日",
+        "90days": "90 日",
+        "1year": "1 年"
+      },
+      "customDate": "カスタム日付",
+      "enableOnRenew": "同時にユーザーを有効化",
+      "cancel": "キャンセル",
+      "confirm": "更新を確認",
+      "confirming": "更新中...",
+      "success": "更新に成功しました",
+      "failed": "更新に失敗しました"
+    },
     "editDialog": {
       "title": "ユーザーとキーを編集",
       "description": "ユーザー情報とAPIキーの設定を編集",
@@ -1171,6 +1189,13 @@
           "label": "モデル制限",
           "placeholder": "モデル名を入力またはドロップダウンから選択",
           "description": "ユーザーがアクセスできるAIモデルを制限します。空欄は制限なし。"
+        },
+        "enableStatus": {
+          "label": "有効状態",
+          "enabledDescription": "現在有効です。無効にすると、このユーザーとそのキーは使用できなくなります。",
+          "disabledDescription": "現在無効です。有効にすると、このユーザーとそのキーが通常通り使用できるようになります。",
+          "confirmDisable": "無効化を確認",
+          "confirmEnable": "有効化を確認"
         }
       },
       "presetClients": {

+ 30 - 2
messages/ru/dashboard.json

@@ -800,7 +800,7 @@
     "title": "Управление пользователями",
     "description": "Показано {count} пользователей",
     "toolbar": {
-      "searchPlaceholder": "Поиск по имени пользователя...",
+      "searchPlaceholder": "Поиск по имени, заметкам, тегам, ключам...",
       "groupFilter": "Фильтр по группе",
       "allGroups": "Все группы",
       "tagFilter": "Фильтр по тегу",
@@ -928,6 +928,7 @@
         "username": "Имя пользователя",
         "note": "Примечание",
         "expiresAt": "Дата истечения",
+        "expiresAtHint": "Нажмите для быстрого продления",
         "limit5h": "Лимит 5 ч",
         "limitDaily": "Дневной лимит",
         "limitWeekly": "Недельный лимит",
@@ -976,6 +977,26 @@
       "totalCalls": "Всего вызовов",
       "totalCost": "Общий расход"
     },
+    "quickRenew": {
+      "title": "Быстрое продление",
+      "description": "Установить новую дату истечения для пользователя {userName}",
+      "currentExpiry": "Текущий срок",
+      "neverExpires": "Бессрочно",
+      "expired": "Истёк",
+      "quickOptions": {
+        "7days": "7 дней",
+        "30days": "30 дней",
+        "90days": "90 дней",
+        "1year": "1 год"
+      },
+      "customDate": "Произвольная дата",
+      "enableOnRenew": "Также включить пользователя",
+      "cancel": "Отмена",
+      "confirm": "Подтвердить продление",
+      "confirming": "Продление...",
+      "success": "Продление успешно",
+      "failed": "Ошибка продления"
+    },
     "editDialog": {
       "title": "Редактировать пользователя и ключи",
       "description": "Редактирование данных пользователя и настроек API-ключей",
@@ -1017,7 +1038,7 @@
       "steps": {
         "welcome": {
           "title": "Пользователи и ключи",
-          "description": "Пользователи - основные субъекты для доступа к API. Каждый пользователь может иметь несколько API ключей. Лимиты на уровне пользователя влияют на все ключи, а лимиты на уровне ключа обеспечивают более точный контроль."
+          "description": "Пользователи - основные субъекты для доступа к API. Каждый пользователь может иметь несколько API ключей. Лимиты на уровне пользователя влияют на все ключи, а лимиты на уровне ключа обеспечивают более точный контроль использования."
         },
         "limits": {
           "title": "Управление лимитами",
@@ -1173,6 +1194,13 @@
           "label": "Ограничения моделей",
           "placeholder": "Введите название модели или выберите из списка",
           "description": "Ограничить AI модели для пользователя. Пусто = без ограничений."
+        },
+        "enableStatus": {
+          "label": "Статус включения",
+          "enabledDescription": "Сейчас включён. При отключении пользователь и его ключи станут недоступны.",
+          "disabledDescription": "Сейчас отключён. При включении пользователь и его ключи станут доступны.",
+          "confirmDisable": "Подтвердить отключение",
+          "confirmEnable": "Подтвердить включение"
         }
       },
       "presetClients": {

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

@@ -545,6 +545,7 @@
     "activeKeys": "活跃密钥",
     "totalKeys": "总密钥",
     "expiresAt": "过期时间",
+    "expiresAtHint": "用户过期后将自动禁用",
     "status": {
       "active": "已启用",
       "expiringSoon": "即将过期",
@@ -894,7 +895,7 @@
     "title": "用户管理",
     "description": "显示 {count} 个用户",
     "toolbar": {
-      "searchPlaceholder": "按用户名搜索...",
+      "searchPlaceholder": "搜索用户名、备注、标签、Key...",
       "groupFilter": "按分组筛选",
       "allGroups": "所有分组",
       "tagFilter": "按标签筛选",
@@ -1022,6 +1023,7 @@
         "username": "用户名",
         "note": "备注",
         "expiresAt": "到期时间",
+        "expiresAtHint": "点击快捷续期",
         "limit5h": "5h 限额",
         "limitDaily": "每日限额",
         "limitWeekly": "周限额",
@@ -1070,6 +1072,26 @@
       "totalCalls": "总调用",
       "totalCost": "总消费"
     },
+    "quickRenew": {
+      "title": "快捷续期",
+      "description": "为用户 {userName} 设置新的过期时间",
+      "currentExpiry": "当前到期时间",
+      "neverExpires": "永不过期",
+      "expired": "已过期",
+      "quickOptions": {
+        "7days": "7 天",
+        "30days": "30 天",
+        "90days": "90 天",
+        "1year": "1 年"
+      },
+      "customDate": "自定义日期",
+      "enableOnRenew": "同时启用用户",
+      "cancel": "取消",
+      "confirm": "确认续期",
+      "confirming": "续期中...",
+      "success": "续期成功",
+      "failed": "续期失败"
+    },
     "editDialog": {
       "title": "编辑用户与密钥",
       "description": "编辑用户信息和 API 密钥设置",
@@ -1265,6 +1287,13 @@
           "label": "模型限制",
           "placeholder": "输入模型名称或从下拉列表选择",
           "description": "限制用户只能使用指定的 AI 模型。留空表示无限制。"
+        },
+        "enableStatus": {
+          "label": "启用状态",
+          "enabledDescription": "当前已启用,禁用后该用户及其密钥将无法继续使用",
+          "disabledDescription": "当前已禁用,启用后该用户及其密钥将恢复正常使用",
+          "confirmDisable": "确认禁用",
+          "confirmEnable": "确认启用"
         }
       },
       "presetClients": {

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

@@ -801,7 +801,7 @@
     "title": "使用者管理",
     "description": "顯示 {count} 位使用者",
     "toolbar": {
-      "searchPlaceholder": "依使用者名稱搜尋...",
+      "searchPlaceholder": "搜尋使用者名稱、備註、標籤、Key...",
       "groupFilter": "依群組篩選",
       "allGroups": "所有群組",
       "tagFilter": "依標籤篩選",
@@ -929,6 +929,7 @@
         "username": "使用者名稱",
         "note": "備註",
         "expiresAt": "到期時間",
+        "expiresAtHint": "點擊快速續期",
         "limit5h": "5h 限額",
         "limitDaily": "每日限額",
         "limitWeekly": "週限額",
@@ -977,6 +978,26 @@
       "totalCalls": "今日總呼叫",
       "totalCost": "今日總消費"
     },
+    "quickRenew": {
+      "title": "快速續期",
+      "description": "為使用者 {userName} 設定新的過期時間",
+      "currentExpiry": "目前到期時間",
+      "neverExpires": "永不過期",
+      "expired": "已過期",
+      "quickOptions": {
+        "7days": "7 天",
+        "30days": "30 天",
+        "90days": "90 天",
+        "1year": "1 年"
+      },
+      "customDate": "自訂日期",
+      "enableOnRenew": "同時啟用使用者",
+      "cancel": "取消",
+      "confirm": "確認續期",
+      "confirming": "續期中...",
+      "success": "續期成功",
+      "failed": "續期失敗"
+    },
     "editDialog": {
       "title": "編輯使用者與金鑰",
       "description": "編輯使用者資訊和 API 金鑰設定",
@@ -1172,6 +1193,13 @@
           "label": "模型限制",
           "placeholder": "輸入模型名稱或從下拉選單選擇",
           "description": "限制使用者只能使用指定的 AI 模型。留空表示無限制。"
+        },
+        "enableStatus": {
+          "label": "啟用狀態",
+          "enabledDescription": "目前已啟用,停用後該使用者及其金鑰將無法繼續使用",
+          "disabledDescription": "目前已停用,啟用後該使用者及其金鑰將恢復正常使用",
+          "confirmDisable": "確認停用",
+          "confirmEnable": "確認啟用"
         }
       },
       "presetClients": {

+ 23 - 208
src/app/[locale]/dashboard/_components/user/forms/danger-zone.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { Loader2, ShieldCheck, ShieldOff, Trash2 } from "lucide-react";
+import { Loader2, Trash2 } from "lucide-react";
 import { useMemo, useState } from "react";
 import {
   AlertDialog,
@@ -21,20 +21,15 @@ import { cn } from "@/lib/utils";
 export interface DangerZoneProps {
   userId: number;
   userName: string;
-  isEnabled: boolean;
-  onEnable: () => Promise<void>;
-  onDisable: () => Promise<void>;
   onDelete: () => Promise<void>;
   /**
    * i18n strings passed from parent.
    * Expected keys (optional):
    * - title, description
-   * - enable.title, enable.description, enable.trigger, enable.confirm
-   * - disable.title, disable.description, disable.trigger, disable.confirm
    * - delete.title, delete.description, delete.trigger, delete.confirm
    * - delete.confirmHint (e.g. "Type {name} to confirm")
    * - actions.cancel
-   * - errors.enableFailed, errors.disableFailed, errors.deleteFailed
+   * - errors.deleteFailed
    */
   translations: Record<string, unknown>;
 }
@@ -49,25 +44,10 @@ function getTranslation(translations: Record<string, unknown>, path: string, fal
   return typeof value === "string" && value.trim() ? value : fallback;
 }
 
-export function DangerZone({
-  userId,
-  userName,
-  isEnabled,
-  onEnable,
-  onDisable,
-  onDelete,
-  translations,
-}: DangerZoneProps) {
-  const [enableOpen, setEnableOpen] = useState(false);
-  const [disableOpen, setDisableOpen] = useState(false);
+export function DangerZone({ userId, userName, onDelete, translations }: DangerZoneProps) {
   const [deleteOpen, setDeleteOpen] = useState(false);
-  const [isEnabling, setIsEnabling] = useState(false);
-  const [isDisabling, setIsDisabling] = useState(false);
   const [isDeleting, setIsDeleting] = useState(false);
   const [deleteConfirmText, setDeleteConfirmText] = useState("");
-
-  const [enableError, setEnableError] = useState<string | null>(null);
-  const [disableError, setDisableError] = useState<string | null>(null);
   const [deleteError, setDeleteError] = useState<string | null>(null);
 
   const canDelete = useMemo(
@@ -75,34 +55,6 @@ export function DangerZone({
     [deleteConfirmText, userName]
   );
 
-  const handleEnable = async () => {
-    setEnableError(null);
-    setIsEnabling(true);
-    try {
-      await onEnable();
-      setEnableOpen(false);
-    } catch (err) {
-      console.error("启用用户失败:", { userId, err });
-      setEnableError(getTranslation(translations, "errors.enableFailed", "操作失败,请稍后重试"));
-    } finally {
-      setIsEnabling(false);
-    }
-  };
-
-  const handleDisable = async () => {
-    setDisableError(null);
-    setIsDisabling(true);
-    try {
-      await onDisable();
-      setDisableOpen(false);
-    } catch (err) {
-      console.error("禁用用户失败:", { userId, err });
-      setDisableError(getTranslation(translations, "errors.disableFailed", "操作失败,请稍后重试"));
-    } finally {
-      setIsDisabling(false);
-    }
-  };
-
   const handleDelete = async () => {
     setDeleteError(null);
     setIsDeleting(true);
@@ -110,8 +62,10 @@ export function DangerZone({
       await onDelete();
       setDeleteOpen(false);
     } catch (err) {
-      console.error("删除用户失败:", { userId, err });
-      setDeleteError(getTranslation(translations, "errors.deleteFailed", "操作失败,请稍后重试"));
+      console.error("Delete user failed:", { userId, err });
+      setDeleteError(
+        getTranslation(translations, "errors.deleteFailed", "Operation failed, please try again")
+      );
     } finally {
       setIsDeleting(false);
     }
@@ -121,166 +75,29 @@ export function DangerZone({
     <section className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
       <header className="space-y-1">
         <h3 className="text-sm font-medium text-destructive">
-          {getTranslation(translations, "title", "危险操作")}
+          {getTranslation(translations, "title", "Danger Zone")}
         </h3>
         <p className="text-xs text-muted-foreground">
-          {getTranslation(translations, "description", "以下操作不可逆,请谨慎执行")}
+          {getTranslation(
+            translations,
+            "description",
+            "The following actions are irreversible, please proceed with caution"
+          )}
         </p>
       </header>
 
       <div className="mt-4 grid gap-3">
-        {/* Enable/Disable user - conditional rendering based on current state */}
-        {isEnabled ? (
-          /* Disable user (when currently enabled) */
-          <div className="flex flex-col gap-3 rounded-md border border-destructive/20 bg-background p-3 sm:flex-row sm:items-center sm:justify-between">
-            <div className="space-y-1">
-              <div className="text-sm font-medium">
-                {getTranslation(translations, "disable.title", "禁用用户")}
-              </div>
-              <div className="text-xs text-muted-foreground">
-                {getTranslation(
-                  translations,
-                  "disable.description",
-                  "禁用后该用户及其密钥将无法继续使用"
-                )}
-              </div>
-            </div>
-
-            <AlertDialog open={disableOpen} onOpenChange={setDisableOpen}>
-              <AlertDialogTrigger asChild>
-                <Button
-                  type="button"
-                  variant="outline"
-                  className="border-destructive/40 text-destructive hover:bg-destructive/10"
-                >
-                  <ShieldOff className="h-4 w-4" />
-                  {getTranslation(translations, "disable.trigger", "禁用")}
-                </Button>
-              </AlertDialogTrigger>
-
-              <AlertDialogContent>
-                <AlertDialogHeader>
-                  <AlertDialogTitle>
-                    {getTranslation(translations, "disable.title", "禁用用户")}
-                  </AlertDialogTitle>
-                  <AlertDialogDescription>
-                    {getTranslation(
-                      translations,
-                      "disable.confirmDescription",
-                      `确认要禁用用户 "${userName}" 吗?`
-                    )}
-                  </AlertDialogDescription>
-                </AlertDialogHeader>
-
-                {disableError && <p className="text-sm text-destructive">{disableError}</p>}
-
-                <AlertDialogFooter>
-                  <AlertDialogCancel disabled={isDisabling}>
-                    {getTranslation(translations, "actions.cancel", "取消")}
-                  </AlertDialogCancel>
-                  <AlertDialogAction
-                    onClick={(e) => {
-                      e.preventDefault();
-                      handleDisable();
-                    }}
-                    disabled={isDisabling}
-                    className={cn(buttonVariants({ variant: "destructive" }))}
-                  >
-                    {isDisabling ? (
-                      <>
-                        <Loader2 className="h-4 w-4 animate-spin" />
-                        {getTranslation(translations, "disable.loading", "处理中...")}
-                      </>
-                    ) : (
-                      getTranslation(translations, "disable.confirm", "确认禁用")
-                    )}
-                  </AlertDialogAction>
-                </AlertDialogFooter>
-              </AlertDialogContent>
-            </AlertDialog>
-          </div>
-        ) : (
-          /* Enable user (when currently disabled) */
-          <div className="flex flex-col gap-3 rounded-md border border-green-500/20 bg-green-500/5 p-3 sm:flex-row sm:items-center sm:justify-between">
-            <div className="space-y-1">
-              <div className="text-sm font-medium text-green-700 dark:text-green-400">
-                {getTranslation(translations, "enable.title", "启用用户")}
-              </div>
-              <div className="text-xs text-muted-foreground">
-                {getTranslation(
-                  translations,
-                  "enable.description",
-                  "启用后该用户及其密钥将恢复正常使用"
-                )}
-              </div>
-            </div>
-
-            <AlertDialog open={enableOpen} onOpenChange={setEnableOpen}>
-              <AlertDialogTrigger asChild>
-                <Button
-                  type="button"
-                  variant="outline"
-                  className="border-green-500/40 text-green-700 hover:bg-green-500/10 dark:text-green-400"
-                >
-                  <ShieldCheck className="h-4 w-4" />
-                  {getTranslation(translations, "enable.trigger", "启用")}
-                </Button>
-              </AlertDialogTrigger>
-
-              <AlertDialogContent>
-                <AlertDialogHeader>
-                  <AlertDialogTitle>
-                    {getTranslation(translations, "enable.title", "启用用户")}
-                  </AlertDialogTitle>
-                  <AlertDialogDescription>
-                    {getTranslation(
-                      translations,
-                      "enable.confirmDescription",
-                      `确认要启用用户 "${userName}" 吗?`
-                    )}
-                  </AlertDialogDescription>
-                </AlertDialogHeader>
-
-                {enableError && <p className="text-sm text-destructive">{enableError}</p>}
-
-                <AlertDialogFooter>
-                  <AlertDialogCancel disabled={isEnabling}>
-                    {getTranslation(translations, "actions.cancel", "取消")}
-                  </AlertDialogCancel>
-                  <AlertDialogAction
-                    onClick={(e) => {
-                      e.preventDefault();
-                      handleEnable();
-                    }}
-                    disabled={isEnabling}
-                    className="bg-green-600 text-white hover:bg-green-700"
-                  >
-                    {isEnabling ? (
-                      <>
-                        <Loader2 className="h-4 w-4 animate-spin" />
-                        {getTranslation(translations, "enable.loading", "处理中...")}
-                      </>
-                    ) : (
-                      getTranslation(translations, "enable.confirm", "确认启用")
-                    )}
-                  </AlertDialogAction>
-                </AlertDialogFooter>
-              </AlertDialogContent>
-            </AlertDialog>
-          </div>
-        )}
-
         {/* Delete user */}
         <div className="flex flex-col gap-3 rounded-md border border-destructive/20 bg-background p-3 sm:flex-row sm:items-center sm:justify-between">
           <div className="space-y-1">
             <div className="text-sm font-medium">
-              {getTranslation(translations, "delete.title", "删除用户")}
+              {getTranslation(translations, "delete.title", "Delete User")}
             </div>
             <div className="text-xs text-muted-foreground">
               {getTranslation(
                 translations,
                 "delete.description",
-                "将删除该用户的所有关联数据,此操作无法撤销"
+                "This will delete all associated data and cannot be undone"
               )}
             </div>
           </div>
@@ -290,7 +107,6 @@ export function DangerZone({
             onOpenChange={(next) => {
               setDeleteOpen(next);
               if (!next) {
-                // Reset the second confirmation input when closed.
                 setDeleteConfirmText("");
                 setDeleteError(null);
               }
@@ -299,28 +115,27 @@ export function DangerZone({
             <AlertDialogTrigger asChild>
               <Button type="button" variant="destructive">
                 <Trash2 className="h-4 w-4" />
-                {getTranslation(translations, "delete.trigger", "删除")}
+                {getTranslation(translations, "delete.trigger", "Delete")}
               </Button>
             </AlertDialogTrigger>
 
             <AlertDialogContent>
               <AlertDialogHeader>
                 <AlertDialogTitle>
-                  {getTranslation(translations, "delete.title", "删除用户")}
+                  {getTranslation(translations, "delete.title", "Delete User")}
                 </AlertDialogTitle>
                 <AlertDialogDescription>
                   {getTranslation(
                     translations,
                     "delete.confirmDescription",
-                    `此操作将删除用户 "${userName}" 的所有关联数据,且无法撤销。`
+                    `This will delete user "${userName}" and all associated data. This action cannot be undone.`
                   )}
                 </AlertDialogDescription>
               </AlertDialogHeader>
 
-              {/* Second confirmation: type exact user name. */}
               <div className="grid gap-2">
                 <Label htmlFor="delete-confirm-input">
-                  {getTranslation(translations, "delete.confirmLabel", "二次确认")}
+                  {getTranslation(translations, "delete.confirmLabel", "Confirm")}
                 </Label>
                 <Input
                   id="delete-confirm-input"
@@ -329,7 +144,7 @@ export function DangerZone({
                   placeholder={getTranslation(
                     translations,
                     "delete.confirmHint",
-                    `请输入 "${userName}" 以确认删除`
+                    `Type "${userName}" to confirm deletion`
                   )}
                   autoComplete="off"
                 />
@@ -339,7 +154,7 @@ export function DangerZone({
 
               <AlertDialogFooter>
                 <AlertDialogCancel disabled={isDeleting}>
-                  {getTranslation(translations, "actions.cancel", "取消")}
+                  {getTranslation(translations, "actions.cancel", "Cancel")}
                 </AlertDialogCancel>
                 <AlertDialogAction
                   onClick={(e) => {
@@ -352,10 +167,10 @@ export function DangerZone({
                   {isDeleting ? (
                     <>
                       <Loader2 className="h-4 w-4 animate-spin" />
-                      {getTranslation(translations, "delete.loading", "删除中...")}
+                      {getTranslation(translations, "delete.loading", "Deleting...")}
                     </>
                   ) : (
-                    getTranslation(translations, "delete.confirm", "确认删除")
+                    getTranslation(translations, "delete.confirm", "Confirm Delete")
                   )}
                 </AlertDialogAction>
               </AlertDialogFooter>

+ 262 - 0
src/app/[locale]/dashboard/_components/user/forms/quick-renew-dialog.tsx

@@ -0,0 +1,262 @@
+"use client";
+
+import { addDays } from "date-fns";
+import { Loader2 } from "lucide-react";
+import { useLocale } from "next-intl";
+import { useCallback, useMemo, useState } from "react";
+import { DatePickerField } from "@/components/form/date-picker-field";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { formatDate, formatDateDistance } from "@/lib/utils/date-format";
+
+export interface QuickRenewUser {
+  id: number;
+  name: string;
+  expiresAt?: Date | null;
+  isEnabled: boolean;
+}
+
+export interface QuickRenewDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  user: QuickRenewUser | null;
+  onConfirm: (userId: number, expiresAt: Date, enableUser?: boolean) => Promise<{ ok: boolean }>;
+  translations: {
+    title: string;
+    description: string;
+    currentExpiry: string;
+    neverExpires: string;
+    expired: string;
+    quickOptions: {
+      "7days": string;
+      "30days": string;
+      "90days": string;
+      "1year": string;
+    };
+    customDate: string;
+    enableOnRenew: string;
+    cancel: string;
+    confirm: string;
+    confirming: string;
+  };
+}
+
+function getTranslation(translations: Record<string, unknown>, path: string, fallback: string) {
+  const value = path.split(".").reduce<unknown>((acc, key) => {
+    if (acc && typeof acc === "object" && key in (acc as Record<string, unknown>)) {
+      return (acc as Record<string, unknown>)[key];
+    }
+    return undefined;
+  }, translations);
+  return typeof value === "string" && value.trim() ? value : fallback;
+}
+
+export function QuickRenewDialog({
+  open,
+  onOpenChange,
+  user,
+  onConfirm,
+  translations,
+}: QuickRenewDialogProps) {
+  const locale = useLocale();
+  const [customDate, setCustomDate] = useState("");
+  const [enableOnRenew, setEnableOnRenew] = useState(false);
+  const [isSubmitting, setIsSubmitting] = useState(false);
+
+  // Format current expiry for display
+  const currentExpiryText = useMemo(() => {
+    if (!user?.expiresAt) {
+      return getTranslation(translations, "neverExpires", "Never expires");
+    }
+    const expiresAt = user.expiresAt instanceof Date ? user.expiresAt : new Date(user.expiresAt);
+    const now = new Date();
+    if (expiresAt <= now) {
+      return getTranslation(translations, "expired", "Expired");
+    }
+    const relative = formatDateDistance(expiresAt, now, locale, { addSuffix: true });
+    const absolute = formatDate(expiresAt, "yyyy-MM-dd", locale);
+    return `${relative} (${absolute})`;
+  }, [user?.expiresAt, locale, translations]);
+
+  // Handle quick selection
+  const handleQuickSelect = useCallback(
+    async (days: number) => {
+      if (!user) return;
+      setIsSubmitting(true);
+      try {
+        const newDate = addDays(new Date(), days);
+        // Set to end of day
+        newDate.setHours(23, 59, 59, 999);
+        const result = await onConfirm(user.id, newDate, !user.isEnabled && enableOnRenew ? true : undefined);
+        if (result.ok) {
+          onOpenChange(false);
+        }
+      } finally {
+        setIsSubmitting(false);
+      }
+    },
+    [user, enableOnRenew, onConfirm, onOpenChange]
+  );
+
+  // Handle custom date confirm
+  const handleCustomConfirm = useCallback(async () => {
+    if (!user || !customDate) return;
+    setIsSubmitting(true);
+    try {
+      const [year, month, day] = customDate.split("-").map(Number);
+      if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) {
+        setIsSubmitting(false);
+        return;
+      }
+      const newDate = new Date(year, month - 1, day);
+      newDate.setHours(23, 59, 59, 999);
+      const result = await onConfirm(user.id, newDate, !user.isEnabled && enableOnRenew ? true : undefined);
+      if (result.ok) {
+        onOpenChange(false);
+      }
+    } finally {
+      setIsSubmitting(false);
+    }
+  }, [user, customDate, enableOnRenew, onConfirm, onOpenChange]);
+
+  // Reset state when dialog closes
+  const handleOpenChange = useCallback(
+    (nextOpen: boolean) => {
+      if (!nextOpen) {
+        setCustomDate("");
+        setEnableOnRenew(false);
+      }
+      onOpenChange(nextOpen);
+    },
+    [onOpenChange]
+  );
+
+  if (!user) return null;
+
+  return (
+    <Dialog open={open} onOpenChange={handleOpenChange}>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{getTranslation(translations, "title", "Quick Renew")}</DialogTitle>
+          <DialogDescription>
+            {getTranslation(
+              translations,
+              "description",
+              "Set new expiration date for user {userName}"
+            ).replace("{userName}", user.name)}
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-4 py-4">
+          {/* Current expiry display */}
+          <div className="text-sm">
+            <span className="text-muted-foreground">
+              {getTranslation(translations, "currentExpiry", "Current Expiration")}:{" "}
+            </span>
+            <span className="font-medium">{currentExpiryText}</span>
+          </div>
+
+          {/* Quick select buttons */}
+          <div className="grid grid-cols-4 gap-2">
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => handleQuickSelect(7)}
+              disabled={isSubmitting}
+            >
+              {isSubmitting ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                getTranslation(translations, "quickOptions.7days", "7 Days")
+              )}
+            </Button>
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => handleQuickSelect(30)}
+              disabled={isSubmitting}
+            >
+              {isSubmitting ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                getTranslation(translations, "quickOptions.30days", "30 Days")
+              )}
+            </Button>
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => handleQuickSelect(90)}
+              disabled={isSubmitting}
+            >
+              {isSubmitting ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                getTranslation(translations, "quickOptions.90days", "90 Days")
+              )}
+            </Button>
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => handleQuickSelect(365)}
+              disabled={isSubmitting}
+            >
+              {isSubmitting ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                getTranslation(translations, "quickOptions.1year", "1 Year")
+              )}
+            </Button>
+          </div>
+
+          {/* Custom date picker */}
+          <DatePickerField
+            id="quick-renew-date"
+            label={getTranslation(translations, "customDate", "Custom Date")}
+            value={customDate}
+            onChange={setCustomDate}
+            minDate={new Date()}
+          />
+
+          {/* Enable on renew switch (only show if user is disabled) */}
+          {!user.isEnabled && (
+            <div className="flex items-center space-x-2">
+              <Switch
+                id="enable-on-renew"
+                checked={enableOnRenew}
+                onCheckedChange={setEnableOnRenew}
+              />
+              <Label htmlFor="enable-on-renew" className="text-sm font-normal cursor-pointer">
+                {getTranslation(translations, "enableOnRenew", "Also enable user")}
+              </Label>
+            </div>
+          )}
+        </div>
+
+        <DialogFooter>
+          <Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}>
+            {getTranslation(translations, "cancel", "Cancel")}
+          </Button>
+          <Button onClick={handleCustomConfirm} disabled={!customDate || isSubmitting}>
+            {isSubmitting ? (
+              <>
+                <Loader2 className="h-4 w-4 animate-spin mr-2" />
+                {getTranslation(translations, "confirming", "Renewing...")}
+              </>
+            ) : (
+              getTranslation(translations, "confirm", "Confirm")
+            )}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 127 - 1
src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx

@@ -1,10 +1,23 @@
 "use client";
 
-import { Calendar, Gauge, User } from "lucide-react";
+import { Calendar, Gauge, Loader2, ShieldCheck, ShieldOff, User } from "lucide-react";
 import { useMemo, useState } from "react";
 import { DatePickerField } from "@/components/form/date-picker-field";
 import { ArrayTagInputField, TextField } from "@/components/form/form-field";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
 import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { cn } from "@/lib/utils";
 import { AccessRestrictionsSection } from "./access-restrictions-section";
 import { type DailyResetMode, LimitRulePicker, type LimitType } from "./limit-rule-picker";
 import { type LimitRuleDisplayItem, LimitRulesDisplay } from "./limit-rules-display";
@@ -30,6 +43,8 @@ export interface UserEditSectionProps {
     allowedClients?: string[];
     allowedModels?: string[];
   };
+  isEnabled?: boolean;
+  onToggleEnabled?: () => Promise<void>;
   showProviderGroup?: boolean;
   modelSuggestions?: string[];
   onChange: (field: string, value: any) => void;
@@ -48,6 +63,19 @@ export interface UserEditSectionProps {
         label: string;
         placeholder: string;
       };
+      enableStatus?: {
+        label: string;
+        enabledDescription: string;
+        disabledDescription: string;
+        confirmEnable: string;
+        confirmDisable: string;
+        confirmEnableTitle: string;
+        confirmDisableTitle: string;
+        confirmEnableDescription: string;
+        confirmDisableDescription: string;
+        cancel: string;
+        processing: string;
+      };
       allowedClients: {
         label: string;
         description: string;
@@ -102,17 +130,32 @@ function toNumberOrNull(value: unknown): number | null {
 
 export function UserEditSection({
   user,
+  isEnabled,
+  onToggleEnabled,
   showProviderGroup,
   modelSuggestions = [],
   onChange,
   translations,
 }: UserEditSectionProps) {
   const [rulePickerOpen, setRulePickerOpen] = useState(false);
+  const [toggleConfirmOpen, setToggleConfirmOpen] = useState(false);
+  const [isToggling, setIsToggling] = useState(false);
 
   const emitChange = (field: string, value: any) => {
     onChange(field, value);
   };
 
+  const handleToggleEnabled = async () => {
+    if (!onToggleEnabled) return;
+    setIsToggling(true);
+    try {
+      await onToggleEnabled();
+      setToggleConfirmOpen(false);
+    } finally {
+      setIsToggling(false);
+    }
+  };
+
   const expiresAtValue = useMemo(() => {
     if (!user.expiresAt) return "";
     return formatYmdLocal(new Date(user.expiresAt));
@@ -203,6 +246,8 @@ export function UserEditSection({
     }
   };
 
+  const enableStatusTranslations = translations.fields.enableStatus;
+
   return (
     <div className="space-y-4">
       <section className="rounded-lg border border-border bg-card/50 p-3 space-y-3">
@@ -248,6 +293,87 @@ export function UserEditSection({
             )}
           </div>
         </div>
+
+        {/* Enable/Disable toggle - only show if onToggleEnabled is provided */}
+        {onToggleEnabled && enableStatusTranslations && (
+          <div
+            className={cn(
+              "flex items-center justify-between rounded-md border p-3 mt-3",
+              isEnabled ? "border-border bg-background" : "border-amber-500/30 bg-amber-500/5"
+            )}
+          >
+            <div className="space-y-0.5">
+              <Label
+                htmlFor="user-enabled-toggle"
+                className="text-sm font-medium flex items-center gap-2 cursor-pointer"
+              >
+                {isEnabled ? (
+                  <ShieldCheck className="h-4 w-4 text-green-600" />
+                ) : (
+                  <ShieldOff className="h-4 w-4 text-amber-600" />
+                )}
+                {enableStatusTranslations.label || "Enable Status"}
+              </Label>
+              <p className="text-xs text-muted-foreground">
+                {isEnabled
+                  ? enableStatusTranslations.enabledDescription || "Currently enabled"
+                  : enableStatusTranslations.disabledDescription || "Currently disabled"}
+              </p>
+            </div>
+            <Switch
+              id="user-enabled-toggle"
+              checked={isEnabled}
+              onCheckedChange={() => setToggleConfirmOpen(true)}
+            />
+
+            <AlertDialog open={toggleConfirmOpen} onOpenChange={setToggleConfirmOpen}>
+              <AlertDialogContent>
+                <AlertDialogHeader>
+                  <AlertDialogTitle>
+                    {isEnabled
+                      ? enableStatusTranslations.confirmDisableTitle || "Disable User"
+                      : enableStatusTranslations.confirmEnableTitle || "Enable User"}
+                  </AlertDialogTitle>
+                  <AlertDialogDescription>
+                    {isEnabled
+                      ? enableStatusTranslations.confirmDisableDescription ||
+                        `Are you sure you want to disable user "${user.name}"?`
+                      : enableStatusTranslations.confirmEnableDescription ||
+                        `Are you sure you want to enable user "${user.name}"?`}
+                  </AlertDialogDescription>
+                </AlertDialogHeader>
+                <AlertDialogFooter>
+                  <AlertDialogCancel disabled={isToggling}>
+                    {enableStatusTranslations.cancel || "Cancel"}
+                  </AlertDialogCancel>
+                  <AlertDialogAction
+                    onClick={(e) => {
+                      e.preventDefault();
+                      handleToggleEnabled();
+                    }}
+                    disabled={isToggling}
+                    className={cn(
+                      isEnabled
+                        ? "bg-amber-600 hover:bg-amber-700"
+                        : "bg-green-600 hover:bg-green-700"
+                    )}
+                  >
+                    {isToggling ? (
+                      <>
+                        <Loader2 className="h-4 w-4 animate-spin mr-2" />
+                        {enableStatusTranslations.processing || "Processing..."}
+                      </>
+                    ) : isEnabled ? (
+                      enableStatusTranslations.confirmDisable || "Disable"
+                    ) : (
+                      enableStatusTranslations.confirmEnable || "Enable"
+                    )}
+                  </AlertDialogAction>
+                </AlertDialogFooter>
+              </AlertDialogContent>
+            </AlertDialog>
+          </div>
+        )}
       </section>
 
       <section className="rounded-lg border border-border bg-card/50 p-3 space-y-3">

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

@@ -450,6 +450,26 @@ function UnifiedEditDialogInner({
               placeholder: t("userEditSection.fields.providerGroup.placeholder"),
             }
           : undefined,
+        enableStatus:
+          mode === "edit" && isAdmin
+            ? {
+                label: t("userEditSection.fields.enableStatus.label"),
+                enabledDescription: t("userEditSection.fields.enableStatus.enabledDescription"),
+                disabledDescription: t("userEditSection.fields.enableStatus.disabledDescription"),
+                confirmEnable: t("userEditSection.fields.enableStatus.confirmEnable"),
+                confirmDisable: t("userEditSection.fields.enableStatus.confirmDisable"),
+                confirmEnableTitle: t("userEditSection.fields.enableStatus.confirmEnableTitle"),
+                confirmDisableTitle: t("userEditSection.fields.enableStatus.confirmDisableTitle"),
+                confirmEnableDescription: t(
+                  "userEditSection.fields.enableStatus.confirmEnableDescription"
+                ),
+                confirmDisableDescription: t(
+                  "userEditSection.fields.enableStatus.confirmDisableDescription"
+                ),
+                cancel: t("userEditSection.fields.enableStatus.cancel"),
+                processing: t("userEditSection.fields.enableStatus.processing"),
+              }
+            : undefined,
         allowedClients: {
           label: t("userEditSection.fields.allowedClients.label"),
           description: t("userEditSection.fields.allowedClients.description"),
@@ -492,7 +512,7 @@ function UnifiedEditDialogInner({
         year: t("quickExpire.oneYear"),
       },
     };
-  }, [t, showUserProviderGroup]);
+  }, [t, showUserProviderGroup, mode, isAdmin]);
 
   const keyEditTranslations = useMemo(() => {
     return {
@@ -714,6 +734,18 @@ function UnifiedEditDialogInner({
               allowedClients: currentUserDraft.allowedClients || [],
               allowedModels: currentUserDraft.allowedModels || [],
             }}
+            isEnabled={mode === "edit" ? user?.isEnabled : undefined}
+            onToggleEnabled={
+              mode === "edit" && isAdmin && user
+                ? async () => {
+                    if (user.isEnabled) {
+                      await handleDisableUser();
+                    } else {
+                      await handleEnableUser();
+                    }
+                  }
+                : undefined
+            }
             showProviderGroup={showUserProviderGroup}
             onChange={handleUserChange}
             translations={userEditTranslations}
@@ -848,9 +880,6 @@ function UnifiedEditDialogInner({
             <DangerZone
               userId={user.id}
               userName={user.name}
-              isEnabled={user.isEnabled}
-              onEnable={handleEnableUser}
-              onDisable={handleDisableUser}
               onDelete={handleDeleteUser}
               translations={{}}
             />

+ 19 - 2
src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx

@@ -21,6 +21,7 @@ export interface UserKeyTableRowProps {
   expanded: boolean;
   onToggle: () => void;
   onEditUser: (scrollToKeyId?: number) => void;
+  onQuickRenew?: (user: UserDisplay) => void;
   currentUser?: { role: string };
   currencyCode?: string;
   highlightKeyIds?: Set<number>;
@@ -29,6 +30,7 @@ export interface UserKeyTableRowProps {
       username: string;
       note: string;
       expiresAt: string;
+      expiresAtHint?: string;
       limit5h: string;
       limitDaily: string;
       limitWeekly: string;
@@ -73,6 +75,7 @@ export function UserKeyTableRow({
   expanded,
   onToggle,
   onEditUser,
+  onQuickRenew,
   currencyCode,
   highlightKeyIds,
   translations,
@@ -144,8 +147,22 @@ export function UserKeyTableRow({
           </div>
         </TableCell>
 
-        {/* 到期时间 */}
-        <TableCell className="text-sm text-muted-foreground">{expiresText}</TableCell>
+        {/* 到期时间 - clickable for quick renew */}
+        <TableCell
+          className={cn(
+            "text-sm text-muted-foreground",
+            onQuickRenew && "cursor-pointer hover:text-primary hover:underline"
+          )}
+          onClick={(e) => {
+            if (onQuickRenew) {
+              e.stopPropagation();
+              onQuickRenew(user);
+            }
+          }}
+          title={onQuickRenew ? translations.columns.expiresAtHint : undefined}
+        >
+          {expiresText}
+        </TableCell>
 
         {/* 5h 限额 */}
         <TableCell className="text-center">

+ 103 - 3
src/app/[locale]/dashboard/_components/user/user-management-table.tsx

@@ -1,8 +1,11 @@
 "use client";
 
 import { Users } from "lucide-react";
+import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { renewUser } from "@/actions/users";
 import { Button } from "@/components/ui/button";
 import {
   Table,
@@ -14,6 +17,7 @@ import {
 } from "@/components/ui/table";
 import { cn } from "@/lib/utils";
 import type { User, UserDisplay } from "@/types/user";
+import { QuickRenewDialog, type QuickRenewUser } from "./forms/quick-renew-dialog";
 import { UnifiedEditDialog } from "./unified-edit-dialog";
 import { UserKeyTableRow } from "./user-key-table-row";
 
@@ -30,6 +34,7 @@ export interface UserManagementTableProps {
         username: string;
         note: string;
         expiresAt: string;
+        expiresAtHint?: string;
         limit5h: string;
         limitDaily: string;
         limitWeekly: string;
@@ -56,6 +61,26 @@ export interface UserManagementTableProps {
       page: string;
       of: string;
     };
+    quickRenew?: {
+      title: string;
+      description: string;
+      currentExpiry: string;
+      neverExpires: string;
+      expired: string;
+      quickOptions: {
+        "7days": string;
+        "30days": string;
+        "90days": string;
+        "1year": string;
+      };
+      customDate: string;
+      enableOnRenew: string;
+      cancel: string;
+      confirm: string;
+      confirming: string;
+      success: string;
+      failed: string;
+    };
   };
 }
 
@@ -82,7 +107,10 @@ export function UserManagementTable({
   autoExpandOnFilter,
   translations,
 }: UserManagementTableProps) {
+  const router = useRouter();
   const tUserList = useTranslations("dashboard.userList");
+  const tUserMgmt = useTranslations("dashboard.userManagement");
+  const isAdmin = currentUser?.role === "admin";
   const [currentPage, setCurrentPage] = useState(1);
   const [expandedUsers, setExpandedUsers] = useState<Map<number, boolean>>(
     () => new Map(users.map((user) => [user.id, true]))
@@ -91,6 +119,10 @@ export function UserManagementTable({
   const [editingUserId, setEditingUserId] = useState<number | null>(null);
   const [scrollToKeyId, setScrollToKeyId] = useState<number | undefined>(undefined);
 
+  // Quick renew dialog state
+  const [quickRenewOpen, setQuickRenewOpen] = useState(false);
+  const [quickRenewUser, setQuickRenewUser] = useState<QuickRenewUser | null>(null);
+
   const totalPages = useMemo(
     () => Math.max(1, Math.ceil(users.length / PAGE_SIZE)),
     [users.length]
@@ -115,7 +147,6 @@ export function UserManagementTable({
     });
   }, [users]);
 
-  // Auto-expand all users when filter is active
   useEffect(() => {
     if (autoExpandOnFilter) {
       setExpandedUsers(new Map(users.map((user) => [user.id, true])));
@@ -160,15 +191,46 @@ export function UserManagementTable({
 
   const rowTranslations = useMemo(() => {
     return {
-      columns: translations.table.columns,
+      columns: {
+        ...translations.table.columns,
+        expiresAtHint: isAdmin
+          ? translations.table.columns.expiresAtHint || tUserMgmt("table.columns.expiresAtHint")
+          : undefined,
+      },
       keyRow: translations.table.keyRow,
       expand: translations.table.expand,
       collapse: translations.table.collapse,
       noKeys: translations.table.noKeys,
       defaultGroup: translations.table.defaultGroup,
       actions: translations.actions,
+      userStatus: {
+        disabled: tUserMgmt("keyStatus.disabled"),
+      },
     };
-  }, [translations]);
+  }, [translations, isAdmin, tUserMgmt]);
+
+  const quickRenewTranslations = useMemo(() => {
+    if (translations.quickRenew) return translations.quickRenew;
+    // Fallback to translation keys
+    return {
+      title: tUserMgmt("quickRenew.title"),
+      description: tUserMgmt("quickRenew.description"),
+      currentExpiry: tUserMgmt("quickRenew.currentExpiry"),
+      neverExpires: tUserMgmt("quickRenew.neverExpires"),
+      expired: tUserMgmt("quickRenew.expired"),
+      quickOptions: {
+        "7days": tUserMgmt("quickRenew.quickOptions.7days"),
+        "30days": tUserMgmt("quickRenew.quickOptions.30days"),
+        "90days": tUserMgmt("quickRenew.quickOptions.90days"),
+        "1year": tUserMgmt("quickRenew.quickOptions.1year"),
+      },
+      customDate: tUserMgmt("quickRenew.customDate"),
+      enableOnRenew: tUserMgmt("quickRenew.enableOnRenew"),
+      cancel: tUserMgmt("quickRenew.cancel"),
+      confirm: tUserMgmt("quickRenew.confirm"),
+      confirming: tUserMgmt("quickRenew.confirming"),
+    };
+  }, [translations.quickRenew, tUserMgmt]);
 
   const editingUser = useMemo(() => {
     if (!editingUserId) return null;
@@ -210,6 +272,34 @@ export function UserManagementTable({
     setScrollToKeyId(undefined);
   };
 
+  // Quick renew handlers
+  const handleOpenQuickRenew = (user: UserDisplay) => {
+    setQuickRenewUser({
+      id: user.id,
+      name: user.name,
+      expiresAt: user.expiresAt ?? null,
+      isEnabled: user.isEnabled,
+    });
+    setQuickRenewOpen(true);
+  };
+
+  const handleQuickRenewConfirm = async (userId: number, expiresAt: Date, enableUser?: boolean): Promise<{ ok: boolean }> => {
+    try {
+      const res = await renewUser(userId, { expiresAt: expiresAt.toISOString(), enableUser });
+      if (!res.ok) {
+        toast.error(res.error || tUserMgmt("quickRenew.failed"));
+        return { ok: false };
+      }
+      toast.success(tUserMgmt("quickRenew.success"));
+      router.refresh();
+      return { ok: true };
+    } catch (error) {
+      console.error("[QuickRenew] failed", error);
+      toast.error(tUserMgmt("quickRenew.failed"));
+      return { ok: false };
+    }
+  };
+
   return (
     <div className="space-y-3">
       <div className="flex items-center justify-between gap-2 flex-wrap">
@@ -296,6 +386,7 @@ export function UserManagementTable({
                   expanded={expandedUsers.get(user.id) ?? true}
                   onToggle={() => handleToggleUser(user.id)}
                   onEditUser={(keyId) => openEditDialog(user.id, keyId)}
+                  onQuickRenew={isAdmin ? handleOpenQuickRenew : undefined}
                   currentUser={currentUser}
                   currencyCode={currencyCode}
                   translations={rowTranslations}
@@ -317,6 +408,15 @@ export function UserManagementTable({
           currentUser={currentUser}
         />
       ) : null}
+
+      {/* Quick renew dialog */}
+      <QuickRenewDialog
+        open={quickRenewOpen}
+        onOpenChange={setQuickRenewOpen}
+        user={quickRenewUser}
+        onConfirm={handleQuickRenewConfirm}
+        translations={quickRenewTranslations}
+      />
     </div>
   );
 }

+ 85 - 19
src/app/[locale]/dashboard/users/users-page-client.tsx

@@ -20,6 +20,17 @@ import { UserOnboardingTour } from "../_components/user/user-onboarding-tour";
 
 const ONBOARDING_KEY = "cch-users-onboarding-seen";
 
+/**
+ * Split comma-separated tags into an array of trimmed, non-empty strings.
+ * This matches the server-side providerGroup handling in provider-selector.ts
+ */
+function splitTags(value?: string | null): string[] {
+  return (value ?? "")
+    .split(",")
+    .map((t) => t.trim())
+    .filter(Boolean);
+}
+
 interface UsersPageClientProps {
   users: UserDisplay[];
   currentUser: User;
@@ -30,7 +41,6 @@ export function UsersPageClient({ users, currentUser }: UsersPageClientProps) {
   const tUiTable = useTranslations("ui.table");
   const tUserMgmt = useTranslations("dashboard.userManagement");
   const tKeyList = useTranslations("dashboard.keyList");
-  const tUserList = useTranslations("dashboard.userList");
   const tCommon = useTranslations("common");
   const [searchTerm, setSearchTerm] = useState("");
   const [tagFilter, setTagFilter] = useState("all");
@@ -84,42 +94,98 @@ export function UsersPageClient({ users, currentUser }: UsersPageClientProps) {
     return [...new Set(tags)].sort();
   }, [users]);
 
-  // Extract unique key groups from users
+  // Extract unique key groups from users (split comma-separated tags)
   const uniqueKeyGroups = useMemo(() => {
-    const groups = users.flatMap((u) => u.keys?.map((k) => k.providerGroup).filter(Boolean) || []);
-    return [...new Set(groups)].sort() as string[];
+    const groups = users.flatMap((u) => u.keys?.flatMap((k) => splitTags(k.providerGroup)) || []);
+    return [...new Set(groups)].sort();
   }, [users]);
 
+  // Reset filter if selected value no longer exists in options
+  useEffect(() => {
+    if (tagFilter !== "all" && !uniqueTags.includes(tagFilter)) {
+      setTagFilter("all");
+    }
+  }, [uniqueTags, tagFilter]);
+
+  useEffect(() => {
+    if (keyGroupFilter !== "all" && !uniqueKeyGroups.includes(keyGroupFilter)) {
+      setKeyGroupFilter("all");
+    }
+  }, [uniqueKeyGroups, keyGroupFilter]);
+
   // Filter users based on search term, tag filter, and key group filter
   const { filteredUsers, matchingKeyIds } = useMemo(() => {
     const matchingIds = new Set<number>();
+    const normalizedTerm = searchTerm.trim().toLowerCase();
+    const hasSearch = normalizedTerm.length > 0;
+
+    const filtered: UserDisplay[] = [];
+
+    for (const user of users) {
+      // Collect matching key IDs for this user (before filtering)
+      const userMatchingKeyIds: number[] = [];
 
-    const filtered = users.filter((user) => {
-      // Search filter: match username or tag
-      const matchesSearch =
-        searchTerm === "" ||
-        user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
-        (user.tags || []).some((tag) => tag.toLowerCase().includes(searchTerm.toLowerCase()));
+      // Search filter: match user-level fields or any key fields
+      let matchesSearch = !hasSearch;
+
+      if (hasSearch) {
+        // User-level fields: name, note, tags, providerGroup
+        const userMatches =
+          user.name.toLowerCase().includes(normalizedTerm) ||
+          (user.note || "").toLowerCase().includes(normalizedTerm) ||
+          (user.tags || []).some((tag) => tag.toLowerCase().includes(normalizedTerm)) ||
+          (user.providerGroup || "").toLowerCase().includes(normalizedTerm);
+
+        if (userMatches) {
+          matchesSearch = true;
+        } else if (user.keys) {
+          // Key-level fields: name, maskedKey, fullKey, providerGroup
+          for (const key of user.keys) {
+            const keyMatches =
+              key.name.toLowerCase().includes(normalizedTerm) ||
+              key.maskedKey.toLowerCase().includes(normalizedTerm) ||
+              (key.fullKey || "").toLowerCase().includes(normalizedTerm) ||
+              (key.providerGroup || "").toLowerCase().includes(normalizedTerm);
+
+            if (keyMatches) {
+              matchesSearch = true;
+              userMatchingKeyIds.push(key.id);
+              // Don't break - collect all matching keys
+            }
+          }
+        }
+      }
 
       // Tag filter
       const matchesTag = tagFilter === "all" || (user.tags || []).includes(tagFilter);
 
-      // Key group filter
+      // Key group filter (check if any split tag matches the filter)
       let matchesKeyGroup = keyGroupFilter === "all";
       if (keyGroupFilter !== "all" && user.keys) {
-        const matchedKeys = user.keys.filter((k) => k.providerGroup === keyGroupFilter);
-        if (matchedKeys.length > 0) {
-          matchesKeyGroup = true;
-          matchedKeys.forEach((k) => matchingIds.add(k.id));
+        for (const key of user.keys) {
+          if (splitTags(key.providerGroup).includes(keyGroupFilter)) {
+            matchesKeyGroup = true;
+            userMatchingKeyIds.push(key.id);
+          }
         }
       }
 
-      return matchesSearch && matchesTag && matchesKeyGroup;
-    });
+      // Only add to results and matchingIds if user passes ALL filters
+      if (matchesSearch && matchesTag && matchesKeyGroup) {
+        filtered.push(user);
+        // Add matching key IDs only for users that pass the filter
+        for (const keyId of userMatchingKeyIds) {
+          matchingIds.add(keyId);
+        }
+      }
+    }
 
     return { filteredUsers: filtered, matchingKeyIds: matchingIds };
   }, [users, searchTerm, tagFilter, keyGroupFilter]);
 
+  // Determine if we should highlight keys (either search or keyGroup filter is active)
+  const shouldHighlightKeys = searchTerm.trim().length > 0 || keyGroupFilter !== "all";
+
   return (
     <div className="space-y-4">
       <div className="flex items-center justify-between">
@@ -192,8 +258,8 @@ export function UsersPageClient({ users, currentUser }: UsersPageClientProps) {
         currentUser={currentUser}
         currencyCode="USD"
         onCreateUser={handleCreateUser}
-        highlightKeyIds={keyGroupFilter !== "all" ? matchingKeyIds : undefined}
-        autoExpandOnFilter={keyGroupFilter !== "all"}
+        highlightKeyIds={shouldHighlightKeys ? matchingKeyIds : undefined}
+        autoExpandOnFilter={shouldHighlightKeys}
         translations={{
           table: {
             columns: {