Kaynağa Gözat

feat(users): add expiration date validation and error messages

- Introduced a new function `validateExpiresAt` to validate expiration dates, ensuring they are in the future and do not exceed 10 years.
- Updated user-related actions to incorporate expiration date validation, returning appropriate error messages when validation fails.
- Enhanced user forms to handle expiration dates correctly, converting input dates to the end of the day to avoid premature expiration.
- Added new error messages in both English and Chinese for better user feedback regarding expiration date issues.

This update improves the robustness of user management by enforcing expiration date rules and enhancing user experience with clear error messaging.
NightYu 4 ay önce
ebeveyn
işleme
44f47824c8

+ 5 - 1
messages/en/errors.json

@@ -62,5 +62,9 @@
   "CREATE_USER_FAILED": "Failed to create user, please try again later",
   "UPDATE_USER_FAILED": "Failed to update user, please try again later",
   "DELETE_USER_FAILED": "Failed to delete user, please try again later",
-  "GET_USER_QUOTA_FAILED": "Failed to get user quota information"
+  "GET_USER_QUOTA_FAILED": "Failed to get user quota information",
+  "EXPIRES_AT_FIELD": "Expiration date",
+  "EXPIRES_AT_MUST_BE_FUTURE": "Expiration date must be in the future",
+  "EXPIRES_AT_TOO_FAR": "Expiration date cannot exceed 10 years",
+  "CANNOT_DISABLE_SELF": "Cannot disable your own account"
 }

+ 5 - 1
messages/zh-CN/errors.json

@@ -61,5 +61,9 @@
   "CREATE_USER_FAILED": "创建用户失败,请稍后重试",
   "UPDATE_USER_FAILED": "更新用户失败,请稍后重试",
   "DELETE_USER_FAILED": "删除用户失败,请稍后重试",
-  "GET_USER_QUOTA_FAILED": "获取用户限额使用情况失败"
+  "GET_USER_QUOTA_FAILED": "获取用户限额使用情况失败",
+  "EXPIRES_AT_FIELD": "过期时间",
+  "EXPIRES_AT_MUST_BE_FUTURE": "过期时间必须是未来时间",
+  "EXPIRES_AT_TOO_FAR": "过期时间不能超过10年",
+  "CANNOT_DISABLE_SELF": "不能禁用自己的账户"
 }

+ 135 - 6
src/actions/users.ts

@@ -21,6 +21,47 @@ import { createUser, deleteUser, findUserById, findUserList, updateUser } from "
 import type { UserDisplay } from "@/types/user";
 import type { ActionResult } from "./types";
 
