From 329184851f3109ac7a284aec533b6d206ce045c9 Mon Sep 17 00:00:00 2001
From: NieiR
Date: Sun, 4 Jan 2026 23:47:23 +0800
Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=20unified-ed?=
=?UTF-8?q?it-dialog=20=E4=B8=BA=E4=B8=93=E7=94=A8=E5=AF=B9=E8=AF=9D?=
=?UTF-8?q?=E6=A1=86=E7=BB=84=E4=BB=B6=20(#413)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
将 1120 行的 unified-edit-dialog.tsx 拆分为 4 个单一职责组件:
- CreateUserDialog: 创建用户 + 首个密钥
- EditUserDialog: 编辑用户信息
- AddKeyDialog: 添加新密钥
- EditKeyDialog: 编辑单个密钥
主要改动:
- 提取共享逻辑到 hooks/ (翻译、模型建议)
- 提取工具函数到 utils/ (表单处理、provider group)
- 新增 getModelSuggestionsByProviderGroup action
- 修复 expiresAt 清除时传参问题 (使用空字符串)
- 添加单元测试覆盖新组件
BREAKING CHANGE: 删除 unified-edit-dialog.tsx,引用需更新为新组件
---
messages/en/dashboard.json | 21 +-
messages/ja/dashboard.json | 19 +-
messages/ru/dashboard.json | 19 +-
messages/zh-CN/dashboard.json | 19 +-
messages/zh-TW/dashboard.json | 19 +-
src/actions/providers.ts | 76 ++
.../_components/user/add-key-dialog.tsx | 128 ++
.../_components/user/create-user-dialog.tsx | 425 +++++++
.../_components/user/edit-key-dialog.tsx | 64 +
.../_components/user/edit-user-dialog.tsx | 268 ++++
.../_components/user/forms/add-key-form.tsx | 13 +-
.../_components/user/forms/edit-key-form.tsx | 19 +-
.../user/forms/key-edit-section.tsx | 100 +-
.../user/hooks/use-key-translations.ts | 136 ++
.../user/hooks/use-model-suggestions.ts | 27 +
.../user/hooks/use-user-translations.ts | 191 +++
.../_components/user/unified-edit-dialog.tsx | 1120 -----------------
.../_components/user/user-key-table-row.tsx | 78 +-
.../user/user-management-table.tsx | 26 +-
.../_components/user/utils/form-utils.ts | 9 +
.../_components/user/utils/provider-group.ts | 23 +
.../dashboard/users/users-page-client.tsx | 91 +-
.../add-key-form-expiry-clear-ui.test.tsx | 140 +++
tests/unit/user-dialogs.test.tsx | 752 +++++++++++
24 files changed, 2559 insertions(+), 1224 deletions(-)
create mode 100644 src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx
create mode 100644 src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx
create mode 100644 src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx
create mode 100644 src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx
create mode 100644 src/app/[locale]/dashboard/_components/user/hooks/use-key-translations.ts
create mode 100644 src/app/[locale]/dashboard/_components/user/hooks/use-model-suggestions.ts
create mode 100644 src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts
delete mode 100644 src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx
create mode 100644 src/app/[locale]/dashboard/_components/user/utils/form-utils.ts
create mode 100644 src/app/[locale]/dashboard/_components/user/utils/provider-group.ts
create mode 100644 tests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsx
create mode 100644 tests/unit/user-dialogs.test.tsx
diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json
index ff40e3b60..c5d9330bc 100644
--- a/messages/en/dashboard.json
+++ b/messages/en/dashboard.json
@@ -769,6 +769,12 @@
"defaultDescription": "default includes providers without groupTag.",
"descriptionWithUserGroup": "Provider groups for this key (user groups: {group}; default: default)."
},
+ "successTitle": "Key Created Successfully",
+ "successDescription": "Your API key has been created successfully.",
+ "generatedKey": {
+ "label": "Generated Key",
+ "hint": "You can view and copy this key anytime from the key list."
+ },
"errors": {
"userIdMissing": "User ID does not exist",
"createFailed": "Failed to create, please try again later",
@@ -1142,6 +1148,9 @@
"defaultGroup": "default",
"userStatus": {
"disabled": "Disabled"
+ },
+ "actions": {
+ "addKey": "Add Key"
}
},
"keyFullDisplay": {
@@ -1210,8 +1219,8 @@
"editKeyTitle": "Edit Key"
},
"editDialog": {
- "title": "Edit user and keys",
- "description": "Edit user information and API key settings",
+ "title": "Edit user",
+ "description": "Edit user information",
"userSection": "User settings",
"keysSection": "Key settings",
"scrollToKey": "Scroll to key",
@@ -1310,6 +1319,10 @@
"saveFailed": "Failed to create user",
"keyCreateFailed": "Failed to create key",
"createSuccess": "User created successfully",
+ "successTitle": "Created Successfully",
+ "successDescription": "User and key have been created",
+ "generatedKey": "Generated Key",
+ "keyHint": "You can view and copy this key anytime from the user management page",
"keysSection": "Keys",
"addKey": "Add key",
"removeKey": "Remove key",
@@ -1404,7 +1417,7 @@
"label": "Enable dedicated balance page",
"description": "Allow users to view their balance via a dedicated page",
"descriptionEnabled": "When enabled, this key will access an independent personal usage page upon login. However, it cannot modify its own key's provider group.",
- "descriptionDisabled": "When disabled, the user cannot access the personal usage page UI. However, they can modify their own key's provider group in the restricted Web UI."
+ "descriptionDisabled": "When disabled, the user cannot access the personal usage page UI. Instead, they will use the restricted Web UI."
},
"cacheTtlOverride": {
"label": "Cache TTL override",
@@ -1529,7 +1542,7 @@
"label": "Independent Personal Usage Page",
"description": "When enabled, this key can access an independent personal usage page",
"descriptionEnabled": "When enabled, this key will access an independent personal usage page upon login. However, it cannot modify its own key's provider group.",
- "descriptionDisabled": "When disabled, the user cannot access the personal usage page UI. However, they can modify their own key's provider group in the restricted Web UI."
+ "descriptionDisabled": "When disabled, the user cannot access the personal usage page UI. Instead, they will use the restricted Web UI."
},
"providerGroup": {
"label": "Provider Group",
diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json
index 4111670ed..43721adc4 100644
--- a/messages/ja/dashboard.json
+++ b/messages/ja/dashboard.json
@@ -750,6 +750,12 @@
"defaultDescription": "default は groupTag 未設定のプロバイダーを含みます",
"descriptionWithUserGroup": "このキーのプロバイダーグループ(ユーザーのグループ: {group}、既定: default)"
},
+ "successTitle": "キーが正常に作成されました",
+ "successDescription": "APIキーが正常に作成されました。",
+ "generatedKey": {
+ "label": "生成されたキー",
+ "hint": "このキーはキー一覧からいつでも確認・コピーできます。"
+ },
"errors": {
"userIdMissing": "ユーザーIDが存在しません",
"createFailed": "作成に失敗しました。後でもう一度お試しください",
@@ -1113,6 +1119,9 @@
"defaultGroup": "default",
"userStatus": {
"disabled": "無効"
+ },
+ "actions": {
+ "addKey": "キーを追加"
}
},
"keyFullDisplay": {
@@ -1172,8 +1181,8 @@
"failed": "更新に失敗しました"
},
"editDialog": {
- "title": "ユーザーとキーを編集",
- "description": "ユーザー情報とAPIキーの設定を編集",
+ "title": "ユーザーを編集",
+ "description": "ユーザー情報を編集",
"userSection": "ユーザー設定",
"keysSection": "キー設定",
"scrollToKey": "キーへスクロール",
@@ -1272,6 +1281,10 @@
"saveFailed": "ユーザーの作成に失敗しました",
"keyCreateFailed": "キーの作成に失敗しました",
"createSuccess": "ユーザーが作成されました",
+ "successTitle": "作成完了",
+ "successDescription": "ユーザーとキーが作成されました",
+ "generatedKey": "生成されたキー",
+ "keyHint": "このキーはユーザー管理ページからいつでも確認・コピーできます",
"keysSection": "キー",
"addKey": "キーを追加",
"removeKey": "キーを削除",
@@ -1489,7 +1502,7 @@
"label": "独立した個人使用量ページ",
"description": "有効にすると、このキーで独立した個人使用量ページにアクセスできます",
"descriptionEnabled": "有効にすると、このキーはログイン時に独立した個人使用量ページにアクセスします。ただし、自分のキーのプロバイダーグループは変更できません。",
- "descriptionDisabled": "無効にすると、ユーザーは個人使用量ページUIにアクセスできません。ただし、制限されたWeb UIで自分のキーのプロバイダーグループを変更できます。"
+ "descriptionDisabled": "無効にすると、ユーザーは個人使用量ページUIにアクセスできません。代わりに制限されたWeb UIを使用します。"
},
"providerGroup": {
"label": "プロバイダーグループ",
diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json
index 942ebb537..356e2afdf 100644
--- a/messages/ru/dashboard.json
+++ b/messages/ru/dashboard.json
@@ -752,6 +752,12 @@
"defaultDescription": "default включает провайдеров без groupTag.",
"descriptionWithUserGroup": "Группы провайдеров для этого ключа (группы пользователя: {group}; по умолчанию: default)."
},
+ "successTitle": "Ключ успешно создан",
+ "successDescription": "Ваш API-ключ был успешно создан.",
+ "generatedKey": {
+ "label": "Сгенерированный ключ",
+ "hint": "Вы можете просмотреть и скопировать этот ключ в любое время из списка ключей."
+ },
"errors": {
"userIdMissing": "ID пользователя не существует",
"createFailed": "Не удалось создать, попробуйте позже",
@@ -1120,6 +1126,9 @@
"defaultGroup": "default",
"userStatus": {
"disabled": "Отключен"
+ },
+ "actions": {
+ "addKey": "Добавить ключ"
}
},
"keyFullDisplay": {
@@ -1183,8 +1192,8 @@
"editKeyTitle": "Редактировать ключ"
},
"editDialog": {
- "title": "Редактировать пользователя и ключи",
- "description": "Редактирование данных пользователя и настроек API-ключей",
+ "title": "Редактировать пользователя",
+ "description": "Редактирование данных пользователя",
"userSection": "Настройки пользователя",
"keysSection": "Настройки ключей",
"scrollToKey": "Прокрутить к ключу",
@@ -1283,6 +1292,10 @@
"saveFailed": "Не удалось создать пользователя",
"keyCreateFailed": "Не удалось создать ключ",
"createSuccess": "Пользователь создан",
+ "successTitle": "Успешно создано",
+ "successDescription": "Пользователь и ключ успешно созданы",
+ "generatedKey": "Сгенерированный ключ",
+ "keyHint": "Вы можете просмотреть и скопировать этот ключ в любое время на странице управления пользователями",
"keysSection": "Ключи",
"addKey": "Добавить ключ",
"removeKey": "Удалить ключ",
@@ -1377,7 +1390,7 @@
"label": "Включить отдельную страницу проверки баланса",
"description": "Разрешить пользователю просматривать баланс на отдельной странице",
"descriptionEnabled": "При включении этот ключ будет использовать независимую страницу личного использования при входе. Однако он не может изменять группу провайдеров собственного ключа.",
- "descriptionDisabled": "При отключении пользователь не сможет получить доступ к странице личного использования. Однако он может изменять группу провайдеров своего ключа в ограниченном Web UI."
+ "descriptionDisabled": "При отключении пользователь не сможет получить доступ к странице личного использования. Вместо этого будет использоваться ограниченный Web UI."
},
"cacheTtlOverride": {
"label": "Переопределение Cache TTL",
diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json
index dfad6f083..7074481a2 100644
--- a/messages/zh-CN/dashboard.json
+++ b/messages/zh-CN/dashboard.json
@@ -770,6 +770,12 @@
"defaultDescription": "default 分组包含所有未设置 groupTag 的供应商",
"descriptionWithUserGroup": "供应商分组(默认:default;用户分组:{group})"
},
+ "successTitle": "密钥创建成功",
+ "successDescription": "您的 API 密钥已成功创建。",
+ "generatedKey": {
+ "label": "生成的密钥",
+ "hint": "您可以随时在密钥列表中查看和复制此密钥"
+ },
"errors": {
"userIdMissing": "用户ID不存在",
"createFailed": "创建失败,请稍后重试",
@@ -1143,6 +1149,9 @@
"defaultGroup": "default",
"userStatus": {
"disabled": "已禁用"
+ },
+ "actions": {
+ "addKey": "新增密钥"
}
},
"keyFullDisplay": {
@@ -1211,8 +1220,8 @@
"editKeyTitle": "编辑 Key"
},
"editDialog": {
- "title": "编辑用户与密钥",
- "description": "编辑用户信息和 API 密钥设置",
+ "title": "编辑用户",
+ "description": "编辑用户信息",
"userSection": "用户设置",
"keysSection": "密钥设置",
"scrollToKey": "滚动到密钥",
@@ -1311,6 +1320,10 @@
"saveFailed": "创建用户失败",
"keyCreateFailed": "创建密钥失败",
"createSuccess": "用户创建成功",
+ "successTitle": "创建成功",
+ "successDescription": "用户和密钥已成功创建",
+ "generatedKey": "生成的密钥",
+ "keyHint": "您可以随时在用户管理页面查看和复制此密钥",
"keysSection": "密钥",
"addKey": "添加密钥",
"removeKey": "删除密钥",
@@ -1528,7 +1541,7 @@
"label": "独立个人用量页面",
"description": "启用后,此密钥可使用独立的个人用量查询页面",
"descriptionEnabled": "启用后,此密钥在登录时将进入独立的个人用量页面。但不可修改自己密钥的供应商分组。",
- "descriptionDisabled": "关闭后,用户将无法进入个人独立用量页面 UI。但可在受限的 Web UI功能中修改自己密钥的供应商分组。"
+ "descriptionDisabled": "关闭后,用户将无法进入个人独立用量页面 UI,而是进入受限的 Web UI。"
},
"providerGroup": {
"label": "供应商分组",
diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json
index 40c18f295..c27eb01ee 100644
--- a/messages/zh-TW/dashboard.json
+++ b/messages/zh-TW/dashboard.json
@@ -750,6 +750,12 @@
"defaultDescription": "default 分組包含所有未設定 groupTag 的供應商",
"descriptionWithUserGroup": "供應商分組(預設:default;使用者分組:{group})"
},
+ "successTitle": "金鑰建立成功",
+ "successDescription": "您的 API 金鑰已成功建立。",
+ "generatedKey": {
+ "label": "產生的金鑰",
+ "hint": "您可以隨時在金鑰列表中查看和複製此金鑰"
+ },
"errors": {
"userIdMissing": "使用者 ID 不存在",
"createFailed": "建立失敗,請稍後重試",
@@ -1118,6 +1124,9 @@
"defaultGroup": "default",
"userStatus": {
"disabled": "已停用"
+ },
+ "actions": {
+ "addKey": "新增金鑰"
}
},
"keyFullDisplay": {
@@ -1181,8 +1190,8 @@
"editKeyTitle": "編輯 Key"
},
"editDialog": {
- "title": "編輯使用者與金鑰",
- "description": "編輯使用者資訊和 API 金鑰設定",
+ "title": "編輯使用者",
+ "description": "編輯使用者資訊",
"userSection": "使用者設定",
"keysSection": "金鑰設定",
"scrollToKey": "捲動到金鑰",
@@ -1281,6 +1290,10 @@
"saveFailed": "建立使用者失敗",
"keyCreateFailed": "建立金鑰失敗",
"createSuccess": "使用者建立成功",
+ "successTitle": "建立成功",
+ "successDescription": "使用者和金鑰已成功建立",
+ "generatedKey": "產生的金鑰",
+ "keyHint": "您可以隨時在使用者管理頁面查看和複製此金鑰",
"keysSection": "金鑰",
"addKey": "新增金鑰",
"removeKey": "刪除金鑰",
@@ -1498,7 +1511,7 @@
"label": "獨立個人用量頁面",
"description": "啟用後,此金鑰可使用獨立的個人用量查詢頁面",
"descriptionEnabled": "啟用後,此金鑰在登入時將進入獨立的個人用量頁面。但不可修改自己金鑰的供應商分組。",
- "descriptionDisabled": "關閉後,使用者將無法進入個人獨立用量頁面 UI。但可在受限的 Web UI功能中修改自己金鑰的供應商分組。"
+ "descriptionDisabled": "關閉後,使用者將無法進入個人獨立用量頁面 UI,而是進入受限的 Web UI。"
},
"providerGroup": {
"label": "供應商分組",
diff --git a/src/actions/providers.ts b/src/actions/providers.ts
index 6ae389675..4a8fb9788 100644
--- a/src/actions/providers.ts
+++ b/src/actions/providers.ts
@@ -3090,3 +3090,79 @@ async function fetchAnthropicModels(
return handleFetchException(error, "fetchAnthropicModels");
}
}
+
+/**
+ * 解析分组字符串为数组
+ */
+function parseGroupString(groupString: string): string[] {
+ return groupString
+ .split(",")
+ .map((g) => g.trim())
+ .filter(Boolean);
+}
+
+/**
+ * 检查供应商分组是否匹配用户分组
+ */
+function checkProviderGroupMatch(providerGroupTag: string | null, userGroups: string[]): boolean {
+ if (userGroups.includes(PROVIDER_GROUP.ALL)) {
+ return true;
+ }
+
+ const providerTags = providerGroupTag
+ ? parseGroupString(providerGroupTag)
+ : [PROVIDER_GROUP.DEFAULT];
+
+ return providerTags.some((tag) => userGroups.includes(tag));
+}
+
+/**
+ * 根据供应商分组获取模型建议列表
+ *
+ * 用于用户/密钥编辑时的模型限制下拉建议。
+ * 从匹配分组的启用供应商中收集 allowedModels 并去重。
+ *
+ * @param providerGroup - 可选的供应商分组(逗号分隔),默认为 "default"
+ * @returns 去重后的模型列表
+ */
+export async function getModelSuggestionsByProviderGroup(
+ providerGroup?: string | null
+): Promise> {
+ try {
+ const session = await getSession();
+ if (!session) {
+ return { ok: false, error: "未登录" };
+ }
+
+ // 获取所有启用的供应商
+ const providers = await findAllProviders();
+ const enabledProviders = providers.filter((p) => p.isEnabled);
+
+ // 解析用户分组
+ const userGroups = providerGroup ? parseGroupString(providerGroup) : [PROVIDER_GROUP.DEFAULT];
+
+ // 过滤匹配分组的供应商并收集 allowedModels
+ const modelSet = new Set();
+
+ for (const provider of enabledProviders) {
+ if (checkProviderGroupMatch(provider.groupTag, userGroups)) {
+ const models = provider.allowedModels;
+ if (models && Array.isArray(models)) {
+ for (const model of models) {
+ if (model) {
+ modelSet.add(model);
+ }
+ }
+ }
+ }
+ }
+
+ // 转换为数组并排序
+ const sortedModels = Array.from(modelSet).sort();
+
+ return { ok: true, data: sortedModels };
+ } catch (error) {
+ logger.error("获取模型建议列表失败:", error);
+ return { ok: false, error: "获取模型建议列表失败" };
+ }
+}
diff --git a/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx
new file mode 100644
index 000000000..4b12d238a
--- /dev/null
+++ b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import { Check, Copy } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import type { User } from "@/types/user";
+import { AddKeyForm } from "./forms/add-key-form";
+
+export interface AddKeyDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ userId: number;
+ user?: User;
+ isAdmin?: boolean;
+ onSuccess?: () => void;
+}
+
+interface GeneratedKeyInfo {
+ generatedKey: string;
+ name: string;
+}
+
+export function AddKeyDialog({
+ open,
+ onOpenChange,
+ userId,
+ user,
+ isAdmin,
+ onSuccess,
+}: AddKeyDialogProps) {
+ const t = useTranslations("dashboard.addKeyForm");
+ const tCommon = useTranslations("common");
+ const [generatedKey, setGeneratedKey] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ const handleSuccess = (result: GeneratedKeyInfo) => {
+ setGeneratedKey(result);
+ onSuccess?.();
+ };
+
+ const handleCopy = async () => {
+ if (!generatedKey) return;
+ try {
+ await navigator.clipboard.writeText(generatedKey.generatedKey);
+ setCopied(true);
+ toast.success(tCommon("copySuccess"));
+ setTimeout(() => setCopied(false), 2000);
+ } catch (error) {
+ console.error("[AddKeyDialog] copy failed", error);
+ toast.error(tCommon("copyFailed"));
+ }
+ };
+
+ const handleClose = () => {
+ setGeneratedKey(null);
+ setCopied(false);
+ onOpenChange(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx
new file mode 100644
index 000000000..d124bf8d1
--- /dev/null
+++ b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx
@@ -0,0 +1,425 @@
+"use client";
+
+import { useQueryClient } from "@tanstack/react-query";
+import { Check, Copy, Loader2, UserPlus } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useMemo, useState, useTransition } from "react";
+import { toast } from "sonner";
+import { z } from "zod";
+import { addKey } from "@/actions/keys";
+import { createUserOnly, removeUser } from "@/actions/users";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
+import { useZodForm } from "@/lib/hooks/use-zod-form";
+import { KeyFormSchema, UpdateUserSchema } from "@/lib/validation/schemas";
+import { KeyEditSection } from "./forms/key-edit-section";
+import { UserEditSection } from "./forms/user-edit-section";
+import { useKeyTranslations } from "./hooks/use-key-translations";
+import { useModelSuggestions } from "./hooks/use-model-suggestions";
+import { useUserTranslations } from "./hooks/use-user-translations";
+import { getFirstErrorMessage } from "./utils/form-utils";
+import { normalizeProviderGroup } from "./utils/provider-group";
+
+export interface CreateUserDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: () => void;
+}
+
+const CreateUserSchema = UpdateUserSchema.extend({
+ name: z.string().min(1).max(64),
+ providerGroup: z.string().max(50).nullable().optional(),
+ allowedClients: z.array(z.string().max(64)).max(50).optional().default([]),
+ allowedModels: z.array(z.string().max(64)).max(50).optional().default([]),
+ dailyQuota: z.number().nullable().optional(),
+});
+
+const CreateKeySchema = KeyFormSchema.extend({
+ id: z.number(),
+ isEnabled: z.boolean().optional(),
+ // 覆盖 expiresAt 以支持 Date 类型(KeyEditSection 返回 Date 对象)
+ expiresAt: z
+ .union([z.date(), z.string(), z.null(), z.undefined()])
+ .optional()
+ .transform((val) => {
+ if (val === null || val === undefined || val === "") return undefined;
+ if (val instanceof Date) return val.toISOString();
+ return val;
+ }),
+});
+
+const CreateFormSchema = z.object({
+ user: CreateUserSchema,
+ key: CreateKeySchema,
+});
+
+type CreateFormValues = z.infer;
+
+function getNextTempKeyId() {
+ return -Math.floor(Date.now() + Math.random() * 1000);
+}
+
+function buildDefaultValues(): CreateFormValues {
+ return {
+ user: {
+ name: "",
+ note: "",
+ tags: [],
+ expiresAt: undefined,
+ providerGroup: PROVIDER_GROUP.DEFAULT,
+ rpm: 0,
+ limit5hUsd: null,
+ dailyQuota: null,
+ limitWeeklyUsd: null,
+ limitMonthlyUsd: null,
+ limitTotalUsd: null,
+ limitConcurrentSessions: null,
+ dailyResetMode: "fixed",
+ dailyResetTime: "00:00",
+ allowedClients: [],
+ allowedModels: [],
+ },
+ key: {
+ id: getNextTempKeyId(),
+ name: "default",
+ isEnabled: true,
+ expiresAt: undefined,
+ canLoginWebUi: false,
+ providerGroup: PROVIDER_GROUP.DEFAULT,
+ cacheTtlPreference: "inherit" as const,
+ limit5hUsd: null,
+ limitDailyUsd: null,
+ dailyResetMode: "fixed",
+ dailyResetTime: "00:00",
+ limitWeeklyUsd: null,
+ limitMonthlyUsd: null,
+ limitTotalUsd: null,
+ limitConcurrentSessions: 0,
+ },
+ };
+}
+
+interface GeneratedKeyInfo {
+ generatedKey: string;
+ keyName: string;
+ userName: string;
+}
+
+function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProps) {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const t = useTranslations("dashboard.userManagement");
+ const tCommon = useTranslations("common");
+ const [isPending, startTransition] = useTransition();
+ const [generatedKey, setGeneratedKey] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ // Use shared hooks
+ const modelSuggestions = useModelSuggestions(PROVIDER_GROUP.DEFAULT);
+ const userEditTranslations = useUserTranslations({ showProviderGroup: false });
+ const keyEditTranslations = useKeyTranslations();
+
+ const defaultValues = useMemo(() => buildDefaultValues(), []);
+
+ const form = useZodForm({
+ schema: CreateFormSchema,
+ defaultValues,
+ onSubmit: async (data) => {
+ startTransition(async () => {
+ try {
+ // Create user first
+ const userRes = await createUserOnly({
+ name: data.user.name,
+ note: data.user.note,
+ tags: data.user.tags,
+ expiresAt: data.user.expiresAt ?? null,
+ rpm: data.user.rpm,
+ limit5hUsd: data.user.limit5hUsd,
+ dailyQuota: data.user.dailyQuota ?? undefined,
+ limitWeeklyUsd: data.user.limitWeeklyUsd,
+ limitMonthlyUsd: data.user.limitMonthlyUsd,
+ limitTotalUsd: data.user.limitTotalUsd,
+ limitConcurrentSessions: data.user.limitConcurrentSessions,
+ dailyResetMode: data.user.dailyResetMode,
+ dailyResetTime: data.user.dailyResetTime,
+ allowedClients: data.user.allowedClients,
+ allowedModels: data.user.allowedModels,
+ });
+ if (!userRes.ok) {
+ toast.error(userRes.error || t("createDialog.saveFailed"));
+ return;
+ }
+
+ const newUserId = userRes.data.user.id;
+
+ // Create the first key
+ const keyRes = await addKey({
+ userId: newUserId,
+ name: data.key.name,
+ // 重要:清除到期时间时用空字符串表达,避免 undefined 在 Server Action 序列化时被丢弃
+ expiresAt: data.key.expiresAt ?? "",
+ canLoginWebUi: data.key.canLoginWebUi,
+ providerGroup: normalizeProviderGroup(data.key.providerGroup),
+ cacheTtlPreference: data.key.cacheTtlPreference,
+ limit5hUsd: data.key.limit5hUsd,
+ limitDailyUsd: data.key.limitDailyUsd,
+ dailyResetMode: data.key.dailyResetMode,
+ dailyResetTime: data.key.dailyResetTime,
+ limitWeeklyUsd: data.key.limitWeeklyUsd,
+ limitMonthlyUsd: data.key.limitMonthlyUsd,
+ limitTotalUsd: data.key.limitTotalUsd,
+ limitConcurrentSessions: data.key.limitConcurrentSessions,
+ });
+
+ if (!keyRes.ok) {
+ // Rollback: delete the user since key creation failed
+ try {
+ await removeUser(newUserId);
+ } catch (rollbackError) {
+ console.error("[CreateUserDialog] rollback failed", rollbackError);
+ }
+ toast.error(keyRes.error || t("createDialog.keyCreateFailed", { name: data.key.name }));
+ return;
+ }
+
+ // Show generated key
+ setGeneratedKey({
+ generatedKey: keyRes.data?.generatedKey || "",
+ keyName: data.key.name,
+ userName: data.user.name,
+ });
+
+ onSuccess?.();
+ queryClient.invalidateQueries({ queryKey: ["users"] });
+ router.refresh();
+ } catch (error) {
+ console.error("[CreateUserDialog] submit failed", error);
+ toast.error(t("createDialog.saveFailed"));
+ }
+ });
+ },
+ });
+
+ const errorMessage = useMemo(() => getFirstErrorMessage(form.errors), [form.errors]);
+
+ const currentUserDraft = form.values.user || defaultValues.user;
+ const currentKeyDraft = form.values.key || defaultValues.key;
+
+ const handleUserChange = (field: string | Record, value?: any) => {
+ const prev = form.values.user || defaultValues.user;
+ const next = { ...prev };
+
+ if (typeof field === "object") {
+ Object.entries(field).forEach(([key, val]) => {
+ const mappedField = key === "description" ? "note" : key;
+ (next as any)[mappedField] = mappedField === "expiresAt" ? (val ?? undefined) : val;
+ });
+ } else {
+ const mappedField = field === "description" ? "note" : field;
+ if (mappedField === "expiresAt") {
+ (next as any)[mappedField] = value ?? undefined;
+ } else {
+ (next as any)[mappedField] = value;
+ }
+ }
+
+ // 直接替换整个 user 对象,因为 useZodForm.setValue 不支持嵌套路径
+ form.setValue("user" as any, next);
+ };
+
+ const handleKeyChange = (field: string | Record, value?: any) => {
+ const prev = form.values.key || defaultValues.key;
+ const next = { ...prev };
+
+ if (typeof field === "object") {
+ Object.entries(field).forEach(([key, val]) => {
+ (next as any)[key] = key === "expiresAt" ? (val ?? undefined) : val;
+ });
+ } else {
+ if (field === "expiresAt") {
+ (next as any)[field] = value ?? undefined;
+ } else {
+ (next as any)[field] = value;
+ }
+ }
+
+ // 直接替换整个 key 对象,因为 useZodForm.setValue 不支持嵌套路径
+ form.setValue("key" as any, next);
+ };
+
+ const handleCopy = async () => {
+ if (!generatedKey) return;
+ try {
+ await navigator.clipboard.writeText(generatedKey.generatedKey);
+ setCopied(true);
+ toast.success(tCommon("copySuccess"));
+ setTimeout(() => setCopied(false), 2000);
+ } catch (error) {
+ console.error("[CreateUserDialog] copy failed", error);
+ toast.error(tCommon("copyFailed"));
+ }
+ };
+
+ const handleClose = () => {
+ setGeneratedKey(null);
+ setCopied(false);
+ onOpenChange(false);
+ };
+
+ // Show generated key result
+ if (generatedKey) {
+ return (
+
+
+
+
+ {t("createDialog.successTitle")}
+
+ {t("createDialog.successDescription")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{t("createDialog.keyHint")}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+export function CreateUserDialog(props: CreateUserDialogProps) {
+ return (
+
+ );
+}
diff --git a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx
new file mode 100644
index 000000000..afaf10a2f
--- /dev/null
+++ b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import type { User } from "@/types/user";
+import { EditKeyForm } from "./forms/edit-key-form";
+
+export interface EditKeyDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ keyData: {
+ id: number;
+ name: string;
+ expiresAt: string;
+ canLoginWebUi?: boolean;
+ providerGroup?: string | null;
+ cacheTtlPreference?: "inherit" | "5m" | "1h";
+ limit5hUsd?: number | null;
+ limitDailyUsd?: number | null;
+ dailyResetMode?: "fixed" | "rolling";
+ dailyResetTime?: string;
+ limitWeeklyUsd?: number | null;
+ limitMonthlyUsd?: number | null;
+ limitTotalUsd?: number | null;
+ limitConcurrentSessions?: number;
+ };
+ user?: User;
+ isAdmin?: boolean;
+ onSuccess?: () => void;
+}
+
+export function EditKeyDialog({
+ open,
+ onOpenChange,
+ keyData,
+ user,
+ isAdmin,
+ onSuccess,
+}: EditKeyDialogProps) {
+ const t = useTranslations("quota.keys.editKeyForm");
+
+ const handleSuccess = () => {
+ onSuccess?.();
+ onOpenChange(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx
new file mode 100644
index 000000000..cc21562b3
--- /dev/null
+++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx
@@ -0,0 +1,268 @@
+"use client";
+
+import { useQueryClient } from "@tanstack/react-query";
+import { Loader2, UserCog } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useMemo, useTransition } from "react";
+import { toast } from "sonner";
+import { z } from "zod";
+import { editUser, removeUser, toggleUserEnabled } from "@/actions/users";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { useZodForm } from "@/lib/hooks/use-zod-form";
+import { UpdateUserSchema } from "@/lib/validation/schemas";
+import type { UserDisplay } from "@/types/user";
+import { DangerZone } from "./forms/danger-zone";
+import { UserEditSection } from "./forms/user-edit-section";
+import { useModelSuggestions } from "./hooks/use-model-suggestions";
+import { useUserTranslations } from "./hooks/use-user-translations";
+import { getFirstErrorMessage } from "./utils/form-utils";
+import { normalizeProviderGroup } from "./utils/provider-group";
+
+export interface EditUserDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ user: UserDisplay;
+ onSuccess?: () => void;
+}
+
+const EditUserSchema = UpdateUserSchema.extend({
+ name: z.string().min(1).max(64),
+ providerGroup: z.string().max(50).nullable().optional(),
+ allowedClients: z.array(z.string().max(64)).max(50).optional().default([]),
+ allowedModels: z.array(z.string().max(64)).max(50).optional().default([]),
+ dailyQuota: z.number().nullable().optional(),
+});
+
+type EditUserValues = z.infer;
+
+function buildDefaultValues(user: UserDisplay): EditUserValues {
+ return {
+ name: user.name || "",
+ note: user.note || "",
+ tags: user.tags || [],
+ expiresAt: user.expiresAt ?? undefined,
+ providerGroup: normalizeProviderGroup(user.providerGroup),
+ rpm: user.rpm ?? 0,
+ limit5hUsd: user.limit5hUsd ?? null,
+ dailyQuota: user.dailyQuota ?? null,
+ limitWeeklyUsd: user.limitWeeklyUsd ?? null,
+ limitMonthlyUsd: user.limitMonthlyUsd ?? null,
+ limitTotalUsd: user.limitTotalUsd ?? null,
+ limitConcurrentSessions: user.limitConcurrentSessions ?? null,
+ dailyResetMode: user.dailyResetMode ?? "fixed",
+ dailyResetTime: user.dailyResetTime ?? "00:00",
+ allowedClients: user.allowedClients || [],
+ allowedModels: user.allowedModels || [],
+ };
+}
+
+function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogProps) {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const t = useTranslations("dashboard.userManagement");
+ const tCommon = useTranslations("common");
+ const [isPending, startTransition] = useTransition();
+
+ // Use shared hooks
+ const modelSuggestions = useModelSuggestions(user.providerGroup);
+ const showUserProviderGroup = Boolean(user.providerGroup?.trim());
+ const userEditTranslations = useUserTranslations({ showProviderGroup: showUserProviderGroup });
+
+ const defaultValues = useMemo(() => buildDefaultValues(user), [user]);
+
+ const form = useZodForm({
+ schema: EditUserSchema,
+ defaultValues,
+ onSubmit: async (data) => {
+ startTransition(async () => {
+ try {
+ const userRes = await editUser(user.id, {
+ name: data.name,
+ note: data.note,
+ tags: data.tags,
+ expiresAt: data.expiresAt ?? null,
+ providerGroup: normalizeProviderGroup(data.providerGroup),
+ rpm: data.rpm,
+ limit5hUsd: data.limit5hUsd,
+ dailyQuota: data.dailyQuota,
+ limitWeeklyUsd: data.limitWeeklyUsd,
+ limitMonthlyUsd: data.limitMonthlyUsd,
+ limitTotalUsd: data.limitTotalUsd,
+ limitConcurrentSessions: data.limitConcurrentSessions,
+ dailyResetMode: data.dailyResetMode,
+ dailyResetTime: data.dailyResetTime,
+ allowedClients: data.allowedClients,
+ allowedModels: data.allowedModels,
+ });
+ if (!userRes.ok) {
+ toast.error(userRes.error || t("editDialog.saveFailed"));
+ return;
+ }
+
+ toast.success(t("editDialog.saveSuccess"));
+ onSuccess?.();
+ onOpenChange(false);
+ queryClient.invalidateQueries({ queryKey: ["users"] });
+ router.refresh();
+ } catch (error) {
+ console.error("[EditUserDialog] submit failed", error);
+ toast.error(t("editDialog.saveFailed"));
+ }
+ });
+ },
+ });
+
+ const errorMessage = useMemo(() => getFirstErrorMessage(form.errors), [form.errors]);
+
+ const currentUserDraft = form.values || defaultValues;
+
+ const handleUserChange = (field: string | Record, value?: any) => {
+ const prev = form.values || defaultValues;
+ const next = { ...prev } as EditUserValues;
+
+ if (typeof field === "object") {
+ Object.entries(field).forEach(([key, val]) => {
+ const mappedField = key === "description" ? "note" : key;
+ (next as any)[mappedField] = mappedField === "expiresAt" ? (val ?? undefined) : val;
+ });
+ } else {
+ const mappedField = field === "description" ? "note" : field;
+ if (mappedField === "expiresAt") {
+ (next as any)[mappedField] = value ?? undefined;
+ } else {
+ (next as any)[mappedField] = value;
+ }
+ }
+ // Set all changed fields
+ Object.keys(next).forEach((key) => {
+ if ((next as any)[key] !== (prev as any)[key]) {
+ form.setValue(key as keyof EditUserValues, (next as any)[key]);
+ }
+ });
+ };
+
+ const handleDisableUser = async () => {
+ const res = await toggleUserEnabled(user.id, false);
+ if (!res.ok) {
+ throw new Error(res.error || t("editDialog.operationFailed"));
+ }
+ toast.success(t("editDialog.userDisabled"));
+ onSuccess?.();
+ queryClient.invalidateQueries({ queryKey: ["users"] });
+ router.refresh();
+ };
+
+ const handleEnableUser = async () => {
+ const res = await toggleUserEnabled(user.id, true);
+ if (!res.ok) {
+ throw new Error(res.error || t("editDialog.operationFailed"));
+ }
+ toast.success(t("editDialog.userEnabled"));
+ onSuccess?.();
+ queryClient.invalidateQueries({ queryKey: ["users"] });
+ router.refresh();
+ };
+
+ const handleDeleteUser = async () => {
+ const res = await removeUser(user.id);
+ if (!res.ok) {
+ throw new Error(res.error || t("editDialog.deleteFailed"));
+ }
+ toast.success(t("editDialog.userDeleted"));
+ onSuccess?.();
+ onOpenChange(false);
+ queryClient.invalidateQueries({ queryKey: ["users"] });
+ router.refresh();
+ };
+
+ return (
+
+
+
+ );
+}
+
+export function EditUserDialog(props: EditUserDialogProps) {
+ return (
+
+ );
+}
diff --git a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx
index a3e040a39..377d5e50c 100644
--- a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx
+++ b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx
@@ -53,7 +53,7 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF
defaultValues: {
name: "",
expiresAt: "",
- canLoginWebUi: true,
+ canLoginWebUi: false,
providerGroup: PROVIDER_GROUP.DEFAULT,
cacheTtlPreference: "inherit",
limit5hUsd: null,
@@ -74,7 +74,8 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF
const result = await addKey({
userId: userId!,
name: data.name,
- expiresAt: data.expiresAt || undefined,
+ // 重要:清除到期时间时用空字符串表达,避免 undefined 在 Server Action 序列化时被丢弃
+ expiresAt: data.expiresAt ?? "",
canLoginWebUi: data.canLoginWebUi,
limit5hUsd: data.limit5hUsd,
limitDailyUsd: data.limitDailyUsd,
@@ -151,6 +152,10 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF
touched={form.getFieldProps("expiresAt").touched}
/>
+ {/* Balance Query Page toggle uses inverted logic by design:
+ - canLoginWebUi=true means user accesses full WebUI (switch OFF)
+ - canLoginWebUi=false means user uses independent balance page (switch ON)
+ The switch represents "enable independent page" which is !canLoginWebUi */}
form.setValue("canLoginWebUi", checked)}
+ checked={!form.values.canLoginWebUi}
+ onCheckedChange={(checked) => form.setValue("canLoginWebUi", !checked)}
/>
diff --git a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
index c2d2fc7c5..2458b2927 100644
--- a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
+++ b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
@@ -51,6 +51,9 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK
const router = useRouter();
const t = useTranslations("quota.keys.editKeyForm");
const tKeyEdit = useTranslations("dashboard.userManagement.keyEditSection.fields");
+ const tBalancePage = useTranslations(
+ "dashboard.userManagement.keyEditSection.fields.balanceQueryPage"
+ );
const tUI = useTranslations("ui.tagInput");
const tCommon = useTranslations("common");
const tErrors = useTranslations("errors");
@@ -168,17 +171,25 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK
touched={form.getFieldProps("expiresAt").touched}
/>
+ {/* Balance Query Page toggle uses inverted logic by design:
+ - canLoginWebUi=true means user accesses full WebUI (switch OFF)
+ - canLoginWebUi=false means user uses independent balance page (switch ON)
+ The switch represents "enable independent page" which is !canLoginWebUi */}
-
{t("canLoginWebUi.description")}
+
+ {form.values.canLoginWebUi
+ ? tBalancePage("descriptionDisabled")
+ : tBalancePage("descriptionEnabled")}
+
form.setValue("canLoginWebUi", checked)}
+ checked={!form.values.canLoginWebUi}
+ onCheckedChange={(checked) => form.setValue("canLoginWebUi", !checked)}
/>
diff --git a/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx b/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx
index e45b34eed..5da461294 100644
--- a/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx
+++ b/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx
@@ -48,6 +48,10 @@ export interface KeyEditSectionProps {
/** 是否是最后一个启用的 key (用于禁用 Switch 防止全部禁用) */
isLastEnabledKey?: boolean;
userProviderGroup?: string;
+ /** 是否显示限额规则区域,默认为 true */
+ showLimitRules?: boolean;
+ /** 是否显示到期时间区域,默认为 true */
+ showExpireTime?: boolean;
onChange: {
(field: string, value: any): void;
(batch: Record): void;
@@ -136,6 +140,8 @@ export function KeyEditSection({
isAdmin = false,
isLastEnabledKey = false,
userProviderGroup,
+ showLimitRules = true,
+ showExpireTime = true,
onChange,
scrollRef,
translations,
@@ -370,55 +376,59 @@ export function KeyEditSection({
- {/* 到期时间区域 */}
-
-
-
-
{translations.sections.expireTime}
-
- onChange("expiresAt", parseDateStringEndOfDay(val))}
- />
- onChange("expiresAt", toEndOfDay(date))}
- />
-
-
- {/* 限额规则区域 */}
-
-
+ {/* 到期时间区域 - 仅在 showExpireTime 为 true 时显示 */}
+ {showExpireTime && (
+
-
-
{translations.sections.limitRules}
+
+ {translations.sections.expireTime}
+
+ onChange("expiresAt", parseDateStringEndOfDay(val))}
+ />
+ onChange("expiresAt", toEndOfDay(date))}
+ />
+
+ )}
+
+ {/* 限额规则区域 - 仅在 showLimitRules 为 true 时显示 */}
+ {showLimitRules && (
+
+
+
+
+
{translations.sections.limitRules}
+
+
-
-
-
+
-
-
+
+
+ )}
{/* 特殊功能区域 */}
{
+ return {
+ sections: {
+ basicInfo: t("keyEditSection.sections.basicInfo"),
+ expireTime: t("keyEditSection.sections.expireTime"),
+ limitRules: t("keyEditSection.sections.limitRules"),
+ specialFeatures: t("keyEditSection.sections.specialFeatures"),
+ },
+ fields: {
+ keyName: {
+ label: t("keyEditSection.fields.keyName.label"),
+ placeholder: t("keyEditSection.fields.keyName.placeholder"),
+ },
+ balanceQueryPage: {
+ label: t("keyEditSection.fields.balanceQueryPage.label"),
+ description: t("keyEditSection.fields.balanceQueryPage.description"),
+ descriptionEnabled: t("keyEditSection.fields.balanceQueryPage.descriptionEnabled"),
+ descriptionDisabled: t("keyEditSection.fields.balanceQueryPage.descriptionDisabled"),
+ },
+ providerGroup: {
+ label: t("keyEditSection.fields.providerGroup.label"),
+ placeholder: t("keyEditSection.fields.providerGroup.placeholder"),
+ },
+ cacheTtl: {
+ label: t("keyEditSection.fields.cacheTtl.label"),
+ options: {
+ inherit: t("keyEditSection.fields.cacheTtl.options.inherit"),
+ "5m": t("keyEditSection.fields.cacheTtl.options.5m"),
+ "1h": t("keyEditSection.fields.cacheTtl.options.1h"),
+ },
+ },
+ enableStatus: {
+ label: t("keyEditSection.fields.enableStatus.label"),
+ description: t("keyEditSection.fields.enableStatus.description"),
+ },
+ },
+ limitRules: {
+ addRule: t("limitRules.addRule"),
+ ruleTypes: {
+ limit5h: t("limitRules.ruleTypes.limit5h"),
+ limitDaily: t("limitRules.ruleTypes.limitDaily"),
+ limitWeekly: t("limitRules.ruleTypes.limitWeekly"),
+ limitMonthly: t("limitRules.ruleTypes.limitMonthly"),
+ limitTotal: t("limitRules.ruleTypes.limitTotal"),
+ limitSessions: t("limitRules.ruleTypes.limitSessions"),
+ },
+ quickValues: {
+ unlimited: t("limitRules.quickValues.unlimited"),
+ "10": t("limitRules.quickValues.10"),
+ "50": t("limitRules.quickValues.50"),
+ "100": t("limitRules.quickValues.100"),
+ "500": t("limitRules.quickValues.500"),
+ },
+ },
+ quickExpire: {
+ week: t("quickExpire.oneWeek"),
+ month: t("quickExpire.oneMonth"),
+ threeMonths: t("quickExpire.threeMonths"),
+ year: t("quickExpire.oneYear"),
+ },
+ };
+ }, [t]);
+}
diff --git a/src/app/[locale]/dashboard/_components/user/hooks/use-model-suggestions.ts b/src/app/[locale]/dashboard/_components/user/hooks/use-model-suggestions.ts
new file mode 100644
index 000000000..9a440744a
--- /dev/null
+++ b/src/app/[locale]/dashboard/_components/user/hooks/use-model-suggestions.ts
@@ -0,0 +1,27 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { getModelSuggestionsByProviderGroup } from "@/actions/providers";
+
+/**
+ * Hook to fetch model suggestions for autocomplete.
+ * Returns an array of model names available for the given provider group.
+ * @param providerGroup - The provider group to filter models by (comma-separated)
+ */
+export function useModelSuggestions(providerGroup?: string | null): string[] {
+ const [modelSuggestions, setModelSuggestions] = useState([]);
+
+ useEffect(() => {
+ getModelSuggestionsByProviderGroup(providerGroup)
+ .then((res) => {
+ if (res.ok && res.data) {
+ setModelSuggestions(res.data);
+ }
+ })
+ .catch(() => {
+ // Silently fail - model suggestions are optional enhancement
+ });
+ }, [providerGroup]);
+
+ return modelSuggestions;
+}
diff --git a/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts b/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts
new file mode 100644
index 000000000..1360c99f1
--- /dev/null
+++ b/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts
@@ -0,0 +1,191 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { useMemo } from "react";
+
+export interface UserEditTranslations {
+ sections: {
+ basicInfo: string;
+ expireTime: string;
+ limitRules: string;
+ accessRestrictions: string;
+ };
+ fields: {
+ username: {
+ label: string;
+ placeholder: string;
+ };
+ description: {
+ label: string;
+ placeholder: string;
+ };
+ tags: {
+ label: string;
+ placeholder: string;
+ };
+ providerGroup?: {
+ 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;
+ customLabel: string;
+ customPlaceholder: string;
+ };
+ allowedModels: {
+ label: string;
+ placeholder: string;
+ description: string;
+ };
+ };
+ presetClients: {
+ "claude-cli": string;
+ "gemini-cli": string;
+ "factory-cli": string;
+ "codex-cli": string;
+ };
+ limitRules: {
+ addRule: string;
+ ruleTypes: {
+ limitRpm: string;
+ limit5h: string;
+ limitDaily: string;
+ limitWeekly: string;
+ limitMonthly: string;
+ limitTotal: string;
+ limitSessions: string;
+ };
+ quickValues: {
+ unlimited: string;
+ "10": string;
+ "50": string;
+ "100": string;
+ "500": string;
+ };
+ };
+ quickExpire: {
+ week: string;
+ month: string;
+ threeMonths: string;
+ year: string;
+ };
+}
+
+export interface UseUserTranslationsOptions {
+ showProviderGroup?: boolean;
+}
+
+/**
+ * Hook to build user edit section translations.
+ * Centralizes all translation key lookups for UserEditSection.
+ */
+export function useUserTranslations(
+ options: UseUserTranslationsOptions = {}
+): UserEditTranslations {
+ const { showProviderGroup = false } = options;
+ const t = useTranslations("dashboard.userManagement");
+
+ return useMemo(() => {
+ return {
+ sections: {
+ basicInfo: t("userEditSection.sections.basicInfo"),
+ expireTime: t("userEditSection.sections.expireTime"),
+ limitRules: t("userEditSection.sections.limitRules"),
+ accessRestrictions: t("userEditSection.sections.accessRestrictions"),
+ },
+ fields: {
+ username: {
+ label: t("userEditSection.fields.username.label"),
+ placeholder: t("userEditSection.fields.username.placeholder"),
+ },
+ description: {
+ label: t("userEditSection.fields.description.label"),
+ placeholder: t("userEditSection.fields.description.placeholder"),
+ },
+ tags: {
+ label: t("userEditSection.fields.tags.label"),
+ placeholder: t("userEditSection.fields.tags.placeholder"),
+ },
+ providerGroup: showProviderGroup
+ ? {
+ label: t("userEditSection.fields.providerGroup.label"),
+ placeholder: t("userEditSection.fields.providerGroup.placeholder"),
+ }
+ : undefined,
+ enableStatus: {
+ 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"),
+ },
+ allowedClients: {
+ label: t("userEditSection.fields.allowedClients.label"),
+ description: t("userEditSection.fields.allowedClients.description"),
+ customLabel: t("userEditSection.fields.allowedClients.customLabel"),
+ customPlaceholder: t("userEditSection.fields.allowedClients.customPlaceholder"),
+ },
+ allowedModels: {
+ label: t("userEditSection.fields.allowedModels.label"),
+ placeholder: t("userEditSection.fields.allowedModels.placeholder"),
+ description: t("userEditSection.fields.allowedModels.description"),
+ },
+ },
+ presetClients: {
+ "claude-cli": t("userEditSection.presetClients.claude-cli"),
+ "gemini-cli": t("userEditSection.presetClients.gemini-cli"),
+ "factory-cli": t("userEditSection.presetClients.factory-cli"),
+ "codex-cli": t("userEditSection.presetClients.codex-cli"),
+ },
+ limitRules: {
+ addRule: t("limitRules.addRule"),
+ ruleTypes: {
+ limitRpm: t("limitRules.ruleTypes.limitRpm"),
+ limit5h: t("limitRules.ruleTypes.limit5h"),
+ limitDaily: t("limitRules.ruleTypes.limitDaily"),
+ limitWeekly: t("limitRules.ruleTypes.limitWeekly"),
+ limitMonthly: t("limitRules.ruleTypes.limitMonthly"),
+ limitTotal: t("limitRules.ruleTypes.limitTotal"),
+ limitSessions: t("limitRules.ruleTypes.limitSessions"),
+ },
+ quickValues: {
+ unlimited: t("limitRules.quickValues.unlimited"),
+ "10": t("limitRules.quickValues.10"),
+ "50": t("limitRules.quickValues.50"),
+ "100": t("limitRules.quickValues.100"),
+ "500": t("limitRules.quickValues.500"),
+ },
+ },
+ quickExpire: {
+ week: t("quickExpire.oneWeek"),
+ month: t("quickExpire.oneMonth"),
+ threeMonths: t("quickExpire.threeMonths"),
+ year: t("quickExpire.oneYear"),
+ },
+ };
+ }, [t, showProviderGroup]);
+}
diff --git a/src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx b/src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx
deleted file mode 100644
index 05947ae7f..000000000
--- a/src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx
+++ /dev/null
@@ -1,1120 +0,0 @@
-"use client";
-
-import { useQueryClient } from "@tanstack/react-query";
-import {
- ChevronDown,
- ChevronUp,
- KeyRound,
- Loader2,
- Plus,
- Trash2,
- UserCog,
- UserPlus,
-} from "lucide-react";
-import { useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { useEffect, useMemo, useRef, useState, useTransition } from "react";
-import { toast } from "sonner";
-import { z } from "zod";
-import { addKey, editKey, removeKey } from "@/actions/keys";
-import { getFilterOptions } from "@/actions/usage-logs";
-import { createUserOnly, editUser, removeUser, toggleUserEnabled } from "@/actions/users";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Separator } from "@/components/ui/separator";
-import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
-import { useZodForm } from "@/lib/hooks/use-zod-form";
-import { KeyFormSchema, UpdateUserSchema } from "@/lib/validation/schemas";
-import type { UserDisplay } from "@/types/user";
-import { DangerZone } from "./forms/danger-zone";
-import { KeyEditSection } from "./forms/key-edit-section";
-import { UserEditSection } from "./forms/user-edit-section";
-
-export interface UnifiedEditDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- mode: "create" | "edit";
- user?: UserDisplay; // Required in edit mode, optional in create mode
- keyOnlyMode?: boolean;
- scrollToKeyId?: number;
- onSuccess?: () => void;
- currentUser?: { id: number; role: string };
-}
-
-const UnifiedUserSchema = UpdateUserSchema.extend({
- name: z.string().min(1).max(64),
- providerGroup: z.string().max(50).nullable().optional(),
- allowedClients: z.array(z.string().max(64)).max(50).optional().default([]),
- allowedModels: z.array(z.string().max(64)).max(50).optional().default([]),
- dailyQuota: z.number().nullable().optional(),
-});
-
-const UnifiedKeySchema = KeyFormSchema.extend({
- id: z.number(), // Negative IDs indicate new keys to be created
- isEnabled: z.boolean().optional(),
-});
-
-const UnifiedEditSchema = z.object({
- user: UnifiedUserSchema,
- keys: z.array(UnifiedKeySchema),
-});
-
-type UnifiedEditValues = z.infer;
-
-// Generate unique temporary negative IDs for new keys using timestamp + random
-function getNextTempKeyId() {
- return -Math.floor(Date.now() + Math.random() * 1000);
-}
-
-function parseYmdToEndOfDayIso(value: string): string | undefined {
- if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
- const [year, month, day] = value.split("-").map((v) => Number(v));
- if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return undefined;
- const date = new Date(year, month - 1, day);
- if (Number.isNaN(date.getTime())) return undefined;
- date.setHours(23, 59, 59, 999);
- return date.toISOString();
-}
-
-function getKeyExpiresAtIso(expiresAt: string): string | undefined {
- if (!expiresAt) return undefined;
- const ymd = parseYmdToEndOfDayIso(expiresAt);
- if (ymd) return ymd;
- const parsed = new Date(expiresAt);
- if (Number.isNaN(parsed.getTime())) return undefined;
- return parsed.toISOString();
-}
-
-function normalizeProviderGroup(value: unknown): string {
- if (value === null || value === undefined) return PROVIDER_GROUP.DEFAULT;
- if (typeof value !== "string") return PROVIDER_GROUP.DEFAULT;
- const trimmed = value.trim();
- if (trimmed === "") return PROVIDER_GROUP.DEFAULT;
-
- const groups = trimmed
- .split(",")
- .map((g) => g.trim())
- .filter(Boolean);
- if (groups.length === 0) return PROVIDER_GROUP.DEFAULT;
-
- return Array.from(new Set(groups)).sort().join(",");
-}
-
-function buildDefaultValues(
- mode: "create" | "edit",
- user?: UserDisplay,
- keyOnlyMode?: boolean
-): UnifiedEditValues {
- if (mode === "create") {
- return {
- user: {
- name: keyOnlyMode ? (user?.name ?? "self") : "",
- note: "",
- tags: [],
- expiresAt: undefined,
- providerGroup: PROVIDER_GROUP.DEFAULT,
- rpm: 0,
- limit5hUsd: null,
- dailyQuota: null,
- limitWeeklyUsd: null,
- limitMonthlyUsd: null,
- limitTotalUsd: null,
- limitConcurrentSessions: null,
- dailyResetMode: "fixed",
- dailyResetTime: "00:00",
- allowedClients: [],
- allowedModels: [],
- },
- keys: [
- {
- id: getNextTempKeyId(),
- name: "default",
- isEnabled: true,
- expiresAt: undefined,
- canLoginWebUi: false,
- providerGroup: PROVIDER_GROUP.DEFAULT,
- cacheTtlPreference: "inherit" as const,
- limit5hUsd: null,
- limitDailyUsd: null,
- dailyResetMode: "fixed",
- dailyResetTime: "00:00",
- limitWeeklyUsd: null,
- limitMonthlyUsd: null,
- limitTotalUsd: null,
- limitConcurrentSessions: 0,
- },
- ],
- };
- }
-
- // Edit mode - user must exist
- if (!user) {
- throw new Error("User is required in edit mode");
- }
-
- return {
- user: {
- name: user.name || "",
- note: user.note || "",
- tags: user.tags || [],
- expiresAt: user.expiresAt ?? undefined,
- providerGroup: normalizeProviderGroup(user.providerGroup),
- rpm: user.rpm ?? 0,
- limit5hUsd: user.limit5hUsd ?? null,
- dailyQuota: user.dailyQuota ?? null,
- limitWeeklyUsd: user.limitWeeklyUsd ?? null,
- limitMonthlyUsd: user.limitMonthlyUsd ?? null,
- limitTotalUsd: user.limitTotalUsd ?? null,
- limitConcurrentSessions: user.limitConcurrentSessions ?? null,
- dailyResetMode: user.dailyResetMode ?? "fixed",
- dailyResetTime: user.dailyResetTime ?? "00:00",
- allowedClients: user.allowedClients || [],
- allowedModels: user.allowedModels || [],
- },
- keys: user.keys.map((key) => ({
- id: key.id,
- name: key.name || "",
- isEnabled: key.status === "enabled",
- expiresAt: getKeyExpiresAtIso(key.expiresAt),
- canLoginWebUi: key.canLoginWebUi ?? false,
- providerGroup: normalizeProviderGroup(key.providerGroup),
- cacheTtlPreference: "inherit" as const,
- limit5hUsd: key.limit5hUsd ?? null,
- limitDailyUsd: key.limitDailyUsd ?? null,
- dailyResetMode: key.dailyResetMode ?? "fixed",
- dailyResetTime: key.dailyResetTime ?? "00:00",
- limitWeeklyUsd: key.limitWeeklyUsd ?? null,
- limitMonthlyUsd: key.limitMonthlyUsd ?? null,
- limitTotalUsd: key.limitTotalUsd ?? null,
- limitConcurrentSessions: key.limitConcurrentSessions ?? 0,
- })),
- };
-}
-
-function getFirstErrorMessage(errors: Record) {
- if (errors._form) return errors._form;
- const first = Object.entries(errors).find(([, msg]) => Boolean(msg));
- return first?.[1] || null;
-}
-
-function UnifiedEditDialogInner({
- onOpenChange,
- mode,
- user,
- keyOnlyMode,
- scrollToKeyId,
- onSuccess,
- currentUser,
-}: UnifiedEditDialogProps) {
- const router = useRouter();
- const queryClient = useQueryClient();
- const t = useTranslations("dashboard.userManagement");
- const tUsers = useTranslations("dashboard.users");
- const tCommon = useTranslations("common");
- const [isPending, startTransition] = useTransition();
- const keyScrollRef = useRef(null);
- const isAdmin = currentUser?.role === "admin";
- const isKeyOnlyMode = Boolean(keyOnlyMode);
- const [deletedKeyIds, setDeletedKeyIds] = useState([]);
- const [keyToDelete, setKeyToDelete] = useState<{ id: number; name: string } | null>(null);
- const [newlyAddedKeyId, setNewlyAddedKeyId] = useState(null);
- const [expandedKeyIds, setExpandedKeyIds] = useState>(() => {
- // Create mode or single key: all expanded
- if (mode === "create") return new Set([-1]); // placeholder for new keys
- if (!user || user.keys.length <= 1) return new Set(user?.keys.map((k) => k.id) || []);
- // Edit mode with multiple keys: only scrollToKeyId expanded
- if (scrollToKeyId) return new Set([scrollToKeyId]);
- return new Set(); // All collapsed
- });
- const [modelSuggestions, setModelSuggestions] = useState([]);
-
- // Fetch model suggestions for access restrictions
- useEffect(() => {
- getFilterOptions()
- .then((res) => {
- if (res.ok && res.data) {
- setModelSuggestions(res.data.models);
- }
- })
- .catch(() => {
- // Silently fail - model suggestions are optional enhancement
- // User can still manually type model names
- });
- }, []);
-
- // Auto-scroll to newly added key
- useEffect(() => {
- if (newlyAddedKeyId && keyScrollRef.current) {
- // Small delay to ensure DOM is updated
- const timer = setTimeout(() => {
- keyScrollRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
- setNewlyAddedKeyId(null);
- }, 100);
- return () => clearTimeout(timer);
- }
- }, [newlyAddedKeyId]);
-
- const defaultValues = useMemo(
- () => buildDefaultValues(mode, user, keyOnlyMode),
- [mode, user, keyOnlyMode]
- );
-
- const userProviderGroups = useMemo(() => {
- return normalizeProviderGroup(user?.providerGroup)
- .split(",")
- .map((g) => g.trim())
- .filter(Boolean);
- }, [user?.providerGroup]);
-
- const toggleKeyExpanded = (keyId: number) => {
- setExpandedKeyIds((prev) => {
- const next = new Set(prev);
- if (next.has(keyId)) {
- next.delete(keyId);
- } else {
- next.add(keyId);
- }
- return next;
- });
- };
-
- const form = useZodForm({
- schema: UnifiedEditSchema,
- defaultValues,
- 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;
- if (!targetUserId) {
- toast.error(t("editDialog.operationFailed"));
- return;
- }
-
- for (const key of data.keys) {
- const keyRes = await addKey({
- userId: targetUserId,
- name: key.name,
- expiresAt: key.expiresAt || undefined,
- canLoginWebUi: key.canLoginWebUi,
- providerGroup: normalizeProviderGroup(key.providerGroup),
- cacheTtlPreference: key.cacheTtlPreference,
- limit5hUsd: key.limit5hUsd,
- limitDailyUsd: key.limitDailyUsd,
- dailyResetMode: key.dailyResetMode,
- dailyResetTime: key.dailyResetTime,
- limitWeeklyUsd: key.limitWeeklyUsd,
- limitMonthlyUsd: key.limitMonthlyUsd,
- limitTotalUsd: key.limitTotalUsd,
- limitConcurrentSessions: key.limitConcurrentSessions,
- });
- if (!keyRes.ok) {
- toast.error(
- keyRes.error || t("createDialog.keyCreateFailed", { name: key.name })
- );
- return;
- }
- }
-
- toast.success(t("editDialog.saveSuccess"));
- } else {
- // Create user first
- const userRes = await createUserOnly({
- name: data.user.name,
- note: data.user.note,
- tags: data.user.tags,
- expiresAt: data.user.expiresAt ?? null,
- rpm: data.user.rpm,
- limit5hUsd: data.user.limit5hUsd,
- dailyQuota: data.user.dailyQuota ?? undefined,
- limitWeeklyUsd: data.user.limitWeeklyUsd,
- limitMonthlyUsd: data.user.limitMonthlyUsd,
- limitTotalUsd: data.user.limitTotalUsd,
- limitConcurrentSessions: data.user.limitConcurrentSessions,
- dailyResetMode: data.user.dailyResetMode,
- dailyResetTime: data.user.dailyResetTime,
- allowedClients: data.user.allowedClients,
- allowedModels: data.user.allowedModels,
- });
- if (!userRes.ok) {
- toast.error(userRes.error || t("createDialog.saveFailed"));
- return;
- }
-
- const newUserId = userRes.data.user.id;
-
- // Create all keys for the new user
- // If any key creation fails, rollback by deleting the user
- for (const key of data.keys) {
- const keyRes = await addKey({
- userId: newUserId,
- name: key.name,
- expiresAt: key.expiresAt || undefined,
- canLoginWebUi: key.canLoginWebUi,
- providerGroup: normalizeProviderGroup(key.providerGroup),
- cacheTtlPreference: key.cacheTtlPreference,
- limit5hUsd: key.limit5hUsd,
- limitDailyUsd: key.limitDailyUsd,
- dailyResetMode: key.dailyResetMode,
- dailyResetTime: key.dailyResetTime,
- limitWeeklyUsd: key.limitWeeklyUsd,
- limitMonthlyUsd: key.limitMonthlyUsd,
- limitTotalUsd: key.limitTotalUsd,
- limitConcurrentSessions: key.limitConcurrentSessions,
- });
- if (!keyRes.ok) {
- // Rollback: delete the user since key creation failed
- try {
- await removeUser(newUserId);
- } catch (rollbackError) {
- console.error("[UnifiedEditDialog] rollback failed", rollbackError);
- }
- toast.error(
- keyRes.error || t("createDialog.keyCreateFailed", { name: key.name })
- );
- return;
- }
- }
-
- toast.success(t("createDialog.createSuccess"));
- }
- } else {
- // Edit mode - user must exist
- if (!user) return;
-
- if (!isKeyOnlyMode) {
- const userRes = await editUser(user.id, {
- name: data.user.name,
- note: data.user.note,
- tags: data.user.tags,
- expiresAt: data.user.expiresAt ?? null,
- providerGroup: normalizeProviderGroup(data.user.providerGroup),
- rpm: data.user.rpm,
- limit5hUsd: data.user.limit5hUsd,
- dailyQuota: data.user.dailyQuota,
- limitWeeklyUsd: data.user.limitWeeklyUsd,
- limitMonthlyUsd: data.user.limitMonthlyUsd,
- limitTotalUsd: data.user.limitTotalUsd,
- limitConcurrentSessions: data.user.limitConcurrentSessions,
- dailyResetMode: data.user.dailyResetMode,
- dailyResetTime: data.user.dailyResetTime,
- allowedClients: data.user.allowedClients,
- allowedModels: data.user.allowedModels,
- });
- if (!userRes.ok) {
- toast.error(userRes.error || t("editDialog.saveFailed"));
- return;
- }
- }
-
- // Handle keys: edit existing, create new (negative ID), delete removed
- for (const key of data.keys) {
- if (key.id < 0) {
- // New key - create it
- const keyRes = await addKey({
- userId: user.id,
- name: key.name,
- // 重要:清除到期时间时用空字符串表达,避免 undefined 在 Server Action 序列化时被丢弃
- expiresAt: key.expiresAt ?? "",
- isEnabled: key.isEnabled,
- canLoginWebUi: key.canLoginWebUi,
- providerGroup: normalizeProviderGroup(key.providerGroup),
- cacheTtlPreference: key.cacheTtlPreference,
- limit5hUsd: key.limit5hUsd,
- limitDailyUsd: key.limitDailyUsd,
- dailyResetMode: key.dailyResetMode,
- dailyResetTime: key.dailyResetTime,
- limitWeeklyUsd: key.limitWeeklyUsd,
- limitMonthlyUsd: key.limitMonthlyUsd,
- limitTotalUsd: key.limitTotalUsd,
- limitConcurrentSessions: key.limitConcurrentSessions,
- });
- if (!keyRes.ok) {
- toast.error(
- keyRes.error || t("createDialog.keyCreateFailed", { name: key.name })
- );
- return;
- }
- } else {
- // Existing key - edit it
- const keyRes = await editKey(key.id, {
- name: key.name,
- // 重要:清除到期时间时用空字符串表达,避免 undefined 在 Server Action 序列化时被丢弃
- expiresAt: key.expiresAt ?? "",
- canLoginWebUi: key.canLoginWebUi,
- isEnabled: key.isEnabled,
- providerGroup: normalizeProviderGroup(key.providerGroup),
- cacheTtlPreference: key.cacheTtlPreference,
- limit5hUsd: key.limit5hUsd,
- limitDailyUsd: key.limitDailyUsd,
- dailyResetMode: key.dailyResetMode,
- dailyResetTime: key.dailyResetTime,
- limitWeeklyUsd: key.limitWeeklyUsd,
- limitMonthlyUsd: key.limitMonthlyUsd,
- limitTotalUsd: key.limitTotalUsd,
- limitConcurrentSessions: key.limitConcurrentSessions,
- });
- if (!keyRes.ok) {
- toast.error(keyRes.error || t("editDialog.keySaveFailed", { name: key.name }));
- return;
- }
- }
- }
-
- // Delete removed keys
- for (const deletedKeyId of deletedKeyIds) {
- const deleteRes = await removeKey(deletedKeyId);
- if (!deleteRes.ok) {
- toast.error(deleteRes.error || t("editDialog.keyDeleteFailed"));
- return;
- }
- }
-
- toast.success(t("editDialog.saveSuccess"));
- }
-
- onSuccess?.();
- onOpenChange(false);
- queryClient.invalidateQueries({ queryKey: ["users"] });
- router.refresh();
- } catch (error) {
- console.error("[UnifiedEditDialog] submit failed", error);
- toast.error(
- mode === "create"
- ? isKeyOnlyMode
- ? t("editDialog.operationFailed")
- : t("createDialog.saveFailed")
- : t("editDialog.saveFailed")
- );
- }
- });
- },
- });
-
- const errorMessage = useMemo(() => getFirstErrorMessage(form.errors), [form.errors]);
-
- const keys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"];
- const currentUserDraft = form.values.user || defaultValues.user;
- const showUserProviderGroup = mode === "edit" && Boolean(user?.providerGroup?.trim());
-
- const userEditTranslations = useMemo(() => {
- return {
- sections: {
- basicInfo: t("userEditSection.sections.basicInfo"),
- expireTime: t("userEditSection.sections.expireTime"),
- limitRules: t("userEditSection.sections.limitRules"),
- accessRestrictions: t("userEditSection.sections.accessRestrictions"),
- },
- fields: {
- username: {
- label: t("userEditSection.fields.username.label"),
- placeholder: t("userEditSection.fields.username.placeholder"),
- },
- description: {
- label: t("userEditSection.fields.description.label"),
- placeholder: t("userEditSection.fields.description.placeholder"),
- },
- tags: {
- label: t("userEditSection.fields.tags.label"),
- placeholder: t("userEditSection.fields.tags.placeholder"),
- },
- providerGroup: showUserProviderGroup
- ? {
- label: t("userEditSection.fields.providerGroup.label"),
- 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"),
- customLabel: t("userEditSection.fields.allowedClients.customLabel"),
- customPlaceholder: t("userEditSection.fields.allowedClients.customPlaceholder"),
- },
- allowedModels: {
- label: t("userEditSection.fields.allowedModels.label"),
- placeholder: t("userEditSection.fields.allowedModels.placeholder"),
- description: t("userEditSection.fields.allowedModels.description"),
- },
- },
- presetClients: {
- "claude-cli": t("userEditSection.presetClients.claude-cli"),
- "gemini-cli": t("userEditSection.presetClients.gemini-cli"),
- "factory-cli": t("userEditSection.presetClients.factory-cli"),
- "codex-cli": t("userEditSection.presetClients.codex-cli"),
- },
- limitRules: {
- addRule: t("limitRules.addRule"),
- ruleTypes: {
- limitRpm: t("limitRules.ruleTypes.limitRpm"),
- limit5h: t("limitRules.ruleTypes.limit5h"),
- limitDaily: t("limitRules.ruleTypes.limitDaily"),
- limitWeekly: t("limitRules.ruleTypes.limitWeekly"),
- limitMonthly: t("limitRules.ruleTypes.limitMonthly"),
- limitTotal: t("limitRules.ruleTypes.limitTotal"),
- limitSessions: t("limitRules.ruleTypes.limitSessions"),
- },
- quickValues: {
- unlimited: t("limitRules.quickValues.unlimited"),
- "10": t("limitRules.quickValues.10"),
- "50": t("limitRules.quickValues.50"),
- "100": t("limitRules.quickValues.100"),
- "500": t("limitRules.quickValues.500"),
- },
- },
- quickExpire: {
- week: t("quickExpire.oneWeek"),
- month: t("quickExpire.oneMonth"),
- threeMonths: t("quickExpire.threeMonths"),
- year: t("quickExpire.oneYear"),
- },
- };
- }, [t, showUserProviderGroup, mode, isAdmin]);
-
- const keyEditTranslations = useMemo(() => {
- return {
- sections: {
- basicInfo: t("keyEditSection.sections.basicInfo"),
- expireTime: t("keyEditSection.sections.expireTime"),
- limitRules: t("keyEditSection.sections.limitRules"),
- specialFeatures: t("keyEditSection.sections.specialFeatures"),
- },
- fields: {
- keyName: {
- label: t("keyEditSection.fields.keyName.label"),
- placeholder: t("keyEditSection.fields.keyName.placeholder"),
- },
- enableStatus: {
- label: t("keyEditSection.fields.enableStatus.label"),
- description: t("keyEditSection.fields.enableStatus.description"),
- cannotDisableTooltip: t("keyEditSection.fields.enableStatus.cannotDisableTooltip"),
- },
- balanceQueryPage: {
- label: t("keyEditSection.fields.balanceQueryPage.label"),
- description: t("keyEditSection.fields.balanceQueryPage.description"),
- descriptionEnabled: t("keyEditSection.fields.balanceQueryPage.descriptionEnabled"),
- descriptionDisabled: t("keyEditSection.fields.balanceQueryPage.descriptionDisabled"),
- },
- providerGroup: {
- label: t("keyEditSection.fields.providerGroup.label"),
- placeholder: t("keyEditSection.fields.providerGroup.placeholder"),
- selectHint: t("keyEditSection.fields.providerGroup.selectHint"),
- allGroups: t("keyEditSection.fields.providerGroup.allGroups"),
- noGroupHint: t("keyEditSection.fields.providerGroup.noGroupHint"),
- },
- cacheTtl: {
- label: t("keyEditSection.fields.cacheTtl.label"),
- options: {
- inherit: t("keyEditSection.fields.cacheTtl.options.inherit"),
- "5m": t("keyEditSection.fields.cacheTtl.options.5m"),
- "1h": t("keyEditSection.fields.cacheTtl.options.1h"),
- },
- },
- },
- limitRules: {
- title: t("keyEditSection.limitRules.title"),
- limitTypes: {
- limitRpm: t("limitRules.ruleTypes.limitRpm"),
- limit5h: t("limitRules.ruleTypes.limit5h"),
- limitDaily: t("limitRules.ruleTypes.limitDaily"),
- limitWeekly: t("limitRules.ruleTypes.limitWeekly"),
- limitMonthly: t("limitRules.ruleTypes.limitMonthly"),
- limitTotal: t("limitRules.ruleTypes.limitTotal"),
- limitSessions: t("limitRules.ruleTypes.limitSessions"),
- },
- quickValues: {
- unlimited: t("limitRules.quickValues.unlimited"),
- "10": t("limitRules.quickValues.10"),
- "50": t("limitRules.quickValues.50"),
- "100": t("limitRules.quickValues.100"),
- "500": t("limitRules.quickValues.500"),
- },
- actions: {
- add: t("keyEditSection.limitRules.actions.add"),
- remove: t("keyEditSection.limitRules.actions.remove"),
- },
- daily: {
- mode: {
- fixed: t("keyEditSection.limitRules.daily.mode.fixed"),
- rolling: t("keyEditSection.limitRules.daily.mode.rolling"),
- },
- },
- },
- quickExpire: {
- week: t("quickExpire.oneWeek"),
- month: t("quickExpire.oneMonth"),
- threeMonths: t("quickExpire.threeMonths"),
- year: t("quickExpire.oneYear"),
- },
- };
- }, [t]);
-
- const handleUserChange = (field: string | Record, value?: any) => {
- const prev = form.values.user || (defaultValues.user as UnifiedEditValues["user"]);
- const next = { ...prev } as UnifiedEditValues["user"];
-
- if (typeof field === "object") {
- // Batch update: apply multiple fields at once
- Object.entries(field).forEach(([key, val]) => {
- const mappedField = key === "description" ? "note" : key;
- (next as any)[mappedField] = mappedField === "expiresAt" ? (val ?? undefined) : val;
- });
- } else {
- // Single field update (backward compatible)
- const mappedField = field === "description" ? "note" : field;
- if (mappedField === "expiresAt") {
- (next as any)[mappedField] = value ?? undefined;
- } else {
- (next as any)[mappedField] = value;
- }
- }
- form.setValue("user", next);
- };
-
- const handleKeyChange = (keyId: number, field: string | Record, value?: any) => {
- const prevKeys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"];
- const nextKeys = prevKeys.map((k) => {
- if (k.id !== keyId) return k;
-
- if (typeof field === "object") {
- // Batch update
- const updates: Record = {};
- Object.entries(field).forEach(([key, val]) => {
- if (key === "expiresAt") {
- updates[key] = val ? (val as Date).toISOString() : undefined;
- } else {
- updates[key] = val;
- }
- });
- return { ...k, ...updates };
- }
-
- // Single field update (backward compatible)
- if (field === "expiresAt") {
- return { ...k, expiresAt: value ? (value as Date).toISOString() : undefined };
- }
- return { ...k, [field]: value };
- });
- form.setValue("keys", nextKeys);
- };
-
- const handleAddKey = () => {
- const prevKeys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"];
- const newKeyId = getNextTempKeyId();
- const newKey = {
- id: newKeyId,
- name: "",
- isEnabled: true,
- expiresAt: undefined,
- canLoginWebUi: true,
- providerGroup: PROVIDER_GROUP.DEFAULT,
- cacheTtlPreference: "inherit" as const,
- limit5hUsd: null,
- limitDailyUsd: null,
- dailyResetMode: "fixed" as const,
- dailyResetTime: "00:00",
- limitWeeklyUsd: null,
- limitMonthlyUsd: null,
- limitTotalUsd: null,
- limitConcurrentSessions: 0,
- };
- form.setValue("keys", [...prevKeys, newKey]);
- // Trigger auto-scroll to the newly added key
- setNewlyAddedKeyId(newKeyId);
- // Auto-expand the newly added key
- setExpandedKeyIds((prev) => new Set([...prev, newKeyId]));
- };
-
- const handleRemoveKey = (keyId: number, keyName: string) => {
- if (keyId < 0) {
- // New key (not yet saved) - remove directly without confirmation
- const prevKeys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"];
- form.setValue(
- "keys",
- prevKeys.filter((k) => k.id !== keyId)
- );
- } else {
- // Existing key - show confirmation dialog
- setKeyToDelete({ id: keyId, name: keyName });
- }
- };
-
- const confirmRemoveKey = () => {
- if (!keyToDelete) return;
- const prevKeys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"];
- form.setValue(
- "keys",
- prevKeys.filter((k) => k.id !== keyToDelete.id)
- );
- setDeletedKeyIds((prev) => [...prev, keyToDelete.id]);
- setKeyToDelete(null);
- };
-
- const handleDisableUser = async () => {
- if (!user) return;
- const res = await toggleUserEnabled(user.id, false);
- if (!res.ok) {
- throw new Error(res.error || t("editDialog.operationFailed"));
- }
- toast.success(t("editDialog.userDisabled"));
- onSuccess?.();
- queryClient.invalidateQueries({ queryKey: ["users"] });
- router.refresh();
- };
-
- const handleEnableUser = async () => {
- if (!user) return;
- const res = await toggleUserEnabled(user.id, true);
- if (!res.ok) {
- throw new Error(res.error || t("editDialog.operationFailed"));
- }
- toast.success(t("editDialog.userEnabled"));
- onSuccess?.();
- queryClient.invalidateQueries({ queryKey: ["users"] });
- router.refresh();
- };
-
- const handleDeleteUser = async () => {
- if (!user) return;
- const res = await removeUser(user.id);
- if (!res.ok) {
- throw new Error(res.error || t("editDialog.deleteFailed"));
- }
- toast.success(t("editDialog.userDeleted"));
- onSuccess?.();
- onOpenChange(false);
- queryClient.invalidateQueries({ queryKey: ["users"] });
- router.refresh();
- };
-
- return (
-
-
-
- {/* Delete key confirmation dialog */}
- !open && setKeyToDelete(null)}>
-
-
- {t("createDialog.confirmRemoveKeyTitle")}
-
- {t("createDialog.confirmRemoveKeyDescription", { name: keyToDelete?.name || "" })}
-
-
-
- {tCommon("cancel")}
- {tCommon("confirm")}
-
-
-
-
- );
-}
-
-export function UnifiedEditDialog(props: UnifiedEditDialogProps) {
- return (
-
- );
-}
diff --git a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
index 3eb2b121f..b85269904 100644
--- a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
+++ b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
@@ -1,7 +1,7 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
-import { ChevronDown, ChevronRight, SquarePen } from "lucide-react";
+import { ChevronDown, ChevronRight, Plus, SquarePen } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
@@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
import { getContrastTextColor, getGroupColor } from "@/lib/utils/color";
import { formatDate } from "@/lib/utils/date-format";
import type { UserDisplay } from "@/types/user";
+import { EditKeyDialog } from "./edit-key-dialog";
import { KeyRowItem } from "./key-row-item";
import { UserLimitBadge } from "./user-limit-badge";
@@ -31,7 +32,8 @@ export interface UserKeyTableRowProps {
onSelect?: (checked: boolean) => void;
selectedKeyIds?: Set;
onSelectKey?: (keyId: number, checked: boolean) => void;
- onEditUser: (scrollToKeyId?: number) => void;
+ onEditUser: () => void;
+ onAddKey?: () => void;
onQuickRenew?: (user: UserDisplay) => void;
optimisticExpiresAt?: Date;
currentUser?: { role: string };
@@ -61,6 +63,7 @@ export interface UserKeyTableRowProps {
details: string;
logs: string;
delete: string;
+ addKey?: string;
};
userStatus?: {
disabled: string;
@@ -119,6 +122,7 @@ export function UserKeyTableRow({
selectedKeyIds,
onSelectKey,
onEditUser,
+ onAddKey,
onQuickRenew,
optimisticExpiresAt,
currencyCode,
@@ -136,6 +140,8 @@ export function UserKeyTableRow({
const [localIsEnabled, setLocalIsEnabled] = useState(user.isEnabled);
// 乐观更新:本地状态跟踪过期时间
const [localExpiresAt, setLocalExpiresAt] = useState(user.expiresAt);
+ // Key 编辑 Dialog 状态
+ const [editingKeyId, setEditingKeyId] = useState(null);
const isExpanded = isMultiSelectMode ? true : expanded;
const resolvedGridColumnsClass = gridColumnsClass ?? DEFAULT_GRID_COLUMNS_CLASS;
@@ -456,10 +462,10 @@ export function UserKeyTableRow({
isMultiSelectMode={isMultiSelectMode}
isSelected={selectedKeyIds?.has(key.id) ?? false}
onSelect={(checked) => onSelectKey?.(key.id, checked)}
- onEdit={() => onEditUser(key.id)}
+ onEdit={() => setEditingKeyId(key.id)}
onDelete={() => handleDeleteKey(key.id)}
onViewLogs={() => router.push(`/dashboard/logs?keyId=${key.id}`)}
- onViewDetails={() => onEditUser(key.id)}
+ onViewDetails={() => setEditingKeyId(key.id)}
currencyCode={currencyCode}
translations={keyRowTranslations}
highlight={highlightKeyIds?.has(key.id)}
@@ -471,8 +477,72 @@ export function UserKeyTableRow({
{translations.noKeys}
)}
+ {onAddKey && (
+
+
+
+ )}
) : null}
+
+ {/* Key 编辑 Dialog */}
+ {editingKeyId !== null &&
+ (() => {
+ const editingKey = user.keys.find((k) => k.id === editingKeyId);
+ if (!editingKey) return null;
+ return (
+ {
+ if (!open) setEditingKeyId(null);
+ }}
+ keyData={{
+ id: editingKey.id,
+ name: editingKey.name,
+ expiresAt: editingKey.expiresAt,
+ canLoginWebUi: editingKey.canLoginWebUi,
+ providerGroup: editingKey.providerGroup ?? null,
+ limit5hUsd: editingKey.limit5hUsd,
+ limitDailyUsd: editingKey.limitDailyUsd,
+ dailyResetMode: editingKey.dailyResetMode,
+ dailyResetTime: editingKey.dailyResetTime,
+ limitWeeklyUsd: editingKey.limitWeeklyUsd,
+ limitMonthlyUsd: editingKey.limitMonthlyUsd,
+ limitTotalUsd: editingKey.limitTotalUsd,
+ limitConcurrentSessions: editingKey.limitConcurrentSessions,
+ }}
+ user={
+ {
+ id: user.id,
+ name: user.name,
+ providerGroup: user.providerGroup ?? null,
+ limit5hUsd: user.limit5hUsd ?? undefined,
+ limitWeeklyUsd: user.limitWeeklyUsd ?? undefined,
+ limitMonthlyUsd: user.limitMonthlyUsd ?? undefined,
+ limitTotalUsd: user.limitTotalUsd ?? undefined,
+ limitConcurrentSessions: user.limitConcurrentSessions ?? undefined,
+ } as any
+ }
+ isAdmin={isAdmin}
+ onSuccess={() => {
+ setEditingKeyId(null);
+ queryClient.invalidateQueries({ queryKey: ["users"] });
+ router.refresh();
+ }}
+ />
+ );
+ })()}
);
}
diff --git a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx
index 50e1c932c..ba13fa92d 100644
--- a/src/app/[locale]/dashboard/_components/user/user-management-table.tsx
+++ b/src/app/[locale]/dashboard/_components/user/user-management-table.tsx
@@ -12,8 +12,8 @@ import { useVirtualizer } from "@/hooks/use-virtualizer";
import { cn } from "@/lib/utils";
import type { User, UserDisplay } from "@/types/user";
import { BatchEditToolbar } from "./batch-edit/batch-edit-toolbar";
+import { EditUserDialog } from "./edit-user-dialog";
import { QuickRenewDialog, type QuickRenewUser } from "./forms/quick-renew-dialog";
-import { UnifiedEditDialog } from "./unified-edit-dialog";
import { UserKeyTableRow } from "./user-key-table-row";
export interface UserManagementTableProps {
@@ -26,6 +26,7 @@ export interface UserManagementTableProps {
currentUser?: User;
currencyCode?: string;
onCreateUser?: () => void;
+ onAddKey?: (user: UserDisplay) => void;
highlightKeyIds?: Set;
autoExpandOnFilter?: boolean;
isMultiSelectMode?: boolean;
@@ -110,6 +111,7 @@ export function UserManagementTable({
currentUser,
currencyCode,
onCreateUser,
+ onAddKey,
highlightKeyIds,
autoExpandOnFilter,
isMultiSelectMode,
@@ -140,7 +142,6 @@ export function UserManagementTable({
const prevAutoExpandRef = useRef(autoExpandOnFilter);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editingUserId, setEditingUserId] = useState(null);
- const [scrollToKeyId, setScrollToKeyId] = useState(undefined);
// Quick renew dialog state
const [quickRenewOpen, setQuickRenewOpen] = useState(false);
@@ -204,7 +205,10 @@ export function UserManagementTable({
collapse: translations.table.collapse,
noKeys: translations.table.noKeys,
defaultGroup: translations.table.defaultGroup,
- actions: translations.actions,
+ actions: {
+ ...translations.actions,
+ addKey: tUserMgmt("table.actions.addKey"),
+ },
userStatus: {
disabled: tUserMgmt("keyStatus.disabled"),
},
@@ -306,7 +310,6 @@ export function UserManagementTable({
if (!editingUser) {
setEditDialogOpen(false);
setEditingUserId(null);
- setScrollToKeyId(undefined);
}
}, [editDialogOpen, editingUser]);
@@ -323,9 +326,8 @@ export function UserManagementTable({
setExpandedUsers(new Map(users.map((user) => [user.id, nextExpanded])));
};
- const openEditDialog = (userId: number, keyId?: number) => {
+ const openEditDialog = (userId: number) => {
setEditingUserId(userId);
- setScrollToKeyId(keyId);
setEditDialogOpen(true);
};
@@ -333,7 +335,6 @@ export function UserManagementTable({
setEditDialogOpen(open);
if (open) return;
setEditingUserId(null);
- setScrollToKeyId(undefined);
};
// Quick renew handlers
@@ -568,7 +569,8 @@ export function UserManagementTable({
}
selectedKeyIds={selectedKeyIdSet}
onSelectKey={showMultiSelect ? onSelectKey : undefined}
- onEditUser={(keyId) => openEditDialog(user.id, keyId)}
+ onEditUser={() => openEditDialog(user.id)}
+ onAddKey={onAddKey ? () => onAddKey(user) : undefined}
onQuickRenew={isAdmin ? handleOpenQuickRenew : undefined}
optimisticExpiresAt={optimisticUserExpiries.get(user.id)}
currentUser={currentUser}
@@ -592,15 +594,11 @@ export function UserManagementTable({
) : null}
- {editingUser ? (
-
) : null}
diff --git a/src/app/[locale]/dashboard/_components/user/utils/form-utils.ts b/src/app/[locale]/dashboard/_components/user/utils/form-utils.ts
new file mode 100644
index 000000000..f65b9b4fe
--- /dev/null
+++ b/src/app/[locale]/dashboard/_components/user/utils/form-utils.ts
@@ -0,0 +1,9 @@
+/**
+ * Get the first error message from a form errors object.
+ * Prioritizes _form error, then returns the first non-empty message.
+ */
+export function getFirstErrorMessage(errors: Record): string | null {
+ if (errors._form) return errors._form;
+ const first = Object.entries(errors).find(([, msg]) => Boolean(msg));
+ return first?.[1] || null;
+}
diff --git a/src/app/[locale]/dashboard/_components/user/utils/provider-group.ts b/src/app/[locale]/dashboard/_components/user/utils/provider-group.ts
new file mode 100644
index 000000000..c1d5fde4f
--- /dev/null
+++ b/src/app/[locale]/dashboard/_components/user/utils/provider-group.ts
@@ -0,0 +1,23 @@
+import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
+
+/**
+ * Normalize provider group value to a consistent format.
+ * - Trims whitespace
+ * - Splits by comma and deduplicates
+ * - Sorts alphabetically
+ * - Returns DEFAULT if empty or invalid
+ */
+export function normalizeProviderGroup(value: unknown): string {
+ if (value === null || value === undefined) return PROVIDER_GROUP.DEFAULT;
+ if (typeof value !== "string") return PROVIDER_GROUP.DEFAULT;
+ const trimmed = value.trim();
+ if (trimmed === "") return PROVIDER_GROUP.DEFAULT;
+
+ const groups = trimmed
+ .split(",")
+ .map((g) => g.trim())
+ .filter(Boolean);
+ if (groups.length === 0) return PROVIDER_GROUP.DEFAULT;
+
+ return Array.from(new Set(groups)).sort().join(",");
+}
diff --git a/src/app/[locale]/dashboard/users/users-page-client.tsx b/src/app/[locale]/dashboard/users/users-page-client.tsx
index eeaa7e990..8ef674df9 100644
--- a/src/app/[locale]/dashboard/users/users-page-client.tsx
+++ b/src/app/[locale]/dashboard/users/users-page-client.tsx
@@ -5,8 +5,9 @@ import {
QueryClientProvider,
useInfiniteQuery,
useQuery,
+ useQueryClient,
} from "@tanstack/react-query";
-import { Key, Loader2, Plus, Search } from "lucide-react";
+import { Loader2, Plus, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getAllUserKeyGroups, getAllUserTags, getUsers, getUsersBatch } from "@/actions/users";
@@ -23,8 +24,9 @@ import { Skeleton } from "@/components/ui/skeleton";
import { TagInput } from "@/components/ui/tag-input";
import { useDebounce } from "@/lib/hooks/use-debounce";
import type { User, UserDisplay } from "@/types/user";
+import { AddKeyDialog } from "../_components/user/add-key-dialog";
import { BatchEditDialog } from "../_components/user/batch-edit/batch-edit-dialog";
-import { UnifiedEditDialog } from "../_components/user/unified-edit-dialog";
+import { CreateUserDialog } from "../_components/user/create-user-dialog";
import { UserManagementTable } from "../_components/user/user-management-table";
const queryClient = new QueryClient({
@@ -65,6 +67,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
const tUserMgmt = useTranslations("dashboard.userManagement");
const tKeyList = useTranslations("dashboard.keyList");
const tCommon = useTranslations("common");
+ const queryClient = useQueryClient();
const isAdmin = currentUser.role === "admin";
const [searchTerm, setSearchTerm] = useState("");
const [tagFilters, setTagFilters] = useState([]);
@@ -224,6 +227,10 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
// Create dialog state
const [showCreateDialog, setShowCreateDialog] = useState(false);
+ // Add key dialog state
+ const [showAddKeyDialog, setShowAddKeyDialog] = useState(false);
+ const [addKeyUser, setAddKeyUser] = useState(null);
+
const handleCreateUser = useCallback(() => {
setShowCreateDialog(true);
}, []);
@@ -236,6 +243,22 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
setShowCreateDialog(open);
}, []);
+ const handleAddKey = useCallback((user: UserDisplay) => {
+ setAddKeyUser(user);
+ setShowAddKeyDialog(true);
+ }, []);
+
+ const handleAddKeyDialogClose = useCallback((open: boolean) => {
+ setShowAddKeyDialog(open);
+ if (!open) {
+ setAddKeyUser(null);
+ }
+ }, []);
+
+ const handleKeyCreated = useCallback(() => {
+ queryClient.invalidateQueries({ queryKey: ["users"] });
+ }, [queryClient]);
+
const hasPendingFilterChanges = useMemo(() => {
const normalize = (values: string[]) => [...values].sort().join("|");
return (
@@ -473,16 +496,11 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
: t("description", { count: visibleUsers.length })}
- {isAdmin ? (
+ {isAdmin && (
- ) : (
-
)}
@@ -620,6 +638,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
currentUser={currentUser}
currencyCode="USD"
onCreateUser={isAdmin ? handleCreateUser : handleCreateKey}
+ onAddKey={handleAddKey}
highlightKeyIds={shouldHighlightKeys ? matchingKeyIds : undefined}
autoExpandOnFilter={shouldHighlightKeys}
isMultiSelectMode={isAdmin && isMultiSelectMode}
@@ -646,15 +665,53 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
/>
) : null}
- {/* Create User Dialog */}
-
+ {/* Create User Dialog (Admin) or Add Key Dialog (non-Admin) */}
+ {isAdmin ? (
+
+ ) : selfUser ? (
+
+ ) : null}
+
+ {/* Add Key Dialog (triggered from key list) */}
+ {addKeyUser && (
+
+ )}
);
}
diff --git a/tests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsx b/tests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsx
new file mode 100644
index 000000000..ba96678e0
--- /dev/null
+++ b/tests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsx
@@ -0,0 +1,140 @@
+/**
+ * @vitest-environment happy-dom
+ *
+ * AddKeyForm: expiresAt 清除测试
+ * 验证当用户清除到期时间后,提交时 expiresAt 字段被正确传递(空字符串而非 undefined)
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { NextIntlClientProvider } from "next-intl";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { Dialog } from "@/components/ui/dialog";
+import { AddKeyForm } from "@/app/[locale]/dashboard/_components/user/forms/add-key-form";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ refresh: vi.fn() }),
+}));
+
+const sonnerMocks = vi.hoisted(() => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+vi.mock("sonner", () => sonnerMocks);
+
+const keysActionMocks = vi.hoisted(() => ({
+ addKey: vi.fn(async () => ({ ok: true, data: { generatedKey: "sk-test", name: "test" } })),
+}));
+vi.mock("@/actions/keys", () => keysActionMocks);
+
+const providersActionMocks = vi.hoisted(() => ({
+ getAvailableProviderGroups: vi.fn(async () => []),
+}));
+vi.mock("@/actions/providers", () => providersActionMocks);
+
+function loadMessages() {
+ const base = path.join(process.cwd(), "messages/en");
+ const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8"));
+
+ return {
+ common: read("common.json"),
+ errors: read("errors.json"),
+ quota: read("quota.json"),
+ ui: read("ui.json"),
+ dashboard: read("dashboard.json"),
+ forms: read("forms.json"),
+ };
+}
+
+function render(node: ReactNode) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ act(() => {
+ root.render(node);
+ });
+
+ return {
+ container,
+ unmount: () => {
+ act(() => root.unmount());
+ container.remove();
+ },
+ };
+}
+
+describe("AddKeyForm: expiresAt 清除测试", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ document.body.innerHTML = "";
+ });
+
+ test("提交时应携带 expiresAt 字段(即使为空)", async () => {
+ const messages = loadMessages();
+ const onSuccess = vi.fn();
+
+ const { unmount } = render(
+
+
+
+ );
+
+ // 填写必填字段 - Key Name
+ const nameInput = document.body.querySelector('input[placeholder*="key"]') as HTMLInputElement;
+ if (nameInput) {
+ await act(async () => {
+ nameInput.value = "test-key";
+ nameInput.dispatchEvent(new Event("input", { bubbles: true }));
+ nameInput.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ }
+
+ // 提交表单
+ const submit = document.body.querySelector('button[type="submit"]') as HTMLButtonElement | null;
+ expect(submit).toBeTruthy();
+
+ await act(async () => {
+ submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await new Promise((r) => setTimeout(r, 50));
+ });
+
+ // 验证 addKey 被调用且 expiresAt 字段存在
+ if (keysActionMocks.addKey.mock.calls.length > 0) {
+ const payload = keysActionMocks.addKey.mock.calls[0][0] as Record;
+
+ // 关键点:expiresAt 必须存在于 payload 中
+ // 即使值为空字符串,也必须显式传递,后端才能识别为"清除"
+ expect(Object.hasOwn(payload, "expiresAt")).toBe(true);
+ // 空字符串或 undefined 都是有效的清除值,但根据修复,应该是空字符串
+ expect(payload.expiresAt === "" || payload.expiresAt === undefined).toBe(true);
+ }
+
+ unmount();
+ });
+
+ test("expiresAt 使用 ?? 而非 || 确保空字符串不被转换", async () => {
+ // 直接测试代码逻辑:验证 expiresAt ?? "" 的行为
+ // 当 expiresAt 为 ""(用户清除日期)时,应保持 ""
+ // 当 expiresAt 为 null/undefined 时,应变为 ""
+
+ const testCases = [
+ { input: "", expected: "" }, // 用户清除日期
+ { input: null, expected: "" }, // null 转为 ""
+ { input: undefined, expected: "" }, // undefined 转为 ""
+ { input: "2026-01-01", expected: "2026-01-01" }, // 有值时保持原值
+ ];
+
+ for (const { input, expected } of testCases) {
+ const result = input ?? "";
+ expect(result).toBe(expected);
+ }
+ });
+});
diff --git a/tests/unit/user-dialogs.test.tsx b/tests/unit/user-dialogs.test.tsx
new file mode 100644
index 000000000..fefd024bd
--- /dev/null
+++ b/tests/unit/user-dialogs.test.tsx
@@ -0,0 +1,752 @@
+/**
+ * @vitest-environment happy-dom
+ *
+ * 单元测试:用户管理 Dialog 组件
+ *
+ * 测试对象:
+ * - EditUserDialog
+ * - EditKeyDialog
+ * - AddKeyDialog
+ * - CreateUserDialog
+ */
+
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { NextIntlClientProvider } from "next-intl";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
+
+// ==================== Mocks ====================
+
+// Mock next/navigation
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ refresh: vi.fn(),
+ replace: vi.fn(),
+ }),
+}));
+
+// Mock @/i18n/routing
+vi.mock("@/i18n/routing", () => ({
+ Link: ({ children }: { children: ReactNode }) => children,
+ useRouter: () => ({
+ push: vi.fn(),
+ refresh: vi.fn(),
+ replace: vi.fn(),
+ }),
+}));
+
+// Mock Server Actions
+const mockEditUser = vi.fn().mockResolvedValue({ ok: true });
+const mockRemoveUser = vi.fn().mockResolvedValue({ ok: true });
+const mockToggleUserEnabled = vi.fn().mockResolvedValue({ ok: true });
+const mockAddKey = vi.fn().mockResolvedValue({ ok: true, data: { key: "sk-test-key" } });
+const mockEditKey = vi.fn().mockResolvedValue({ ok: true });
+const mockCreateUserOnly = vi.fn().mockResolvedValue({ ok: true, data: { user: { id: 1 } } });
+
+vi.mock("@/actions/users", () => ({
+ editUser: (...args: unknown[]) => mockEditUser(...args),
+ removeUser: (...args: unknown[]) => mockRemoveUser(...args),
+ toggleUserEnabled: (...args: unknown[]) => mockToggleUserEnabled(...args),
+ createUserOnly: (...args: unknown[]) => mockCreateUserOnly(...args),
+}));
+
+vi.mock("@/actions/keys", () => ({
+ addKey: (...args: unknown[]) => mockAddKey(...args),
+ editKey: (...args: unknown[]) => mockEditKey(...args),
+ removeKey: vi.fn().mockResolvedValue({ ok: true }),
+}));
+
+vi.mock("@/actions/usage-logs", () => {
+ return {
+ getFilterOptions: () => Promise.resolve({ ok: true, data: { models: [] } }),
+ };
+});
+
+// Mock sonner toast
+vi.mock("sonner", () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+// Mock Dialog components to simplify rendering
+vi.mock("@/components/ui/dialog", () => {
+ type PropsWithChildren = { children?: ReactNode };
+ type DialogContentProps = PropsWithChildren & { className?: string };
+
+ function Dialog({ children }: PropsWithChildren) {
+ return {children}
;
+ }
+
+ function DialogContent({ children, className }: DialogContentProps) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ function DialogHeader({ children }: PropsWithChildren) {
+ return {children}
;
+ }
+
+ function DialogTitle({ children }: PropsWithChildren) {
+ return {children}
;
+ }
+
+ function DialogDescription({ children, className }: PropsWithChildren & { className?: string }) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ function DialogFooter({ children, className }: PropsWithChildren & { className?: string }) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter };
+});
+
+// Mock form components
+vi.mock("@/app/[locale]/dashboard/_components/user/forms/user-edit-section", () => ({
+ UserEditSection: ({ user, onChange, translations: _translations }: any) => (
+
+ onChange("name", e.target.value)}
+ />
+
+ ),
+}));
+
+vi.mock("@/app/[locale]/dashboard/_components/user/forms/key-edit-section", () => ({
+ KeyEditSection: ({ keyData, onChange, translations: _translations }: any) => (
+
+ onChange("name", e.target.value)}
+ />
+
+ ),
+}));
+
+vi.mock("@/app/[locale]/dashboard/_components/user/forms/danger-zone", () => ({
+ DangerZone: ({ userId, userName, onDelete }: any) => (
+
+
+
+ ),
+}));
+
+vi.mock("@/app/[locale]/dashboard/_components/user/forms/add-key-form", () => ({
+ AddKeyForm: ({ userId, onSuccess }: any) => (
+
+
+
+ ),
+}));
+
+vi.mock("@/app/[locale]/dashboard/_components/user/forms/edit-key-form", () => ({
+ EditKeyForm: ({ keyData, onSuccess }: any) => (
+
+
+
+ ),
+}));
+
+// Import components after mocks
+import { EditUserDialog } from "@/app/[locale]/dashboard/_components/user/edit-user-dialog";
+import { EditKeyDialog } from "@/app/[locale]/dashboard/_components/user/edit-key-dialog";
+import { AddKeyDialog } from "@/app/[locale]/dashboard/_components/user/add-key-dialog";
+import { CreateUserDialog } from "@/app/[locale]/dashboard/_components/user/create-user-dialog";
+import type { UserDisplay } from "@/types/user";
+
+// ==================== Test Utilities ====================
+
+const messages = {
+ common: {
+ save: "Save",
+ cancel: "Cancel",
+ close: "Close",
+ copySuccess: "Copied",
+ copyFailed: "Copy failed",
+ },
+ dashboard: {
+ userManagement: {
+ editDialog: {
+ title: "Edit User",
+ description: "Edit user information",
+ saving: "Saving...",
+ saveSuccess: "User saved",
+ saveFailed: "Save failed",
+ operationFailed: "Operation failed",
+ userDisabled: "User disabled",
+ userEnabled: "User enabled",
+ deleteFailed: "Delete failed",
+ userDeleted: "User deleted",
+ },
+ createDialog: {
+ title: "Create User",
+ description: "Create a new user with API key",
+ creating: "Creating...",
+ create: "Create",
+ saveFailed: "Create failed",
+ successTitle: "User Created",
+ successDescription: "User created successfully",
+ generatedKey: "Generated Key",
+ keyHint: "Save this key, it cannot be recovered",
+ },
+ userEditSection: {
+ sections: {
+ basicInfo: "Basic Info",
+ expireTime: "Expiration",
+ limitRules: "Limits",
+ accessRestrictions: "Access",
+ },
+ fields: {
+ username: { label: "Username", placeholder: "Enter username" },
+ description: { label: "Note", placeholder: "Enter note" },
+ tags: { label: "Tags", placeholder: "Enter tags" },
+ providerGroup: { label: "Provider Group", placeholder: "Select group" },
+ enableStatus: {
+ label: "Status",
+ enabledDescription: "Enabled",
+ disabledDescription: "Disabled",
+ confirmEnable: "Enable",
+ confirmDisable: "Disable",
+ confirmEnableTitle: "Enable User",
+ confirmDisableTitle: "Disable User",
+ confirmEnableDescription: "Enable this user?",
+ confirmDisableDescription: "Disable this user?",
+ cancel: "Cancel",
+ processing: "Processing...",
+ },
+ allowedClients: {
+ label: "Allowed Clients",
+ description: "Restrict clients",
+ customLabel: "Custom",
+ customPlaceholder: "Custom client",
+ },
+ allowedModels: {
+ label: "Allowed Models",
+ placeholder: "Select models",
+ description: "Restrict models",
+ },
+ },
+ presetClients: {
+ "claude-cli": "Claude CLI",
+ "gemini-cli": "Gemini CLI",
+ "factory-cli": "Factory CLI",
+ "codex-cli": "Codex CLI",
+ },
+ },
+ keyEditSection: {
+ sections: {
+ basicInfo: "Basic Information",
+ expireTime: "Expiration Time",
+ limitRules: "Limit Rules",
+ specialFeatures: "Special Features",
+ },
+ fields: {
+ keyName: { label: "Key Name", placeholder: "Enter key name" },
+ providerGroup: { label: "Provider Group", placeholder: "Default: default" },
+ cacheTtl: {
+ label: "Cache TTL Override",
+ options: { inherit: "No override", "5m": "5m", "1h": "1h" },
+ },
+ balanceQueryPage: {
+ label: "Independent Personal Usage Page",
+ description: "When enabled, this key can access an independent personal usage page",
+ descriptionEnabled: "Enabled description",
+ descriptionDisabled: "Disabled description",
+ },
+ enableStatus: {
+ label: "Enable Status",
+ description: "Disabled keys cannot be used",
+ },
+ },
+ },
+ dangerZone: {
+ title: "Danger Zone",
+ deleteUser: "Delete User",
+ deleteUserDescription: "This action cannot be undone",
+ deleteConfirm: "Type username to confirm",
+ deleteButton: "Delete",
+ },
+ limitRules: {
+ addRule: "Add Rule",
+ ruleTypes: {
+ limitRpm: "RPM",
+ limit5h: "5h Limit",
+ limitDaily: "Daily",
+ limitWeekly: "Weekly",
+ limitMonthly: "Monthly",
+ limitTotal: "Total",
+ limitSessions: "Sessions",
+ },
+ quickValues: {
+ unlimited: "Unlimited",
+ "10": "$10",
+ "50": "$50",
+ "100": "$100",
+ "500": "$500",
+ },
+ },
+ quickExpire: {
+ oneWeek: "1 Week",
+ oneMonth: "1 Month",
+ threeMonths: "3 Months",
+ oneYear: "1 Year",
+ },
+ },
+ addKeyForm: {
+ title: "Add Key",
+ description: "Add a new API key",
+ successTitle: "Key Created",
+ successDescription: "Key created successfully",
+ generatedKey: {
+ label: "Generated Key",
+ hint: "Save this key",
+ },
+ keyName: {
+ label: "Key Name",
+ },
+ },
+ },
+ quota: {
+ keys: {
+ editKeyForm: {
+ title: "Edit Key",
+ description: "Edit key settings",
+ },
+ },
+ },
+};
+
+let queryClient: QueryClient;
+
+function renderWithProviders(node: ReactNode) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ act(() => {
+ root.render(
+
+
+ {node}
+
+
+ );
+ });
+
+ return {
+ container,
+ unmount: () => {
+ act(() => root.unmount());
+ container.remove();
+ },
+ };
+}
+
+// Mock user data
+const mockUser: UserDisplay = {
+ id: 1,
+ name: "Test User",
+ note: "Test note",
+ role: "user",
+ rpm: 10,
+ dailyQuota: 100,
+ providerGroup: "default",
+ tags: ["test"],
+ keys: [],
+ isEnabled: true,
+ expiresAt: null,
+};
+
+// ==================== Tests ====================
+
+describe("EditUserDialog", () => {
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ test("renders dialog with user data when open", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ expect(container.querySelector('[data-testid="dialog-root"]')).not.toBeNull();
+ expect(container.querySelector('[data-testid="dialog-title"]')?.textContent).toContain(
+ "Edit User"
+ );
+ expect(container.querySelector('[data-testid="user-edit-section"]')).not.toBeNull();
+ expect(container.querySelector('[data-testid="danger-zone"]')).not.toBeNull();
+
+ unmount();
+ });
+
+ test("does not render content when closed", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ // Dialog root exists but content should be minimal
+ expect(container.querySelector('[data-testid="user-edit-section"]')).toBeNull();
+
+ unmount();
+ });
+
+ test("passes correct user id to UserEditSection", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ const userEditSection = container.querySelector('[data-testid="user-edit-section"]');
+ expect(userEditSection?.getAttribute("data-user-id")).toBe("1");
+
+ unmount();
+ });
+
+ test("passes correct user id to DangerZone", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ const dangerZone = container.querySelector('[data-testid="danger-zone"]');
+ expect(dangerZone?.getAttribute("data-user-id")).toBe("1");
+
+ unmount();
+ });
+
+ test("has save and cancel buttons", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ const buttons = container.querySelectorAll("button");
+ const buttonTexts = Array.from(buttons).map((b) => b.textContent);
+
+ expect(buttonTexts).toContain("Save");
+ expect(buttonTexts).toContain("Cancel");
+
+ unmount();
+ });
+});
+
+describe("EditKeyDialog", () => {
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ const mockKeyData = {
+ id: 1,
+ name: "Test Key",
+ expiresAt: "2025-12-31",
+ canLoginWebUi: false,
+ providerGroup: null,
+ };
+
+ test("renders dialog with key data when open", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ expect(container.querySelector('[data-testid="dialog-root"]')).not.toBeNull();
+ expect(container.querySelector('[data-testid="dialog-title"]')?.textContent).toContain(
+ "Edit Key"
+ );
+ expect(container.querySelector('[data-testid="edit-key-form"]')).not.toBeNull();
+
+ unmount();
+ });
+
+ test("passes keyData to EditKeyForm", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ const editKeyForm = container.querySelector('[data-testid="edit-key-form"]');
+ expect(editKeyForm?.getAttribute("data-key-id")).toBe("1");
+
+ unmount();
+ });
+
+ test("calls onOpenChange when dialog is closed", () => {
+ const onOpenChange = vi.fn();
+ const onSuccess = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ // Simulate clicking save in the mocked form
+ const submitButton = container.querySelector('[data-testid="edit-key-submit"]') as HTMLElement;
+ act(() => {
+ submitButton?.click();
+ });
+
+ expect(onSuccess).toHaveBeenCalled();
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+
+ unmount();
+ });
+});
+
+describe("AddKeyDialog", () => {
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ test("renders dialog with add key form when open", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ expect(container.querySelector('[data-testid="dialog-root"]')).not.toBeNull();
+ expect(container.querySelector('[data-testid="dialog-title"]')?.textContent).toContain(
+ "Add Key"
+ );
+ expect(container.querySelector('[data-testid="add-key-form"]')).not.toBeNull();
+
+ unmount();
+ });
+
+ test("passes userId to AddKeyForm", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ const addKeyForm = container.querySelector('[data-testid="add-key-form"]');
+ expect(addKeyForm?.getAttribute("data-user-id")).toBe("42");
+
+ unmount();
+ });
+
+ test("calls onSuccess after successful key creation", () => {
+ const onOpenChange = vi.fn();
+ const onSuccess = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ // Initially shows form
+ expect(container.querySelector('[data-testid="add-key-form"]')).not.toBeNull();
+
+ // Simulate successful key creation
+ const submitButton = container.querySelector('[data-testid="add-key-submit"]') as HTMLElement;
+ act(() => {
+ submitButton?.click();
+ });
+
+ // onSuccess should be called
+ expect(onSuccess).toHaveBeenCalled();
+
+ // The component should now show the success view with generated key info
+ // (key name "test" from mock result)
+ expect(container.textContent).toContain("Key Created");
+
+ unmount();
+ });
+});
+
+describe("CreateUserDialog", () => {
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ vi.clearAllMocks();
+ mockCreateUserOnly.mockResolvedValue({ ok: true, data: { user: { id: 1 } } });
+ mockAddKey.mockResolvedValue({ ok: true, data: { key: "sk-new-user-key" } });
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ test("renders dialog with user and key sections when open", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ expect(container.querySelector('[data-testid="dialog-root"]')).not.toBeNull();
+ expect(container.querySelector('[data-testid="dialog-title"]')?.textContent).toContain(
+ "Create User"
+ );
+ expect(container.querySelector('[data-testid="user-edit-section"]')).not.toBeNull();
+ expect(container.querySelector('[data-testid="key-edit-section"]')).not.toBeNull();
+
+ unmount();
+ });
+
+ test("does not render content when closed", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ expect(container.querySelector('[data-testid="user-edit-section"]')).toBeNull();
+ expect(container.querySelector('[data-testid="key-edit-section"]')).toBeNull();
+
+ unmount();
+ });
+
+ test("has create and cancel buttons", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ const buttons = container.querySelectorAll("button");
+ const buttonTexts = Array.from(buttons).map((b) => b.textContent);
+
+ expect(buttonTexts).toContain("Create");
+ expect(buttonTexts).toContain("Cancel");
+
+ unmount();
+ });
+});
+
+describe("Dialog Component Integration", () => {
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ test("EditUserDialog re-renders with new user when user prop changes", () => {
+ const onOpenChange = vi.fn();
+
+ const { container, unmount } = renderWithProviders(
+
+ );
+
+ // Check initial user
+ let userEditSection = container.querySelector('[data-testid="user-edit-section"]');
+ expect(userEditSection?.getAttribute("data-user-id")).toBe("1");
+
+ unmount();
+
+ // Render with different user
+ const newUser = { ...mockUser, id: 2, name: "New User" };
+ const { container: container2, unmount: unmount2 } = renderWithProviders(
+
+ );
+
+ userEditSection = container2.querySelector('[data-testid="user-edit-section"]');
+ expect(userEditSection?.getAttribute("data-user-id")).toBe("2");
+
+ unmount2();
+ });
+
+ test("all dialogs have accessible title", () => {
+ const onOpenChange = vi.fn();
+
+ // EditUserDialog
+ const edit = renderWithProviders(
+
+ );
+ expect(edit.container.querySelector('[data-testid="dialog-title"]')).not.toBeNull();
+ edit.unmount();
+
+ // EditKeyDialog
+ const editKey = renderWithProviders(
+
+ );
+ expect(editKey.container.querySelector('[data-testid="dialog-title"]')).not.toBeNull();
+ editKey.unmount();
+
+ // AddKeyDialog
+ const addKey = renderWithProviders(
+
+ );
+ expect(addKey.container.querySelector('[data-testid="dialog-title"]')).not.toBeNull();
+ addKey.unmount();
+
+ // CreateUserDialog
+ const create = renderWithProviders(
+
+ );
+ expect(create.container.querySelector('[data-testid="dialog-title"]')).not.toBeNull();
+ create.unmount();
+ });
+});
From 3fc8585dae59bd5d8b0ce6a256d89b99ce9352a8 Mon Sep 17 00:00:00 2001
From: NieiR
Date: Mon, 5 Jan 2026 10:49:39 +0800
Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=B8=BA=20Key=20Dialog=20=E7=BB=84?=
=?UTF-8?q?=E4=BB=B6=E5=AE=9A=E4=B9=89=E7=B2=BE=E7=A1=AE=E7=B1=BB=E5=9E=8B?=
=?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=20as=20any?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 KeyDialogUserContext 类型,替代过于宽泛的 User 类型
- 移除 4 处 as any 类型断言
- 清理 key-list-header.tsx 中伪造的字段
Addresses Gemini Code Review feedback on type safety
---
.../_components/user/add-key-dialog.tsx | 4 +-
.../_components/user/edit-key-dialog.tsx | 4 +-
.../_components/user/forms/add-key-form.tsx | 4 +-
.../_components/user/forms/edit-key-form.tsx | 4 +-
.../_components/user/key-list-header.tsx | 14 +------
.../_components/user/user-key-table-row.tsx | 21 ++++------
.../dashboard/users/users-page-client.tsx | 42 ++++++++-----------
src/types/user.ts | 14 +++++++
8 files changed, 51 insertions(+), 56 deletions(-)
diff --git a/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx
index 4b12d238a..ab5f45392 100644
--- a/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx
+++ b/src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx
@@ -14,14 +14,14 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import type { User } from "@/types/user";
+import type { KeyDialogUserContext } from "@/types/user";
import { AddKeyForm } from "./forms/add-key-form";
export interface AddKeyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
userId: number;
- user?: User;
+ user?: KeyDialogUserContext;
isAdmin?: boolean;
onSuccess?: () => void;
}
diff --git a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx
index afaf10a2f..884745ab8 100644
--- a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx
+++ b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx
@@ -8,7 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import type { User } from "@/types/user";
+import type { KeyDialogUserContext } from "@/types/user";
import { EditKeyForm } from "./forms/edit-key-form";
export interface EditKeyDialogProps {
@@ -30,7 +30,7 @@ export interface EditKeyDialogProps {
limitTotalUsd?: number | null;
limitConcurrentSessions?: number;
};
- user?: User;
+ user?: KeyDialogUserContext;
isAdmin?: boolean;
onSuccess?: () => void;
}
diff --git a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx
index 377d5e50c..c63eea78c 100644
--- a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx
+++ b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx
@@ -21,11 +21,11 @@ import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
import { useZodForm } from "@/lib/hooks/use-zod-form";
import { getErrorMessage } from "@/lib/utils/error-messages";
import { KeyFormSchema } from "@/lib/validation/schemas";
-import type { User } from "@/types/user";
+import type { KeyDialogUserContext } from "@/types/user";
interface AddKeyFormProps {
userId?: number;
- user?: User;
+ user?: KeyDialogUserContext;
isAdmin?: boolean;
onSuccess?: (result: { generatedKey: string; name: string }) => void;
}
diff --git a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
index 2458b2927..cabddee3e 100644
--- a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
+++ b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
@@ -21,7 +21,7 @@ import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
import { useZodForm } from "@/lib/hooks/use-zod-form";
import { getErrorMessage } from "@/lib/utils/error-messages";
import { KeyFormSchema } from "@/lib/validation/schemas";
-import type { User } from "@/types/user";
+import type { KeyDialogUserContext } from "@/types/user";
interface EditKeyFormProps {
keyData?: {
@@ -40,7 +40,7 @@ interface EditKeyFormProps {
limitTotalUsd?: number | null;
limitConcurrentSessions?: number;
};
- user?: User;
+ user?: KeyDialogUserContext;
isAdmin?: boolean;
onSuccess?: () => void;
}
diff --git a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx
index a05244716..e80a11d94 100644
--- a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx
+++ b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx
@@ -322,22 +322,12 @@ export function KeyListHeader({
activeUser
? {
id: activeUser.id,
- name: activeUser.name,
- description: activeUser.note || "",
- role: activeUser.role,
- rpm: activeUser.rpm,
- dailyQuota: activeUser.dailyQuota,
- providerGroup: activeUser.providerGroup || "default",
- createdAt: new Date(),
- updatedAt: new Date(),
+ providerGroup: activeUser.providerGroup ?? null,
limit5hUsd: activeUser.limit5hUsd ?? undefined,
limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined,
limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined,
+ limitTotalUsd: activeUser.limitTotalUsd ?? undefined,
limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined,
- dailyResetMode: activeUser.dailyResetMode ?? "fixed",
- dailyResetTime: activeUser.dailyResetTime ?? "00:00",
- isEnabled: activeUser.isEnabled,
- expiresAt: activeUser.expiresAt ?? undefined,
}
: undefined
}
diff --git a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
index b85269904..5be783d78 100644
--- a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
+++ b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
@@ -522,18 +522,15 @@ export function UserKeyTableRow({
limitTotalUsd: editingKey.limitTotalUsd,
limitConcurrentSessions: editingKey.limitConcurrentSessions,
}}
- user={
- {
- id: user.id,
- name: user.name,
- providerGroup: user.providerGroup ?? null,
- limit5hUsd: user.limit5hUsd ?? undefined,
- limitWeeklyUsd: user.limitWeeklyUsd ?? undefined,
- limitMonthlyUsd: user.limitMonthlyUsd ?? undefined,
- limitTotalUsd: user.limitTotalUsd ?? undefined,
- limitConcurrentSessions: user.limitConcurrentSessions ?? undefined,
- } as any
- }
+ user={{
+ id: user.id,
+ providerGroup: user.providerGroup ?? null,
+ limit5hUsd: user.limit5hUsd ?? undefined,
+ limitWeeklyUsd: user.limitWeeklyUsd ?? undefined,
+ limitMonthlyUsd: user.limitMonthlyUsd ?? undefined,
+ limitTotalUsd: user.limitTotalUsd ?? undefined,
+ limitConcurrentSessions: user.limitConcurrentSessions ?? undefined,
+ }}
isAdmin={isAdmin}
onSuccess={() => {
setEditingKeyId(null);
diff --git a/src/app/[locale]/dashboard/users/users-page-client.tsx b/src/app/[locale]/dashboard/users/users-page-client.tsx
index 8ef674df9..d7997a207 100644
--- a/src/app/[locale]/dashboard/users/users-page-client.tsx
+++ b/src/app/[locale]/dashboard/users/users-page-client.tsx
@@ -673,18 +673,15 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
open={showCreateDialog}
onOpenChange={handleCreateDialogClose}
userId={selfUser.id}
- user={
- {
- id: selfUser.id,
- name: selfUser.name,
- providerGroup: selfUser.providerGroup ?? null,
- limit5hUsd: selfUser.limit5hUsd ?? undefined,
- limitWeeklyUsd: selfUser.limitWeeklyUsd ?? undefined,
- limitMonthlyUsd: selfUser.limitMonthlyUsd ?? undefined,
- limitTotalUsd: selfUser.limitTotalUsd ?? undefined,
- limitConcurrentSessions: selfUser.limitConcurrentSessions ?? undefined,
- } as any
- }
+ user={{
+ id: selfUser.id,
+ providerGroup: selfUser.providerGroup ?? null,
+ limit5hUsd: selfUser.limit5hUsd ?? undefined,
+ limitWeeklyUsd: selfUser.limitWeeklyUsd ?? undefined,
+ limitMonthlyUsd: selfUser.limitMonthlyUsd ?? undefined,
+ limitTotalUsd: selfUser.limitTotalUsd ?? undefined,
+ limitConcurrentSessions: selfUser.limitConcurrentSessions ?? undefined,
+ }}
isAdmin={false}
onSuccess={handleKeyCreated}
/>
@@ -696,18 +693,15 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
open={showAddKeyDialog}
onOpenChange={handleAddKeyDialogClose}
userId={addKeyUser.id}
- user={
- {
- id: addKeyUser.id,
- name: addKeyUser.name,
- providerGroup: addKeyUser.providerGroup ?? null,
- limit5hUsd: addKeyUser.limit5hUsd ?? undefined,
- limitWeeklyUsd: addKeyUser.limitWeeklyUsd ?? undefined,
- limitMonthlyUsd: addKeyUser.limitMonthlyUsd ?? undefined,
- limitTotalUsd: addKeyUser.limitTotalUsd ?? undefined,
- limitConcurrentSessions: addKeyUser.limitConcurrentSessions ?? undefined,
- } as any
- }
+ user={{
+ id: addKeyUser.id,
+ providerGroup: addKeyUser.providerGroup ?? null,
+ limit5hUsd: addKeyUser.limit5hUsd ?? undefined,
+ limitWeeklyUsd: addKeyUser.limitWeeklyUsd ?? undefined,
+ limitMonthlyUsd: addKeyUser.limitMonthlyUsd ?? undefined,
+ limitTotalUsd: addKeyUser.limitTotalUsd ?? undefined,
+ limitConcurrentSessions: addKeyUser.limitConcurrentSessions ?? undefined,
+ }}
isAdmin={isAdmin}
onSuccess={handleKeyCreated}
/>
diff --git a/src/types/user.ts b/src/types/user.ts
index f380b30bf..75decef2b 100644
--- a/src/types/user.ts
+++ b/src/types/user.ts
@@ -155,6 +155,20 @@ export interface UserDisplay {
allowedModels?: string[]; // 允许的AI模型(空数组=无限制)
}
+/**
+ * Key Dialog 所需的用户上下文(精简版)
+ * 用于 AddKeyDialog/EditKeyDialog 组件,只包含限额相关字段
+ */
+export interface KeyDialogUserContext {
+ id: number;
+ providerGroup?: string | null;
+ limit5hUsd?: number;
+ limitWeeklyUsd?: number;
+ limitMonthlyUsd?: number;
+ limitTotalUsd?: number | null;
+ limitConcurrentSessions?: number;
+}
+
/**
* 用户表单数据
*/