From 12be9975c0bf01fa175a2bb3360607767c8ba5fb Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 9 Sep 2026 14:49:25 +0800 Subject: [PATCH] fix(web): unify server error notifications Preserve backend details and error causes, and report each failed operation once across request, Query/Mutation, and page handling layers. Independent operations with the same error message still receive separate notifications. Migrate direct requests and query consumers, preserve inline and silent error handling, and reject failed settings writes before success callbacks run. Retain safe authentication messages, refresh behavior, and single-use proof replay protection. Validation: independently checked the staged snapshot with 117 frontend test files (1105 tests), typecheck, scoped lint, and protected-header formatting. Security references: OWASP ASVS 5.0.0, Authentication Cheat Sheet, and Session Management Cheat Sheet: https://owasp.org/www-project-application-security-verification-standard/ https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html --- web/AGENTS.md | 23 +- .../layout/components/chat-presets-item.tsx | 3 +- web/src/components/sign-out-dialog.tsx | 7 +- web/src/features/about/index.tsx | 3 +- .../components/forgot-password-form.tsx | 11 +- .../auth/hooks/use-email-verification.ts | 14 +- .../features/auth/hooks/use-oauth-login.ts | 39 +- .../features/auth/otp/components/otp-form.tsx | 3 +- .../auth/reset-password-confirm/index.tsx | 25 +- .../sign-in/components/user-auth-form.tsx | 31 +- .../auth/sign-up/components/sign-up-form.tsx | 20 +- web/src/features/channels/api.ts | 3 +- .../components/channel-type-badge.tsx | 3 +- .../channels/components/channels-columns.tsx | 22 +- .../channels/components/channels-table.tsx | 87 ++-- .../dialogs/balance-query-dialog.tsx | 14 +- .../dialogs/channel-test-dialog.tsx | 27 +- .../components/dialogs/codex-usage-dialog.tsx | 8 +- .../components/dialogs/edit-tag-dialog.tsx | 41 +- .../dialogs/fetch-models-dialog.tsx | 13 +- .../dialogs/multi-key-manage-dialog.tsx | 33 +- .../dialogs/ollama-models-dialog.tsx | 21 +- .../dialogs/tag-batch-edit-dialog.tsx | 167 ++++--- .../drawers/channel-mutate-drawer.tsx | 26 +- .../hooks/use-channel-key-disclosure.ts | 6 +- .../channels/hooks/use-channel-mutate-form.ts | 33 +- .../hooks/use-channel-upstream-updates.ts | 57 +-- web/src/features/channels/index.tsx | 3 +- .../features/channels/lib/channel-actions.ts | 119 ++--- .../chat/hooks/use-active-chat-key.ts | 11 +- .../dashboard/components/flow/flow-charts.tsx | 4 +- .../models/performance-overview.tsx | 6 +- .../overview/overview-dashboard.tsx | 10 +- .../overview/performance-health-panel.tsx | 6 +- .../components/overview/summary-cards.tsx | 13 +- .../components/users/user-charts.tsx | 4 +- .../features/dashboard/lib/flow-selection.ts | 3 +- .../home/hooks/use-home-page-content.ts | 6 +- .../keys/components/api-keys-columns.tsx | 3 +- .../components/api-keys-delete-dialog.tsx | 7 +- .../api-keys-multi-delete-dialog.tsx | 9 +- .../components/api-keys-mutate-drawer.tsx | 19 +- .../keys/components/api-keys-provider.tsx | 16 +- .../keys/components/api-keys-table.tsx | 17 +- .../components/data-table-row-actions.tsx | 7 +- .../components/dialogs/cc-switch-dialog.tsx | 7 +- .../__tests__/save-errors.test.tsx | 151 ++++++ web/src/features/model-pricing/api.ts | 20 +- .../models/components/deployments-table.tsx | 26 +- .../dialogs/create-deployment-drawer.tsx | 96 ++-- .../dialogs/extend-deployment-dialog.tsx | 47 +- .../dialogs/missing-models-dialog.tsx | 3 +- .../dialogs/model-delete-dialog.tsx | 3 +- .../prefill-group-management-dialog.tsx | 8 +- .../dialogs/rename-deployment-dialog.tsx | 11 +- .../components/dialogs/sync-wizard-dialog.tsx | 57 ++- .../dialogs/update-config-dialog.tsx | 11 +- .../dialogs/vendor-mutate-dialog.tsx | 5 +- .../dialogs/vendor-operation-dialog.tsx | 3 +- .../dialogs/vendors-management-dialog.tsx | 3 +- .../dialogs/view-details-dialog.tsx | 17 +- .../components/dialogs/view-logs-dialog.tsx | 50 +- .../drawers/model-mutate-drawer.tsx | 20 +- .../drawers/prefill-group-form-drawer.tsx | 71 ++- .../models/components/models-table.tsx | 32 +- .../components/vendor-linked-models.tsx | 3 +- .../models/components/vendors-table.tsx | 3 +- web/src/features/models/index.tsx | 4 +- web/src/features/models/lib/model-actions.ts | 27 +- web/src/features/models/lib/vendor-actions.ts | 10 +- web/src/features/models/vendor-api.ts | 10 +- web/src/features/playground/api.ts | 3 + .../playground/hooks/use-chat-handler.ts | 5 +- .../hooks/use-playground-options.ts | 15 +- .../pricing/components/model-card-grid.tsx | 3 +- .../components/model-details-performance.tsx | 10 +- .../pricing/components/model-details.tsx | 4 +- .../pricing/hooks/use-pricing-data.ts | 3 +- .../components/checkin-calendar-card.tsx | 10 +- .../components/language-preferences-card.tsx | 25 +- .../components/sidebar-modules-card.tsx | 7 +- .../components/tabs/notification-tab.tsx | 7 +- web/src/features/profile/hooks/use-profile.ts | 20 +- .../features/rankings/hooks/use-rankings.ts | 4 +- .../components/__tests__/bulk-delete.test.tsx | 4 +- .../redemptions-mutate-drawer.test.tsx | 32 +- .../components/data-table-bulk-actions.tsx | 7 +- .../components/data-table-row-actions.tsx | 21 +- .../components/redemptions-delete-dialog.tsx | 5 + .../components/redemptions-mutate-drawer.tsx | 8 +- .../redemptions-primary-buttons.tsx | 3 +- .../components/redemptions-table.tsx | 17 +- .../__tests__/privacy-card.test.tsx | 2 +- .../dialogs/telegram-bind-dialog.tsx | 9 +- .../components/login-sessions-card.tsx | 15 +- .../security/components/passkey-card.tsx | 5 +- .../security/components/privacy-card.tsx | 7 +- .../security/hooks/use-access-token.ts | 3 +- .../security/hooks/use-account-security.ts | 3 +- .../security/hooks/use-two-fa-setup.ts | 7 +- web/src/features/setup/setup-wizard.tsx | 15 +- .../dialogs/reset-subscriptions-dialog.tsx | 7 +- .../dialogs/subscription-purchase-dialog.tsx | 74 +-- .../dialogs/toggle-status-dialog.tsx | 7 +- .../dialogs/user-subscriptions-dialog.tsx | 52 +- .../subscriptions-mutate-drawer.tsx | 115 +++-- .../components/subscriptions-table.tsx | 3 +- .../components/system-instances-panel.tsx | 12 +- .../components/system-tasks-panel.tsx | 21 +- .../hooks/use-custom-oauth-mutations.ts | 20 +- .../hooks/use-custom-oauth-providers.ts | 4 +- .../system-settings/auth/oauth-section.tsx | 6 +- .../content/announcements-section.tsx | 9 +- .../content/api-info-section.tsx | 9 +- .../system-settings/content/faq-section.tsx | 9 +- .../content/uptime-kuma-section.tsx | 9 +- .../channel-affinity/cache-stats-dialog.tsx | 48 +- .../general/channel-affinity/index.tsx | 47 +- .../hooks/use-system-options.ts | 4 +- .../hooks/use-update-option.ts | 10 +- .../integrations/payment-settings-section.tsx | 16 +- .../waffo-pancake-settings-section.tsx | 52 +- .../maintenance/log-settings-section.tsx | 22 +- .../maintenance/performance-section.tsx | 29 +- .../maintenance/update-checker-section.tsx | 3 +- .../models/model-ratio-form.tsx | 18 +- .../models/ratio-settings-card.tsx | 2 +- .../models/upstream-ratio-sync.tsx | 25 +- web/src/features/task-plugins/api.ts | 3 +- .../components/marketplace-install-dialog.tsx | 1 + .../components/marketplace-panel.tsx | 1 + .../components/marketplace-plugin-card.tsx | 1 + .../components/marketplace-sources-dialog.tsx | 3 +- .../components/plugin-detail-sheet.tsx | 39 +- .../task-plugins/components/plugin-icon.tsx | 1 + .../components/plugin-sandbox.tsx | 33 +- .../components/plugin-url-import-field.tsx | 1 + .../task-plugins/components/plugins-table.tsx | 7 +- web/src/features/task-plugins/index.tsx | 7 +- web/src/features/usage-logs/audit/api.ts | 5 +- .../audit/components/audit-log-viewer.tsx | 4 +- .../components/common-logs-filter-bar.tsx | 5 +- .../components/common-logs-stats.tsx | 5 +- .../components/dialogs/user-info-dialog.tsx | 38 +- .../usage-logs/components/task-artifacts.tsx | 4 +- .../components/usage-logs-table.tsx | 5 +- web/src/features/users/api.ts | 2 + .../components/data-table-row-actions.tsx | 21 +- .../dialogs/user-binding-dialog.tsx | 13 +- .../users/components/user-quota-dialog.tsx | 15 +- .../users/components/users-delete-dialog.tsx | 7 +- .../users/components/users-mutate-drawer.tsx | 71 +-- .../features/users/components/users-table.tsx | 8 +- .../components/subscription-plans-card.tsx | 19 +- .../features/wallet/hooks/use-affiliate.ts | 11 +- .../wallet/hooks/use-billing-history.ts | 16 +- .../wallet/hooks/use-creem-payment.ts | 6 +- web/src/features/wallet/hooks/use-payment.ts | 8 +- .../features/wallet/hooks/use-redemption.ts | 5 +- .../features/wallet/hooks/use-topup-info.ts | 8 +- .../wallet/hooks/use-waffo-pancake-payment.ts | 13 +- .../wallet/hooks/use-waffo-payment.ts | 15 +- web/src/hooks/use-notifications.ts | 14 +- .../server-error-notifications.test.ts | 444 ++++++++++++++++++ web/src/lib/api.ts | 3 +- web/src/lib/handle-server-error.ts | 52 +- web/src/lib/http-client.ts | 50 +- web/src/lib/query-client.ts | 65 +++ web/src/lib/secure-verification.ts | 6 +- web/src/lib/server-error-message.ts | 133 +++++- web/src/main.tsx | 54 +-- web/src/routes/(auth)/oauth.tsx | 19 +- web/src/routes/_authenticated/chat2link.tsx | 3 +- web/src/routes/oauth/$provider.tsx | 22 +- 174 files changed, 2471 insertions(+), 1408 deletions(-) create mode 100644 web/src/features/model-pricing/__tests__/save-errors.test.tsx create mode 100644 web/src/lib/__tests__/server-error-notifications.test.ts create mode 100644 web/src/lib/query-client.ts diff --git a/web/AGENTS.md b/web/AGENTS.md index cee313ba1..a2879b26a 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -92,14 +92,14 @@ 以下为常用检索入口,不能把本表当作完整组件清单: -| 场景 | 优先检查的项目入口 | -| --- | --- | -| 通用弹窗布局 | `@/components/dialog` | -| 删除、危险操作及普通确认 | `@/components/confirm-dialog` | -| 复制按钮与剪贴板交互 | `@/components/copy-button`、`@/hooks/use-copy-to-clipboard` | -| 空状态、加载状态、错误状态 | `@/components/empty-state`、`@/components/loading-state`、`@/components/error-state` | -| 表格、分页、工具栏及列表布局 | `@/components/data-table`,先读该目录的 `README.md` 和公开导出 | -| 按钮、输入、选择、提示等基础控件 | `@/components/ui/`,以 `components.json` 和本地实现为准 | +| 场景 | 优先检查的项目入口 | +| -------------------------------- | ------------------------------------------------------------------------------------ | +| 通用弹窗布局 | `@/components/dialog` | +| 删除、危险操作及普通确认 | `@/components/confirm-dialog` | +| 复制按钮与剪贴板交互 | `@/components/copy-button`、`@/hooks/use-copy-to-clipboard` | +| 空状态、加载状态、错误状态 | `@/components/empty-state`、`@/components/loading-state`、`@/components/error-state` | +| 表格、分页、工具栏及列表布局 | `@/components/data-table`,先读该目录的 `README.md` 和公开导出 | +| 按钮、输入、选择、提示等基础控件 | `@/components/ui/`,以 `components.json` 和本地实现为准 | - 使用函数式组件与 Hooks,单一职责;组件 props 须有明确类型(接口或类型别名)。 - **Props 使用**:组件 props 非必要不要解构,直接使用 `props.xxx` 访问属性,保持代码清晰(详见 [3.2 代码风格与类型](#32-代码风格与类型))。 @@ -121,7 +121,7 @@ ### 3.6 API 请求 - **React Query**:数据获取用 `useQuery`,变更用 `useMutation`;为每个查询配置唯一 `queryKey`(建议数组形式、层级一致);在 `onSuccess` 中对相关 query 做 `invalidateQueries`,可配合乐观更新。服务端错误统一通过 `handleServerError` 处理(详见 [3.9 错误处理](#39-错误处理))。 -- **Axios**:使用项目统一的 `api` 实例(含 `baseURL`、`headers`、`withCredentials: true`);GET 默认请求去重,特殊请求可通过配置关闭;认证与通用错误在拦截器中处理。 +- **Axios**:使用项目统一的 `api` 实例(含 `baseURL`、`headers`、`withCredentials: true`);GET 默认请求去重,特殊请求可通过配置关闭;认证刷新与请求重试在拦截器中处理;普通错误提示由 Query/Mutation 的最终失败回调或直接调用方负责。 ### 3.7 表单 @@ -136,7 +136,10 @@ ### 3.9 错误处理 -- **服务端错误**:统一使用 `handleServerError`,在 React Query 全局配置与拦截器中接入;按 HTTP 状态码给出合适提示,文案使用 i18n。 +- **服务端错误**:统一使用 `handleServerError(error, fallbackMessage?)`,优先保留服务端具体原因。相同失败沿 `cause` 传播时只提示一次;不同操作即使文案相同也分别提示。禁止把完整 Axios 错误对象(可能包含凭据)直接写入控制台。 +- **提示归属**:普通请求拦截器不弹 Toast。使用共享 `createAppQueryClient` 在 Query/Mutation 最终失败时提示;Mutation 自定义 `onError` 负责自己的提示或表单错误。自动重试期间和主动取消时不报错。`meta.errorToast: false` 关闭自动提示,页面仍可主动调用错误处理函数。 +- **业务失败**:保留原始 API 响应契约;Query 使用 `requireServerSuccess` 检查 `success: false`,或在业务层用 `createServerError(response, fallbackMessage?)` 抛错并保留来源。直接请求须明确处理失败响应和 Promise 拒绝;需要展示失败状态的 Mutation 不得把失败结果当作保存成功。 +- **认证兼容**:保留稳定错误码映射及 `AuthOperationError` 的安全消息,不解开其来源来展示被屏蔽的细节。维护既有刷新、跳转和一次性授权不重放约束。 - **展示**:使用 `toast.error` 等统一方式;路由级错误由 `errorComponent` 承接,提供友好错误页并记录便于排查的信息。 - **表单**:校验与服务端错误映射到字段后,在字段下方展示;使用 `form.setError` 等与表单库一致的方式。 diff --git a/web/src/components/layout/components/chat-presets-item.tsx b/web/src/components/layout/components/chat-presets-item.tsx index f1e206936..7c3c917e0 100644 --- a/web/src/components/layout/components/chat-presets-item.tsx +++ b/web/src/components/layout/components/chat-presets-item.tsx @@ -48,6 +48,7 @@ import { resolveChatUrl, type ChatPreset, } from '@/features/chat/lib/chat-links' +import { handleServerError } from '@/lib/handle-server-error' import { normalizeHref } from '../lib/url-utils' import type { NavChatPresets } from '../types' @@ -194,7 +195,7 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) { : t( 'Unable to prepare chat link. Please ensure you have an enabled API key.' ) - toast.error(message) + handleServerError(error, message) return } finally { loadingPresetIdRef.current = null diff --git a/web/src/components/sign-out-dialog.tsx b/web/src/components/sign-out-dialog.tsx index 9479c9c00..cece88c20 100644 --- a/web/src/components/sign-out-dialog.tsx +++ b/web/src/components/sign-out-dialog.tsx @@ -25,6 +25,7 @@ import { toast } from 'sonner' import { ConfirmDialog } from '@/components/confirm-dialog' import { logout } from '@/features/auth/api' import { clearAuthenticatedClientState } from '@/lib/auth-session' +import { handleServerError } from '@/lib/handle-server-error' interface SignOutDialogProps { open: boolean @@ -42,7 +43,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) { try { const response = await logout() if (!response.success) { - toast.error(response.message || t('Failed to sign out session')) + handleServerError(response, t('Failed to sign out session')) return } @@ -50,9 +51,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) { toast.success(t('Signed out')) void navigate({ to: '/sign-in', replace: true }) } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to sign out session') - ) + handleServerError(error, t('Failed to sign out session')) } finally { setIsSigningOut(false) } diff --git a/web/src/features/about/index.tsx b/web/src/features/about/index.tsx index 228343050..e6ca22d47 100644 --- a/web/src/features/about/index.tsx +++ b/web/src/features/about/index.tsx @@ -24,6 +24,7 @@ import { PublicLayout } from '@/components/layout' import { RichContent } from '@/components/rich-content' import { Skeleton } from '@/components/ui/skeleton' import { isHttpUrl, isLikelyHtml } from '@/lib/content-format' +import { requireServerSuccess } from '@/lib/server-error-message' import { getAboutContent } from './api' @@ -116,7 +117,7 @@ export function About() { const { t } = useTranslation() const { data, isLoading } = useQuery({ queryKey: ['about-content'], - queryFn: getAboutContent, + queryFn: async () => requireServerSuccess(await getAboutContent()), }) const rawContent = data?.data?.trim() ?? '' diff --git a/web/src/features/auth/forgot-password/components/forgot-password-form.tsx b/web/src/features/auth/forgot-password/components/forgot-password-form.tsx index 9ba93321d..4d5e849ed 100644 --- a/web/src/features/auth/forgot-password/components/forgot-password-form.tsx +++ b/web/src/features/auth/forgot-password/components/forgot-password-form.tsx @@ -42,6 +42,9 @@ import { } from '@/features/auth/constants' import { useTurnstile } from '@/features/auth/hooks/use-turnstile' import { useCountdown } from '@/hooks/use-countdown' +import { handleServerError } from '@/lib/handle-server-error' +import { AuthOperationError } from '@/lib/secure-verification' +import { createServerError } from '@/lib/server-error-message' import { cn } from '@/lib/utils' export function ForgotPasswordForm({ @@ -81,10 +84,14 @@ export function ForgotPasswordForm({ startCountdown() toast.success(t('Reset email sent, please check your inbox')) } else { - toast.error(res?.message || t('Failed to send reset email')) + handleServerError( + createServerError(res, t('Failed to send reset email')) + ) } } catch (_error) { - // Errors are handled by global interceptor + handleServerError( + AuthOperationError.from(_error, t('Failed to send reset email')) + ) } finally { setIsLoading(false) } diff --git a/web/src/features/auth/hooks/use-email-verification.ts b/web/src/features/auth/hooks/use-email-verification.ts index 7fe79b61a..1a20179a7 100644 --- a/web/src/features/auth/hooks/use-email-verification.ts +++ b/web/src/features/auth/hooks/use-email-verification.ts @@ -21,6 +21,9 @@ import { useState } from 'react' import { toast } from 'sonner' import { useCountdown } from '@/hooks/use-countdown' +import { handleServerError } from '@/lib/handle-server-error' +import { AuthOperationError } from '@/lib/secure-verification' +import { createServerError } from '@/lib/server-error-message' import { sendEmailVerification } from '../api' import { EMAIL_VERIFICATION_COUNTDOWN } from '../constants' @@ -63,12 +66,17 @@ export function useEmailVerification(options?: UseEmailVerificationOptions) { toast.success(i18next.t('Verification email sent')) return true } - toast.error( - res?.message || i18next.t('Failed to send verification email') + handleServerError( + createServerError(res, i18next.t('Failed to send verification email')) ) return false } catch (_error) { - // Errors are handled by global interceptor + handleServerError( + AuthOperationError.from( + _error, + i18next.t('Failed to send verification email') + ) + ) return false } finally { setIsSending(false) diff --git a/web/src/features/auth/hooks/use-oauth-login.ts b/web/src/features/auth/hooks/use-oauth-login.ts index 68b219302..f2c129366 100644 --- a/web/src/features/auth/hooks/use-oauth-login.ts +++ b/web/src/features/auth/hooks/use-oauth-login.ts @@ -21,7 +21,9 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { clearAuthentication } from '@/lib/api' +import { handleServerError } from '@/lib/handle-server-error' import { AuthOperationError } from '@/lib/secure-verification' +import { createServerError } from '@/lib/server-error-message' import { createOAuthAuthorization, createOAuthFlow, logout } from '../api' import { @@ -59,7 +61,7 @@ export function useOAuthLogin( const resetSession = async () => { const response = await logout() if (!response.success) { - throw new Error(response.message || t('Failed to sign out session')) + throw createServerError(response, t('Failed to sign out session')) } clearAuthentication() } @@ -91,8 +93,10 @@ export function useOAuthLogin( const url = buildGitHubOAuthUrl(status.github_client_id, state) window.open(url, '_self') - } catch { - toast.error(t('Failed to start GitHub login')) + } catch (error) { + handleServerError( + AuthOperationError.from(error, t('Failed to start GitHub login')) + ) if (githubTimeoutRef.current) { clearTimeout(githubTimeoutRef.current) } @@ -113,8 +117,10 @@ export function useOAuthLogin( const url = buildDiscordOAuthUrl(status.discord_client_id, state) window.open(url, '_self') - } catch { - toast.error(t('Failed to start Discord login')) + } catch (error) { + handleServerError( + AuthOperationError.from(error, t('Failed to start Discord login')) + ) } finally { setIsLoading(false) } @@ -135,8 +141,10 @@ export function useOAuthLogin( state ) window.open(url, '_self') - } catch { - toast.error(t('Failed to start OIDC login')) + } catch (error) { + handleServerError( + AuthOperationError.from(error, t('Failed to start OIDC login')) + ) } finally { setIsLoading(false) } @@ -153,8 +161,10 @@ export function useOAuthLogin( const url = buildLinuxDOOAuthUrl(status.linuxdo_client_id, state) window.open(url, '_self') - } catch { - toast.error(t('Failed to start LinuxDO login')) + } catch (error) { + handleServerError( + AuthOperationError.from(error, t('Failed to start LinuxDO login')) + ) } finally { setIsLoading(false) } @@ -179,7 +189,7 @@ export function useOAuthLogin( rememberOAuthLoginRedirect(authorization.state, redirectTo) window.open(authorization.authorizationUrl, '_self') } catch (error) { - toast.error(t(AuthOperationError.from(error).message)) + handleServerError(AuthOperationError.from(error)) } finally { setIsLoading(false) } @@ -205,9 +215,12 @@ export function useOAuthLogin( } window.open(url.toString(), '_self') - } catch { - toast.error( - t('Failed to start {{provider}} login', { provider: provider.name }) + } catch (error) { + handleServerError( + AuthOperationError.from( + error, + t('Failed to start {{provider}} login', { provider: provider.name }) + ) ) } finally { setIsLoading(false) diff --git a/web/src/features/auth/otp/components/otp-form.tsx b/web/src/features/auth/otp/components/otp-form.tsx index 274774410..c45d07550 100644 --- a/web/src/features/auth/otp/components/otp-form.tsx +++ b/web/src/features/auth/otp/components/otp-form.tsx @@ -20,6 +20,7 @@ import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { handleServerError } from '@/lib/handle-server-error' import { AuthOperationError } from '@/lib/secure-verification' import { useAuthStore } from '@/stores/auth-store' @@ -77,7 +78,7 @@ export function OtpForm() { toast.success(t('Signed in')) } catch (error) { if (!active) return - toast.error(t(AuthOperationError.from(error).message)) + handleServerError(AuthOperationError.from(error)) redirectToLogin() } })() diff --git a/web/src/features/auth/reset-password-confirm/index.tsx b/web/src/features/auth/reset-password-confirm/index.tsx index 5109af33d..e844fe527 100644 --- a/web/src/features/auth/reset-password-confirm/index.tsx +++ b/web/src/features/auth/reset-password-confirm/index.tsx @@ -29,6 +29,9 @@ import { Label } from '@/components/ui/label' import { useCountdown } from '@/hooks/use-countdown' import { api } from '@/lib/api' import { copyToClipboard } from '@/lib/copy-to-clipboard' +import { handleServerError } from '@/lib/handle-server-error' +import { AuthOperationError } from '@/lib/secure-verification' +import { createServerError } from '@/lib/server-error-message' import { AuthLayout } from '../auth-layout' @@ -82,9 +85,11 @@ export function ResetPasswordConfirm({ } else { toast.success(t('Password reset: {{password}}', { password })) } + } else { + handleServerError(createServerError(res.data, t('Request failed'))) } - } catch { - // Errors handled by global interceptor + } catch (error) { + handleServerError(AuthOperationError.from(error)) } finally { setLoading(false) } @@ -179,13 +184,15 @@ export function ResetPasswordConfirm({ newPassword ? false : loading || isActive || !isValidResetLink } > - {newPassword - ? t('auth.resetPasswordConfirm.backToLogin') - : isActive - ? t('auth.resetPasswordConfirm.retry', { - seconds: secondsLeft, - }) - : t('auth.resetPasswordConfirm.confirm')} + {newPassword && t('auth.resetPasswordConfirm.backToLogin')} + {!newPassword && + isActive && + t('auth.resetPasswordConfirm.retry', { + seconds: secondsLeft, + })} + {!newPassword && + !isActive && + t('auth.resetPasswordConfirm.confirm')} {!newPassword && ( diff --git a/web/src/features/auth/sign-in/components/user-auth-form.tsx b/web/src/features/auth/sign-in/components/user-auth-form.tsx index 15c3383b8..5f97a43eb 100644 --- a/web/src/features/auth/sign-in/components/user-auth-form.tsx +++ b/web/src/features/auth/sign-in/components/user-auth-form.tsx @@ -18,7 +18,6 @@ For commercial licensing, please contact support@quantumnous.com */ import { zodResolver } from '@hookform/resolvers/zod' import { Link } from '@tanstack/react-router' -import axios from 'axios' import { Loader2, LogIn, KeyRound } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' @@ -49,12 +48,14 @@ import { useTurnstile } from '@/features/auth/hooks/use-turnstile' import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey' import type { AuthFormProps } from '@/features/auth/types' import { useStatus } from '@/hooks/use-status' +import { handleServerError } from '@/lib/handle-server-error' import { buildAssertionResult, prepareCredentialRequestOptions, isPasskeySupported as detectPasskeySupport, } from '@/lib/passkey' -import { getServerErrorMessageKey } from '@/lib/server-error-message' +import { AuthOperationError } from '@/lib/secure-verification' +import { createServerError } from '@/lib/server-error-message' import { cn } from '@/lib/utils' export function UserAuthForm({ @@ -178,10 +179,11 @@ export function UserAuthForm({ if (await handleLoginResult(res.data, redirectTo)) { toast.success(t('Welcome back!')) } + } else { + handleServerError(createServerError(res, loginFailedMessage)) } } catch (error: unknown) { - if (axios.isAxiosError(error)) return - toast.error(error instanceof Error ? error.message : loginFailedMessage) + handleServerError(AuthOperationError.from(error, loginFailedMessage)) } finally { setIsLoading(false) } @@ -219,12 +221,12 @@ export function UserAuthForm({ toast.success(t('Signed in via WeChat')) } } else { - if (getServerErrorMessageKey(res)) return - toast.error(res?.message || loginFailedMessage) + handleServerError(createServerError(res, loginFailedMessage)) } } catch (error: unknown) { - if (getServerErrorMessageKey(error)) return - toast.error(loginFailedMessage) + handleServerError( + new AuthOperationError(loginFailedMessage, undefined, { cause: error }) + ) } finally { setIsWeChatSubmitting(false) } @@ -250,8 +252,7 @@ export function UserAuthForm({ try { const begin = await beginPasskeyLogin() if (!begin.success) { - if (getServerErrorMessageKey(begin)) return - throw new Error(begin.message || t('Failed to start Passkey login')) + throw createServerError(begin, t('Failed to start Passkey login')) } const publicKey = prepareCredentialRequestOptions( @@ -278,21 +279,21 @@ export function UserAuthForm({ const finish = await finishPasskeyLogin(flowToken, assertion) if (!finish.success) { - if (getServerErrorMessageKey(finish)) return - throw new Error(finish.message || t('Failed to complete Passkey login')) + throw createServerError(finish, t('Failed to complete Passkey login')) } if (await handleLoginResult(finish.data, redirectTo)) { toast.success(t('Signed in with Passkey')) } } catch (error: unknown) { - if (getServerErrorMessageKey(error)) return if (error instanceof DOMException && error.name === 'NotAllowedError') { toast.info(t('Passkey login was cancelled or timed out')) } else if (error instanceof Error) { - toast.error(error.message) + handleServerError(AuthOperationError.from(error)) } else { - toast.error(t('Passkey login failed')) + handleServerError( + AuthOperationError.from(error, t('Passkey login failed')) + ) } } finally { setIsPasskeyLoading(false) diff --git a/web/src/features/auth/sign-up/components/sign-up-form.tsx b/web/src/features/auth/sign-up/components/sign-up-form.tsx index 5de64fdd5..6dbffb25f 100644 --- a/web/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/src/features/auth/sign-up/components/sign-up-form.tsx @@ -50,7 +50,9 @@ import { saveAffiliateCode, } from '@/features/auth/lib/storage' import { useStatus } from '@/hooks/use-status' -import { getServerErrorMessageKey } from '@/lib/server-error-message' +import { handleServerError } from '@/lib/handle-server-error' +import { AuthOperationError } from '@/lib/secure-verification' +import { createServerError } from '@/lib/server-error-message' import { cn } from '@/lib/utils' export function SignUpForm({ @@ -172,10 +174,12 @@ export function SignUpForm({ toast.success(t('Account created! Please sign in')) redirectToLogin() } else { - toast.error(res?.message || t('Failed to create account')) + handleServerError(createServerError(res, t('Failed to create account'))) } - } catch { - // Errors are handled by global interceptor + } catch (error) { + handleServerError( + AuthOperationError.from(error, t('Failed to create account')) + ) } finally { setIsLoading(false) } @@ -220,12 +224,12 @@ export function SignUpForm({ toast.success(t('Signed in via WeChat')) } } else { - if (getServerErrorMessageKey(res)) return - toast.error(res?.message || t('Login failed')) + handleServerError(createServerError(res, t('Login failed'))) } } catch (error: unknown) { - if (getServerErrorMessageKey(error)) return - toast.error(t('Login failed')) + handleServerError( + new AuthOperationError(t('Login failed'), undefined, { cause: error }) + ) } finally { setIsWeChatSubmitting(false) } diff --git a/web/src/features/channels/api.ts b/web/src/features/channels/api.ts index bb411922b..9f47d92ed 100644 --- a/web/src/features/channels/api.ts +++ b/web/src/features/channels/api.ts @@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { getGroups as getUserGroups } from '@/features/users/api' import { api, type ApiRequestConfig } from '@/lib/api' +import { requireServerSuccess } from '@/lib/server-error-message' import type { AddChannelRequest, @@ -64,7 +65,7 @@ export async function getTaskPluginOptions(): Promise { success: boolean data: TaskPluginOption[] }>('/api/task_plugin_options') - return response.data.data + return requireServerSuccess(response.data).data } export type CodexUsageResponse = { diff --git a/web/src/features/channels/components/channel-type-badge.tsx b/web/src/features/channels/components/channel-type-badge.tsx index 99f51b684..e721ad0a5 100644 --- a/web/src/features/channels/components/channel-type-badge.tsx +++ b/web/src/features/channels/components/channel-type-badge.tsx @@ -30,6 +30,7 @@ import { hasPermission, } from '@/lib/admin-permissions' import { getLobeIcon } from '@/lib/lobe-icon' +import { requireServerSuccess } from '@/lib/server-error-message' import { cn } from '@/lib/utils' import { useAuthStore } from '@/stores/auth-store' @@ -77,7 +78,7 @@ export function TaskPluginChannelBadge(props: { pluginKey?: string }) { ) const query = useQuery({ queryKey: ['task-plugin-options'], - queryFn: getTaskPluginOptions, + queryFn: async () => requireServerSuccess(await getTaskPluginOptions()), enabled: Boolean(props.pluginKey) && canBind, staleTime: 60 * 1000, }) diff --git a/web/src/features/channels/components/channels-columns.tsx b/web/src/features/channels/components/channels-columns.tsx index 4fbb4f3af..34ce59152 100644 --- a/web/src/features/channels/components/channels-columns.tsx +++ b/web/src/features/channels/components/channels-columns.tsx @@ -53,6 +53,8 @@ import { getCurrencyLabel, } from '@/lib/currency' import { formatTimestampToDate } from '@/lib/format' +import { handleServerError } from '@/lib/handle-server-error' +import { createServerError } from '@/lib/server-error-message' import { truncateText } from '@/lib/utils' import { getCodexUsage, updateChannelBalance } from '../api' @@ -437,14 +439,12 @@ export function BalanceCell({ channel }: { channel: Channel }) { try { const res = await getCodexUsage(channel.id) if (!res.success) { - throw new Error(res.message || t('Failed to fetch usage')) + throw createServerError(res, t('Failed to fetch usage')) } setCodexUsageResponse(res) setCodexUsageOpen(true) } catch (error) { - toast.error( - error instanceof Error ? error.message : t('Failed to fetch usage') - ) + handleServerError(error, t('Failed to fetch usage')) } finally { setIsUpdating(false) } @@ -470,12 +470,10 @@ export function BalanceCell({ channel }: { channel: Channel }) { setCurrentRow(channel) setRawBalanceResponse(response.raw_response) } else { - toast.error(response.message || t('Failed to update balance')) + handleServerError(response, t('Failed to update balance')) } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to update balance') - ) + handleServerError(error, t('Failed to update balance')) } finally { setIsUpdating(false) } @@ -556,15 +554,11 @@ export function BalanceCell({ channel }: { channel: Channel }) { try { const res = await getCodexUsage(channel.id) if (!res.success) { - throw new Error(res.message || t('Failed to fetch usage')) + throw createServerError(res, t('Failed to fetch usage')) } setCodexUsageResponse(res) } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t('Failed to fetch usage') - ) + handleServerError(error, t('Failed to fetch usage')) } finally { setIsUpdating(false) } diff --git a/web/src/features/channels/components/channels-table.tsx b/web/src/features/channels/components/channels-table.tsx index 6939d0866..53a1696cf 100644 --- a/web/src/features/channels/components/channels-table.tsx +++ b/web/src/features/channels/components/channels-table.tsx @@ -44,6 +44,7 @@ import { } from '@/components/ui/tooltip' import { useMediaQuery } from '@/hooks' import { useTableUrlState } from '@/hooks/use-table-url-state' +import { requireServerSuccess } from '@/lib/server-error-message' import { getChannels, searchChannels, getGroups } from '../api' import { @@ -205,7 +206,7 @@ export function ChannelsTable() { // Fetch groups for filter const { data: groupsData } = useQuery({ queryKey: ['groups'], - queryFn: getGroups, + queryFn: async () => requireServerSuccess(await getGroups()), }) const groupOptions = useMemo( @@ -243,47 +244,51 @@ export function ChannelsTable() { }), queryFn: async () => { if (shouldSearch) { - return searchChannels({ - keyword: globalFilter, - model: modelFilter, - group: - groupFilter.length > 0 && !groupFilter.includes('all') - ? groupFilter[0] - : undefined, - status: - statusFilter.length > 0 && !statusFilter.includes('all') - ? statusFilter[0] - : undefined, - type: - typeFilter.length > 0 && !typeFilter.includes('all') - ? Number(typeFilter[0]) - : undefined, - tag_mode: enableTagMode, - id_sort: idSort, - ...sortParams, - p: pagination.pageIndex + 1, - page_size: pagination.pageSize, - }) + return requireServerSuccess( + await searchChannels({ + keyword: globalFilter, + model: modelFilter, + group: + groupFilter.length > 0 && !groupFilter.includes('all') + ? groupFilter[0] + : undefined, + status: + statusFilter.length > 0 && !statusFilter.includes('all') + ? statusFilter[0] + : undefined, + type: + typeFilter.length > 0 && !typeFilter.includes('all') + ? Number(typeFilter[0]) + : undefined, + tag_mode: enableTagMode, + id_sort: idSort, + ...sortParams, + p: pagination.pageIndex + 1, + page_size: pagination.pageSize, + }) + ) } else { - return getChannels({ - group: - groupFilter.length > 0 && !groupFilter.includes('all') - ? groupFilter[0] - : undefined, - status: - statusFilter.length > 0 && !statusFilter.includes('all') - ? statusFilter[0] - : undefined, - type: - typeFilter.length > 0 && !typeFilter.includes('all') - ? Number(typeFilter[0]) - : undefined, - tag_mode: enableTagMode, - id_sort: idSort, - ...sortParams, - p: pagination.pageIndex + 1, - page_size: pagination.pageSize, - }) + return requireServerSuccess( + await getChannels({ + group: + groupFilter.length > 0 && !groupFilter.includes('all') + ? groupFilter[0] + : undefined, + status: + statusFilter.length > 0 && !statusFilter.includes('all') + ? statusFilter[0] + : undefined, + type: + typeFilter.length > 0 && !typeFilter.includes('all') + ? Number(typeFilter[0]) + : undefined, + tag_mode: enableTagMode, + id_sort: idSort, + ...sortParams, + p: pagination.pageIndex + 1, + page_size: pagination.pageSize, + }) + ) } }, placeholderData: (previousData) => previousData, diff --git a/web/src/features/channels/components/dialogs/balance-query-dialog.tsx b/web/src/features/channels/components/dialogs/balance-query-dialog.tsx index d5f60fe28..1d3c89120 100644 --- a/web/src/features/channels/components/dialogs/balance-query-dialog.tsx +++ b/web/src/features/channels/components/dialogs/balance-query-dialog.tsx @@ -32,6 +32,8 @@ import { Button } from '@/components/ui/button' import { IconBadge } from '@/components/ui/icon-badge' import { formatCurrencyFromUSD } from '@/lib/currency' import { formatTimestampToDate } from '@/lib/format' +import { handleServerError } from '@/lib/handle-server-error' +import { createServerError } from '@/lib/server-error-message' import { getCodexUsage, updateChannelBalance } from '../../api' import { channelsQueryKeys } from '../../lib' @@ -71,13 +73,11 @@ export function BalanceQueryDialog(props: BalanceQueryDialogProps) { try { const res = await getCodexUsage(row.id) if (!res.success) { - throw new Error(res.message || t('Failed to fetch usage')) + throw createServerError(res, t('Failed to fetch usage')) } setCodexUsageResponse(res) } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to fetch usage') - ) + handleServerError(error, t('Failed to fetch usage')) } finally { setIsQuerying(false) } @@ -119,12 +119,10 @@ export function BalanceQueryDialog(props: BalanceQueryDialogProps) { } else if (response.success && response.raw_response !== undefined) { setRawResponse(response.raw_response) } else { - toast.error(response.message || t('Failed to query balance')) + handleServerError(response, t('Failed to query balance')) } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to query balance') - ) + handleServerError(error, t('Failed to query balance')) } finally { setIsQuerying(false) } diff --git a/web/src/features/channels/components/dialogs/channel-test-dialog.tsx b/web/src/features/channels/components/dialogs/channel-test-dialog.tsx index 682acf3a8..74ef4970b 100644 --- a/web/src/features/channels/components/dialogs/channel-test-dialog.tsx +++ b/web/src/features/channels/components/dialogs/channel-test-dialog.tsx @@ -16,7 +16,6 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { Combobox } from '@/components/ui/combobox' import { useQueryClient } from '@tanstack/react-query' import type { ColumnDef, @@ -61,9 +60,9 @@ import { import { StatusBadge } from '@/components/status-badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' +import { Combobox } from '@/components/ui/combobox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' - import { Sheet, SheetContent, @@ -80,6 +79,7 @@ import { } from '@/components/ui/tooltip' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useIsMobile } from '@/hooks/use-mobile' +import { handleServerError } from '@/lib/handle-server-error' import { updateChannel } from '../../api' import { @@ -193,7 +193,6 @@ const endpointTypeOptions: Array<{ value: string; label: string }> = [ { value: 'embeddings', label: 'Embeddings (/v1/embeddings)' }, ] - const STREAM_INCOMPATIBLE_ENDPOINTS = new Set([ 'embeddings', 'image-generation', @@ -794,14 +793,10 @@ function ChannelTestDialogContent({ refreshChannelLists() setIsDeleteFailedDialogOpen(false) } else { - toast.error(response.message || t('Failed to delete failed models')) + handleServerError(response, t('Failed to delete failed models')) } } catch (error: unknown) { - toast.error( - error instanceof Error - ? error.message - : t('Failed to delete failed models') - ) + handleServerError(error, t('Failed to delete failed models')) } finally { setIsDeletingFailed(false) } @@ -988,13 +983,13 @@ function ChannelTestDialogContent({
+ options={endpointSelectItems} + value={endpointType} + onValueChange={handleEndpointTypeChange} + id='endpoint-type' + className='w-full min-w-0' + placeholder={t('Auto detect (default)')} + />

{t( 'Override the endpoint used for testing. Leave empty to auto detect.' diff --git a/web/src/features/channels/components/dialogs/codex-usage-dialog.tsx b/web/src/features/channels/components/dialogs/codex-usage-dialog.tsx index bc6d28e3d..80e8b5311 100644 --- a/web/src/features/channels/components/dialogs/codex-usage-dialog.tsx +++ b/web/src/features/channels/components/dialogs/codex-usage-dialog.tsx @@ -76,6 +76,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import dayjs from '@/lib/dayjs' import { formatDateTimeStr, formatTimestampToDate } from '@/lib/format' +import { createServerError } from '@/lib/server-error-message' import { cn } from '@/lib/utils' import { @@ -983,8 +984,9 @@ export function CodexUsageDialog({ try { const res = await getCodexResetCredits(channelId) if (!res.success) { - throw new Error( - res.message || t('Failed to fetch reset credit details') + throw createServerError( + res, + t('Failed to fetch reset credit details') ) } setResetCreditsResponse(res) @@ -1034,7 +1036,7 @@ export function CodexUsageDialog({ try { const res = await resetCodexUsage(channelId) if (!res.success) { - throw new Error(res.message || t('Failed to reset usage')) + throw createServerError(res, t('Failed to reset usage')) } const resetPayload = res.data as diff --git a/web/src/features/channels/components/dialogs/edit-tag-dialog.tsx b/web/src/features/channels/components/dialogs/edit-tag-dialog.tsx index 8077234e3..4d07fdad0 100644 --- a/web/src/features/channels/components/dialogs/edit-tag-dialog.tsx +++ b/web/src/features/channels/components/dialogs/edit-tag-dialog.tsx @@ -16,7 +16,6 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { Combobox } from '@/components/ui/combobox' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { useEffect, useState } from 'react' @@ -28,11 +27,13 @@ import { GroupBadge } from '@/components/group-badge' import { JsonCodeEditor } from '@/components/json-code-editor' import { StatusBadge } from '@/components/status-badge' import { Button } from '@/components/ui/button' +import { Combobox } from '@/components/ui/combobox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { ScrollArea } from '@/components/ui/scroll-area' - import { Separator } from '@/components/ui/separator' +import { handleServerError } from '@/lib/handle-server-error' +import { requireServerSuccess } from '@/lib/server-error-message' import { editTagChannels, @@ -65,21 +66,24 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) { // Fetch tag models const { data: tagModelsData, isLoading: isLoadingTagModels } = useQuery({ queryKey: ['tag-models', currentTag], - queryFn: () => (currentTag ? getTagModels(currentTag) : null), + queryFn: async () => + requireServerSuccess( + await (currentTag ? getTagModels(currentTag) : null) + ), enabled: open && !!currentTag, }) // Fetch all available models const { data: allModelsData } = useQuery({ queryKey: ['all-models'], - queryFn: getAllModels, + queryFn: async () => requireServerSuccess(await getAllModels()), enabled: open, }) // Fetch groups const { data: groupsData } = useQuery({ queryKey: ['groups'], - queryFn: getGroups, + queryFn: async () => requireServerSuccess(await getGroups()), enabled: open, }) @@ -193,12 +197,10 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) { queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onOpenChange(false) } else { - toast.error(response.message || t('Failed to update tag')) + handleServerError(response, t('Failed to update tag')) } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to update tag') - ) + handleServerError(error, t('Failed to update tag')) } finally { setIsSubmitting(false) } @@ -298,14 +300,19 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {

({ value: model, label: model }))} - onValueChange={(value: string | null) => { - if (value !== null && !selectedModels.includes(value)) setSelectedModels([...selectedModels, value]) - }} - className='flex-1' - placeholder={t('Add from available models...')} - aria-label={t('Add from available models...')} -/> + options={availableModels.map((model) => ({ + value: model, + label: model, + }))} + onValueChange={(value: string | null) => { + if (value !== null && !selectedModels.includes(value)) { + setSelectedModels([...selectedModels, value]) + } + }} + className='flex-1' + placeholder={t('Add from available models...')} + aria-label={t('Add from available models...')} + />
diff --git a/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx b/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx index 03b39e2ce..568b481e7 100644 --- a/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx +++ b/web/src/features/channels/components/dialogs/fetch-models-dialog.tsx @@ -38,6 +38,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' +import { handleServerError } from '@/lib/handle-server-error' import { fetchUpstreamModels, updateChannel } from '../../api' import { @@ -157,14 +158,12 @@ export function FetchModelsDialog({ setSelectedModels(existingModels) toast.success(t('Fetched {{count}} models', { count: list.length })) } else { - toast.error(response.message || t('Failed to fetch models')) + handleServerError(response, t('Failed to fetch models')) setFetchedModels([]) } } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to fetch models') - ) + handleServerError(error, t('Failed to fetch models')) setFetchedModels([]) } finally { setIsFetching(false) @@ -193,12 +192,10 @@ export function FetchModelsDialog({ queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) onOpenChange(false) } else { - toast.error(response.message || t('Failed to update models')) + handleServerError(response, t('Failed to update models')) } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to update models') - ) + handleServerError(error, t('Failed to update models')) } finally { setIsSaving(false) } diff --git a/web/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx b/web/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx index 22f752149..0fd28bffe 100644 --- a/web/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx +++ b/web/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx @@ -41,6 +41,7 @@ import { ADMIN_PERMISSION_RESOURCES, hasPermission, } from '@/lib/admin-permissions' +import { handleServerError } from '@/lib/handle-server-error' import { useAuthStore } from '@/stores/auth-store' import { @@ -137,19 +138,17 @@ export function MultiKeyManageDialog({ setManualDisabledCount(response.data.manual_disabled_count || 0) setAutoDisabledCount(response.data.auto_disabled_count || 0) } else { - toast.error(response.message || t('Failed to load key status')) + handleServerError(response, t('Failed to load key status')) } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to load key status') - ) + handleServerError(error, t('Failed to load key status')) } finally { setIsLoading(false) } } const handleStatusFilterChange = (value: string) => { - const newFilter = value === 'all' ? null : parseInt(value) + const newFilter = value === 'all' ? null : Number.parseInt(value) setStatusFilter(newFilter) setCurrentPage(1) loadKeyStatus(1, pageSize, newFilter) @@ -204,12 +203,10 @@ export function MultiKeyManageDialog({ loadKeyStatus(currentPage, pageSize) } } else { - toast.error(response?.message || t('Operation failed')) + handleServerError(response, t('Operation failed')) } } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Operation failed') - ) + handleServerError(error, t('Operation failed')) } finally { setIsPerformingAction(false) setConfirmAction(null) @@ -294,12 +291,10 @@ export function MultiKeyManageDialog({ {/* Toolbar */}
setNewTag(e.target.value)} - disabled={isSaving} - /> -

- {t('Leave empty to disband the tag')} -

-
- - {/* Models */} -
- -