+/**
+ * 验证过期时间的公共函数
+ * @param expiresAt - 过期时间
+ * @param tError - 翻译函数
+ * @returns 验证结果,如果有错误返回错误信息和错误码
+ */
+async function validateExpiresAt(
+  expiresAt: Date,
+  tError: Awaited<ReturnType<typeof getTranslations<"errors">>>,
+  options: { allowPast?: boolean } = {}
+): Promise<{ error: string; errorCode: string } | null> {
+  // 检查是否为有效日期
+  if (Number.isNaN(expiresAt.getTime())) {
+    return {
+      error: tError("INVALID_FORMAT", { field: tError("EXPIRES_AT_FIELD") }),
+      errorCode: ERROR_CODES.INVALID_FORMAT,
+    };
+  }
+
+  // 拒绝过去或当前时间(可配置允许过去时间,用于立即让用户过期)
+  const now = new Date();
+  if (!options.allowPast && expiresAt <= now) {
+    return {
+      error: tError("EXPIRES_AT_MUST_BE_FUTURE"),
+      errorCode: "EXPIRES_AT_MUST_BE_FUTURE",
+    };
+  }
+
+  // 限制最大续期时长(10年)
+  const maxExpiry = new Date(now);
+  maxExpiry.setFullYear(maxExpiry.getFullYear() + 10);
+  if (expiresAt > maxExpiry) {
+    return {
+      error: tError("EXPIRES_AT_TOO_FAR"),
+      errorCode: "EXPIRES_AT_TOO_FAR",
+    };
+  }
+
+  return null;
+}
+
 // 获取用户数据
 export async function getUsers(): Promise<UserDisplay[]> {
   try {
@@ -196,10 +237,38 @@ export async function addUser(data: {
     });
 
     if (!validationResult.success) {
+      const issue = validationResult.error.issues[0];
+      const { code, params } = await import("@/lib/utils/error-messages").then((m) =>
+        m.zodErrorToCode(issue.code, {
+          minimum: "minimum" in issue ? issue.minimum : undefined,
+          maximum: "maximum" in issue ? issue.maximum : undefined,
+          type: "expected" in issue ? issue.expected : undefined,
+          received: "received" in issue ? issue.received : undefined,
+          validation: "validation" in issue ? issue.validation : undefined,
+          path: issue.path,
+          message: "message" in issue ? issue.message : undefined,
+          params: "params" in issue ? issue.params : undefined,
+        })
+      );
+
+      // For custom errors with nested field keys, translate them
+      let translatedParams = params;
+      if (issue.code === "custom" && params?.field && typeof params.field === "string") {
+        try {
+          translatedParams = {
+            ...params,
+            field: tError(params.field as string),
+          };
+        } catch {
+          // Keep original if translation fails
+        }
+      }
+
       return {
         ok: false,
         error: formatZodError(validationResult.error),
-        errorCode: ERROR_CODES.INVALID_FORMAT,
+        errorCode: code,
+        errorParams: translatedParams,
       };
     }
 
@@ -281,10 +350,38 @@ export async function editUser(
     const validationResult = UpdateUserSchema.safeParse(data);
 
     if (!validationResult.success) {
+      const issue = validationResult.error.issues[0];
+      const { code, params } = await import("@/lib/utils/error-messages").then((m) =>
+        m.zodErrorToCode(issue.code, {
+          minimum: "minimum" in issue ? issue.minimum : undefined,
+          maximum: "maximum" in issue ? issue.maximum : undefined,
+          type: "expected" in issue ? issue.expected : undefined,
+          received: "received" in issue ? issue.received : undefined,
+          validation: "validation" in issue ? issue.validation : undefined,
+          path: issue.path,
+          message: "message" in issue ? issue.message : undefined,
+          params: "params" in issue ? issue.params : undefined,
+        })
+      );
+
+      // For custom errors with nested field keys, translate them
+      let translatedParams = params;
+      if (issue.code === "custom" && params?.field && typeof params.field === "string") {
+        try {
+          translatedParams = {
+            ...params,
+            field: tError(params.field as string),
+          };
+        } catch {
+          // Keep original if translation fails
+        }
+      }
+
       return {
         ok: false,
         error: formatZodError(validationResult.error),
-        errorCode: ERROR_CODES.INVALID_FORMAT,
+        errorCode: code,
+        errorParams: translatedParams,
       };
     }
 
@@ -310,6 +407,18 @@ export async function editUser(
       };
     }
 
+    // 如果设置了过期时间,进行验证
+    if (data.expiresAt !== undefined && data.expiresAt !== null) {
+      const validationResult = await validateExpiresAt(data.expiresAt, tError, { allowPast: true });
+      if (validationResult) {
+        return {
+          ok: false,
+          error: validationResult.error,
+          errorCode: validationResult.errorCode,
+        };
+      }
+    }
+
     // Update user with validated data
     await updateUser(userId, {
       name: validatedData.name,
@@ -459,11 +568,24 @@ export async function renewUser(
 
     // Parse and validate expiration date
     const expiresAt = new Date(data.expiresAt);
-    if (isNaN(expiresAt.getTime())) {
+
+    // 验证过期时间
+    const validationResult = await validateExpiresAt(expiresAt, tError);
+    if (validationResult) {
       return {
         ok: false,
-        error: tError("INVALID_FORMAT"),
-        errorCode: ERROR_CODES.INVALID_FORMAT,
+        error: validationResult.error,
+        errorCode: validationResult.errorCode,
+      };
+    }
+
+    // 检查用户是否存在
+    const user = await findUserById(userId);
+    if (!user) {
+      return {
+        ok: false,
+        error: tError("USER_NOT_FOUND"),
+        errorCode: ERROR_CODES.NOT_FOUND,
       };
     }
 
@@ -479,7 +601,14 @@ export async function renewUser(
       updateData.isEnabled = true;
     }
 
-    await updateUser(userId, updateData);
+    const updated = await updateUser(userId, updateData);
+    if (!updated) {
+      return {
+        ok: false,
+        error: tError("USER_NOT_FOUND"),
+        errorCode: ERROR_CODES.NOT_FOUND,
+      };
+    }
 
     revalidatePath("/dashboard");
     return { ok: true };

+ 9 - 2
src/app/[locale]/dashboard/_components/user/forms/user-form.tsx

@@ -77,6 +77,13 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
       expiresAt: user?.expiresAt ? user.expiresAt.toISOString().split("T")[0] : "",
     },
     onSubmit: async (data) => {
+      // 将纯日期转换为当天结束时间(本地时区 23:59:59.999),避免默认 UTC 零点导致提前过期
+      const toEndOfDay = (dateStr: string) => {
+        const d = new Date(dateStr);
+        d.setHours(23, 59, 59, 999);
+        return d;
+      };
+
       startTransition(async () => {
         try {
           let res;
@@ -94,7 +101,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
               limitTotalUsd: data.limitTotalUsd,
               limitConcurrentSessions: data.limitConcurrentSessions,
               isEnabled: data.isEnabled,
-              expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
+              expiresAt: data.expiresAt ? toEndOfDay(data.expiresAt) : null,
             });
           } else {
             res = await addUser({
@@ -110,7 +117,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
               limitTotalUsd: data.limitTotalUsd,
               limitConcurrentSessions: data.limitConcurrentSessions,
               isEnabled: data.isEnabled,
-              expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
+              expiresAt: data.expiresAt ? toEndOfDay(data.expiresAt) : null,
             });
           }
 

+ 13 - 4
src/lib/validation/schemas.ts

@@ -127,10 +127,19 @@ export const UpdateUserSchema = z.object({
     .optional(),
   // User status and expiry management
   isEnabled: z.boolean().optional(),
-  expiresAt: z
-    .string()
-    .optional()
-    .transform((val) => (!val || val === "" ? undefined : val)),
+  expiresAt: z.preprocess(
+    (val) => {
+      // 兼容服务端传入的 Date 对象,统一转为字符串再走后续校验
+      if (val instanceof Date) return val.toISOString();
+      // null/undefined/空字符串 -> 视为未设置
+      if (val === null || val === undefined || val === "") return undefined;
+      return val;
+    },
+    z
+      .string()
+      .optional()
+      .transform((val) => (!val || val === "" ? undefined : val))
+  ),
 });
 
 /**