diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 9732fde1a..a4e141600 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -130,7 +130,8 @@ "view": "View" }, "error": { - "loadFailed": "Load Failed" + "loadFailed": "Load Failed", + "loadKeysFailed": "Failed to load keys" }, "details": { "title": "Request Details", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index ddd6a7545..bf5fddc11 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -130,7 +130,8 @@ "view": "表示" }, "error": { - "loadFailed": "読み込み失敗" + "loadFailed": "読み込み失敗", + "loadKeysFailed": "キーリストの読み込みに失敗しました" }, "details": { "title": "リクエスト詳細", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 0db08c676..ca7fdad9a 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -130,7 +130,8 @@ "view": "Просмотр" }, "error": { - "loadFailed": "Ошибка загрузки" + "loadFailed": "Ошибка загрузки", + "loadKeysFailed": "Не удалось загрузить список ключей" }, "details": { "title": "Детали запроса", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index bc9131bcf..27e62c789 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -130,7 +130,8 @@ "view": "查看" }, "error": { - "loadFailed": "加载失败" + "loadFailed": "加载失败", + "loadKeysFailed": "加载密钥列表失败" }, "details": { "title": "请求详情", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index e2b486d57..cd8f16209 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -130,7 +130,8 @@ "view": "檢視" }, "error": { - "loadFailed": "載入失敗" + "loadFailed": "載入失敗", + "loadKeysFailed": "載入密鑰列表失敗" }, "details": { "title": "請求詳情", 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 42db1cf16..229b9e603 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 @@ -37,11 +37,13 @@ export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) { // Load provider group suggestions useEffect(() => { - if (user?.id) { - getAvailableProviderGroups(user.id).then(setProviderGroupSuggestions); - } else { - getAvailableProviderGroups().then(setProviderGroupSuggestions); - } + const loadGroups = user?.id + ? getAvailableProviderGroups(user.id) + : getAvailableProviderGroups(); + + loadGroups.then(setProviderGroupSuggestions).catch((err) => { + console.error("[AddKeyForm] Failed to load provider groups:", err); + }); }, [user?.id]); const form = useZodForm({ 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 6f4b34dcf..ac5480748 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 @@ -52,11 +52,13 @@ export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) { // Load provider group suggestions useEffect(() => { - if (user?.id) { - getAvailableProviderGroups(user.id).then(setProviderGroupSuggestions); - } else { - getAvailableProviderGroups().then(setProviderGroupSuggestions); - } + const loadGroups = user?.id + ? getAvailableProviderGroups(user.id) + : getAvailableProviderGroups(); + + loadGroups.then(setProviderGroupSuggestions).catch((err) => { + console.error("[EditKeyForm] Failed to load provider groups:", err); + }); }, [user?.id]); const formatExpiresAt = (expiresAt: string) => { diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx index 6ac03076c..017d113d2 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx @@ -76,7 +76,11 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { // 加载供应商分组建议 useEffect(() => { - getAvailableProviderGroups().then(setProviderGroupSuggestions); + getAvailableProviderGroups() + .then(setProviderGroupSuggestions) + .catch((err) => { + console.error("[UserForm] Failed to load provider groups:", err); + }); }, []); const form = useZodForm({ diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx index 18e51cada..138f9cbad 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx @@ -112,9 +112,14 @@ export function UsageLogsFilters({ // 加载该用户的 keys if (newUserId) { - const keysResult = await getKeys(newUserId); - if (keysResult.ok && keysResult.data) { - setKeys(keysResult.data); + try { + const keysResult = await getKeys(newUserId); + if (keysResult.ok && keysResult.data) { + setKeys(keysResult.data); + } + } catch (error) { + console.error("Failed to load keys:", error); + toast.error(t("logs.error.loadKeysFailed")); } } else { setKeys([]); diff --git a/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx b/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx index 152eb48cb..c879523ed 100644 --- a/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx @@ -146,18 +146,26 @@ export function ApiTestButton({ const currentProviderType = apiFormatToProviderType[apiFormat]; if (!currentProviderType) return; - getProviderTestPresets(currentProviderType).then((result) => { - if (result.ok && result.data) { - setPresets(result.data); - // Auto-select first preset if available - if (result.data.length > 0 && !selectedPreset) { - setSelectedPreset(result.data[0].id); - setSuccessContains(result.data[0].defaultSuccessContains); + getProviderTestPresets(currentProviderType) + .then((result) => { + if (result.ok && result.data) { + setPresets(result.data); + // Auto-select first preset if available + if (result.data.length > 0 && !selectedPreset) { + setSelectedPreset(result.data[0].id); + setSuccessContains(result.data[0].defaultSuccessContains); + } + } else { + if (!result.ok) { + console.error("[ApiTestButton] Failed to load presets:", result.error); + } + setPresets([]); } - } else { + }) + .catch((err) => { + console.error("[ApiTestButton] Failed to load presets:", err); setPresets([]); - } - }); + }); }, [apiFormat, apiFormatToProviderType, selectedPreset]); useEffect(() => { diff --git a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx index 5125584e3..ff100eb7c 100644 --- a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx @@ -152,12 +152,20 @@ export function ProviderRichListItem({ // 处理查看密钥 const handleShowKey = async () => { setShowKeyDialog(true); - const result = await getUnmaskedProviderKey(provider.id); - if (result.ok) { - setUnmaskedKey(result.data.key); - } else { + try { + const result = await getUnmaskedProviderKey(provider.id); + if (result.ok) { + setUnmaskedKey(result.data.key); + } else { + toast.error(tList("getKeyFailed"), { + description: result.error || tList("unknownError"), + }); + setShowKeyDialog(false); + } + } catch (error) { + console.error("Failed to get provider key:", error); toast.error(tList("getKeyFailed"), { - description: result.error || tList("unknownError"), + description: tList("unknownError"), }); setShowKeyDialog(false); } diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index d0ff39014..b2aaaf137 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -1,10 +1,18 @@ "use client"; +import { isNetworkError } from "@/lib/utils/error-detection"; + /** - * 全局错误边界组件 + * Global error boundary component + * + * Must be a Client Component with html and body tags + * Displayed when root layout throws an error * - * 必须是 Client Component,且包含 html 和 body 标签 - * 当 root layout 抛出错误时显示 + * Note: Most errors should be caught by component-level error boundaries + * or try-catch in event handlers. This is the last resort fallback. + * + * Security: Never display raw error.message to users as it may contain + * sensitive information (database strings, file paths, internal APIs, etc.) */ export default function GlobalError({ error, @@ -13,6 +21,13 @@ export default function GlobalError({ error: Error & { digest?: string }; reset: () => void; }) { + // Use shared network error detection + const isNetwork = isNetworkError(error); + + const handleGoHome = () => { + window.location.href = "/"; + }; + return ( @@ -26,33 +41,77 @@ export default function GlobalError({ fontFamily: "system-ui, sans-serif", backgroundColor: "#f8f9fa", padding: "20px", + textAlign: "center", }} > -

- Something went wrong! +

+ {isNetwork ? "Network Connection Error" : "Something went wrong!"}

-

- {error.message || "An unexpected error occurred"} -

+ + {isNetwork ? ( +
+

Unable to connect to the server. Please check:

+ +
+ ) : ( +

+ An unexpected error occurred. Please try again later. +

+ )} + {error.digest && (

Error ID: {error.digest}

)} - + +
+ + +
diff --git a/src/lib/hooks/use-server-action.ts b/src/lib/hooks/use-server-action.ts new file mode 100644 index 000000000..9c5c21e98 --- /dev/null +++ b/src/lib/hooks/use-server-action.ts @@ -0,0 +1,197 @@ +"use client"; + +import { useCallback, useState, useTransition } from "react"; +import { toast } from "sonner"; +import type { ActionResult } from "@/actions/types"; +import { isNetworkError } from "@/lib/utils/error-detection"; + +/** + * Server Action 执行选项 + */ +export interface ExecuteOptions { + /** 成功时显示的消息 */ + successMessage?: string; + /** 失败时显示的消息(优先于 Action 返回的 error) */ + errorMessage?: string; + /** 网络错误时显示的消息 */ + networkErrorMessage?: string; + /** 成功回调 */ + onSuccess?: (data: T) => void; + /** 失败回调(包括 Action 返回 ok:false 和网络错误) */ + onError?: (error: string) => void; + /** 是否显示 toast(默认 true) */ + showToast?: boolean; +} + +/** + * Server Action 执行结果 + */ +export type ExecuteResult = { ok: true; data: T } | { ok: false; error: string }; + +// Default fallback messages (caller should provide i18n messages) +const DEFAULT_NETWORK_ERROR = "Network connection failed"; +const DEFAULT_ERROR = "Operation failed"; + +/** + * 统一的 Server Action 执行 Hook + * + * 功能: + * - 自动管理 loading 状态 + * - 捕获网络错误(Failed to fetch)并转为友好提示 + * - 统一 toast 通知 + * - 安全:不会将原始错误消息暴露给用户 + * + * @example + * ```tsx + * const { execute, isPending } = useServerAction(); + * + * const handleSubmit = async (data: FormData) => { + * const result = await execute( + * () => createUser(data), + * { + * successMessage: t("createSuccess"), + * errorMessage: t("createFailed"), + * onSuccess: (user) => router.refresh(), + * } + * ); + * + * if (result.ok) { + * // 额外的成功处理 + * } + * }; + * ``` + */ +export function useServerAction() { + const [isPending, startTransition] = useTransition(); + const [isExecuting, setIsExecuting] = useState(false); + + const execute = useCallback( + async ( + action: () => Promise>, + options: ExecuteOptions = {} + ): Promise> => { + const { + successMessage, + errorMessage, + networkErrorMessage, + onSuccess, + onError, + showToast = true, + } = options; + + return new Promise((resolve) => { + startTransition(async () => { + setIsExecuting(true); + + try { + const result = await action(); + + if (!result.ok) { + // Action 返回失败 + const message = errorMessage || result.error; + + if (showToast) { + toast.error(message); + } + + onError?.(message); + resolve({ ok: false, error: message }); + return; + } + + // Action 成功 + if (showToast && successMessage) { + toast.success(successMessage); + } + + onSuccess?.(result.data as T); + resolve({ ok: true, data: result.data as T }); + } catch (error) { + // 捕获网络错误或其他异常 + // Security: Never expose raw error.message to users + const message = isNetworkError(error) + ? networkErrorMessage || DEFAULT_NETWORK_ERROR + : errorMessage || DEFAULT_ERROR; + + console.error("[useServerAction] Error:", error); + + if (showToast) { + toast.error(message); + } + + onError?.(message); + resolve({ ok: false, error: message }); + } finally { + setIsExecuting(false); + } + }); + }); + }, + [] + ); + + return { + execute, + /** 是否正在执行(包括 transition pending 和实际执行中) */ + isPending: isPending || isExecuting, + }; +} + +/** + * 简单的错误处理包装器(不使用 Hook) + * + * 适用于不需要 loading 状态的场景 + * + * @example + * ```tsx + * const handleClick = async () => { + * const result = await withErrorHandling( + * () => deleteItem(id), + * { errorMessage: "Failed to delete" } + * ); + * + * if (result.ok) { + * router.refresh(); + * } + * }; + * ``` + */ +export async function withErrorHandling( + action: () => Promise>, + options: Omit, "onSuccess" | "onError"> = {} +): Promise> { + const { successMessage, errorMessage, networkErrorMessage, showToast = true } = options; + + try { + const result = await action(); + + if (!result.ok) { + const message = errorMessage || result.error; + + if (showToast) { + toast.error(message); + } + + return { ok: false, error: message }; + } + + if (showToast && successMessage) { + toast.success(successMessage); + } + + return { ok: true, data: result.data as T }; + } catch (error) { + // Security: Never expose raw error.message to users + const message = isNetworkError(error) + ? networkErrorMessage || DEFAULT_NETWORK_ERROR + : errorMessage || DEFAULT_ERROR; + + console.error("[withErrorHandling] Error:", error); + + if (showToast) { + toast.error(message); + } + + return { ok: false, error: message }; + } +} diff --git a/src/lib/utils/error-detection.ts b/src/lib/utils/error-detection.ts new file mode 100644 index 000000000..d81990059 --- /dev/null +++ b/src/lib/utils/error-detection.ts @@ -0,0 +1,47 @@ +/** + * Detect if an error is network-related + * + * @param error - The error to check + * @returns true if the error appears to be network-related + */ +export function isNetworkError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + + const message = error.message.toLowerCase(); + return ( + message.includes("fetch") || + message.includes("network") || + message.includes("failed to fetch") || + message.includes("networkerror") || + message.includes("econnrefused") || + message.includes("etimedout") || + message.includes("abort") || + message.includes("und_err_connect_timeout") + ); +} + +/** + * Get a safe error message that won't leak sensitive information + * + * @param _error - The error (unused, kept for API consistency) + * @param fallbackMessage - Default message if error message is unavailable + * @returns A safe error message suitable for display to users + */ +export function getSafeErrorMessage(_error: unknown, fallbackMessage = "Operation failed"): string { + // Never expose raw error messages to users - they may contain sensitive info + // Always return the fallback message for user-facing contexts + return fallbackMessage; +} + +/** + * Get the raw error message for logging purposes only + * + * @param error - The error to extract message from + * @returns The error message string + */ +export function getErrorMessageForLogging(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +}