fix: localize API error messages
Build and publish Docker images / Build and publish images (push) Successful in 2m35s
Build and publish Docker images / Build and publish images (push) Successful in 2m35s
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
|
||||
|
||||
/**
|
||||
@@ -20,5 +21,12 @@ export const useTrack = (...args) => {
|
||||
* @param {Object|null} action - Optional callback function or object to execute.
|
||||
*/
|
||||
export const useAlert = (message, action = null) => {
|
||||
emitter.emit('newToastMessage', { message, action });
|
||||
const normalizedMessage =
|
||||
message === null || message === undefined || message === ''
|
||||
? message
|
||||
: parseAPIErrorResponse(message);
|
||||
emitter.emit('newToastMessage', {
|
||||
message: normalizedMessage,
|
||||
action,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import Auth from '../api/auth';
|
||||
import { parseAPIErrorResponse } from '../store/utils/api';
|
||||
|
||||
const parseErrorCode = error => Promise.reject(error);
|
||||
const parseErrorCode = error => {
|
||||
if (error && typeof error === 'object') {
|
||||
error.message = parseAPIErrorResponse(error);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
};
|
||||
const AUTH_HEADERS = ['access-token', 'token-type', 'client', 'expiry', 'uid'];
|
||||
|
||||
export default axios => {
|
||||
@@ -28,12 +34,15 @@ export default axios => {
|
||||
const request = error.config;
|
||||
if (
|
||||
error.response?.status !== 401 ||
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
request?._retry ||
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
request?._sessionRefresh ||
|
||||
!Auth.hasAuthCookie()
|
||||
) {
|
||||
return parseErrorCode(error);
|
||||
}
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
request._retry = true;
|
||||
refreshRequest ||= wootApi
|
||||
.get('/auth/validate_token', { _sessionRefresh: true })
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
"COPY_SUCCESS": "Secret copied to clipboard",
|
||||
"TOGGLE": "Toggle secret visibility",
|
||||
"RESET_SUCCESS": "Webhook secret regenerated successfully",
|
||||
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
|
||||
"RESET_ERROR": "无法重新生成 Webhook 密钥,请重试"
|
||||
},
|
||||
"CHANNEL_DOMAIN": {
|
||||
"LABEL": "网站域名",
|
||||
@@ -314,9 +314,9 @@
|
||||
"CANCELLED": "WhatsApp 注册已取消",
|
||||
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
|
||||
"WAITING_FOR_AUTH": "正在等待认证...",
|
||||
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
|
||||
"INVALID_BUSINESS_DATA": "从 Facebook 收到的企业数据无效,请重试。",
|
||||
"SIGNUP_ERROR": "注册时发生错误",
|
||||
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
|
||||
"AUTH_NOT_COMPLETED": "认证未完成,请重新开始操作。",
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
|
||||
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
|
||||
"MANUAL_LINK_TEXT": "手动设置流程"
|
||||
@@ -331,7 +331,7 @@
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "电话号码",
|
||||
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
|
||||
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
|
||||
"ERROR": "请输入有效的 E.164 格式电话号码(例如:+1234567890)"
|
||||
},
|
||||
"TWILIO": {
|
||||
"ACCOUNT_SID": {
|
||||
@@ -735,12 +735,12 @@
|
||||
"WEBHOOK": {
|
||||
"TITLE": "Webhook Configuration",
|
||||
"DESCRIPTION": "Webhook URL is required for your WhatsApp Business Account to receive messages from customers",
|
||||
"ACTION_REQUIRED": "Webhook not configured",
|
||||
"REGISTER_BUTTON": "Register Webhook",
|
||||
"REGISTER_SUCCESS": "Webhook registered successfully",
|
||||
"REGISTER_ERROR": "Failed to register webhook. Please try again.",
|
||||
"CONFIGURED_SUCCESS": "Webhook configured successfully",
|
||||
"URL_MISMATCH": "Webhook URL mismatch"
|
||||
"ACTION_REQUIRED": "未配置 Webhook",
|
||||
"REGISTER_BUTTON": "注册 Webhook",
|
||||
"REGISTER_SUCCESS": "Webhook 注册成功",
|
||||
"REGISTER_ERROR": "Webhook 注册失败,请重试。",
|
||||
"CONFIGURED_SUCCESS": "Webhook 配置成功",
|
||||
"URL_MISMATCH": "Webhook URL 不匹配"
|
||||
}
|
||||
},
|
||||
"SETTINGS": "设置",
|
||||
@@ -816,12 +816,12 @@
|
||||
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
|
||||
"WHATSAPP_CONNECT_BUTTON": "连接",
|
||||
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
|
||||
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
|
||||
"WHATSAPP_CONNECT_ERROR": "连接 WhatsApp Business 失败,请重试。",
|
||||
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
|
||||
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
|
||||
"WHATSAPP_RECONFIGURE_ERROR": "重新配置 WhatsApp Business 失败,请重试。",
|
||||
"WHATSAPP_APP_ID_MISSING": "WhatsApp ID未配置。请联系您的管理员。",
|
||||
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
|
||||
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
|
||||
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp 配置 ID 未设置,请联系管理员。",
|
||||
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp 登录已取消,请重试。",
|
||||
"WHATSAPP_WEBHOOK_TITLE": "Webhook 验证令牌",
|
||||
"WHATSAPP_WEBHOOK_SUBHEADER": "此令牌用于验证webhook端点的真实性。",
|
||||
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
|
||||
@@ -933,7 +933,7 @@
|
||||
},
|
||||
"BUTTON_TEXT": {
|
||||
"LABEL": "Button text",
|
||||
"PLACEHOLDER": "Please rate us"
|
||||
"PLACEHOLDER": "请为我们评分"
|
||||
},
|
||||
"LANGUAGE": {
|
||||
"LABEL": "语言",
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
"SETTING_UP": "Setting up your account...",
|
||||
"CONTINUE": "Continue",
|
||||
"SAVING": "保存中...",
|
||||
"VALIDATION_ERROR": "Please fill in all required fields",
|
||||
"SUCCESS": "Details saved successfully",
|
||||
"ERROR": "Could not save details. Please try again."
|
||||
"VALIDATION_ERROR": "请填写所有必填项",
|
||||
"SUCCESS": "信息保存成功",
|
||||
"ERROR": "无法保存信息,请重试。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +494,7 @@ export default {
|
||||
const response = await InboxHealthAPI.getHealthStatus(this.inbox.id);
|
||||
this.healthData = response.data;
|
||||
} catch (error) {
|
||||
this.healthError = error.message || 'Failed to fetch health data';
|
||||
this.healthError = error.message || '无法获取健康状态,请稍后再试。';
|
||||
} finally {
|
||||
this.isLoadingHealth = false;
|
||||
}
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ const handleStoreUrlSubmit = async () => {
|
||||
storeUrlError.value = '';
|
||||
if (!validateStoreUrl(storeUrl.value)) {
|
||||
storeUrlError.value =
|
||||
'Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)';
|
||||
'请输入有效的 Shopify 店铺网址(例如:your-store.myshopify.com)';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,16 @@ export const deleteIndexedDBOnLogout = async () => {
|
||||
localStorage.removeItem('cw-idb-names');
|
||||
};
|
||||
|
||||
const getSafeLogoutRedirectLink = redirectLink => {
|
||||
try {
|
||||
const url = new URL(redirectLink || '/', window.location.origin);
|
||||
if (url.origin !== window.location.origin) return '/';
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
};
|
||||
|
||||
export const clearCookiesOnLogout = () => {
|
||||
emitter.emit(CHATWOOT_RESET);
|
||||
emitter.emit(ANALYTICS_RESET);
|
||||
@@ -78,36 +88,163 @@ export const clearCookiesOnLogout = () => {
|
||||
clearLocalStorageOnLogout();
|
||||
clearSessionStorageOnLogout();
|
||||
const globalConfig = window.globalConfig || {};
|
||||
const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || '/';
|
||||
window.location = logoutRedirectLink;
|
||||
const logoutRedirectLink = getSafeLogoutRedirectLink(
|
||||
globalConfig.LOGOUT_REDIRECT_LINK
|
||||
);
|
||||
window.location.assign(logoutRedirectLink);
|
||||
};
|
||||
|
||||
const API_ERROR_MESSAGES = Object.freeze({
|
||||
400: '请求参数有误,请检查后重试。',
|
||||
401: '身份验证失败,请重新登录。',
|
||||
403: '您没有权限执行此操作。',
|
||||
404: '请求的资源不存在。',
|
||||
408: '请求超时,请稍后再试。',
|
||||
409: '操作冲突,请刷新后重试。',
|
||||
422: '提交的信息有误,请检查后重试。',
|
||||
429: '操作过于频繁,请稍后再试。',
|
||||
500: '服务器发生错误,请稍后再试。',
|
||||
502: '服务暂时不可用,请稍后再试。',
|
||||
503: '服务暂时不可用,请稍后再试。',
|
||||
504: '服务暂时不可用,请稍后再试。',
|
||||
});
|
||||
|
||||
const extractStatusFromMessage = message => {
|
||||
const match = message.match(
|
||||
/(?:request failed with status code|status code|error)\s*[:=]?\s*([45]\d{2})/i
|
||||
);
|
||||
return match ? Number(match[1]) : null;
|
||||
};
|
||||
|
||||
const getStatusMessage = status => {
|
||||
const numericStatus = Number(status);
|
||||
if (!Number.isInteger(numericStatus)) return '';
|
||||
return (
|
||||
API_ERROR_MESSAGES[numericStatus] ||
|
||||
(numericStatus >= 500 ? API_ERROR_MESSAGES[500] : '')
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeAPIErrorText = (message, status) => {
|
||||
if (typeof message !== 'string') return message;
|
||||
|
||||
const text = message.trim();
|
||||
if (!text) return text;
|
||||
if (/^(network error|failed to fetch|network request failed)$/i.test(text)) {
|
||||
return '网络连接失败,请检查网络后重试。';
|
||||
}
|
||||
if (/timeout|timed out|超时/i.test(text)) {
|
||||
return '请求超时,请稍后再试。';
|
||||
}
|
||||
if (/^invalid email or password$/i.test(text)) {
|
||||
return '邮箱或密码错误,请重试。';
|
||||
}
|
||||
if (/^email not confirmed\b/i.test(text)) {
|
||||
return '邮箱尚未验证,请先完成邮箱验证。';
|
||||
}
|
||||
if (/^user account is inactive\b/i.test(text)) {
|
||||
return '账户已停用,请联系管理员。';
|
||||
}
|
||||
if (/^this account uses .* authentication/i.test(text)) {
|
||||
return '此账户使用第三方登录,请通过对应方式登录。';
|
||||
}
|
||||
if (
|
||||
/^(invalid (token|credentials|headers)|unauthorized|unauthenticated|authentication failed)$/i.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
return '身份验证失败,请重新登录。';
|
||||
}
|
||||
if (/^account not identified$/i.test(text)) {
|
||||
return '无法识别当前账户,请重新登录。';
|
||||
}
|
||||
if (/administrator role required/i.test(text)) {
|
||||
return '需要管理员权限才能执行此操作。';
|
||||
}
|
||||
if (
|
||||
/^(not authorized|authorization header required|bearer token required|widget_token required)$/i.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
return '缺少有效的登录凭证,请重新登录。';
|
||||
}
|
||||
if (/^account is suspended$/i.test(text)) {
|
||||
return '账户已被停用,请联系管理员。';
|
||||
}
|
||||
if (/^sso session (expired or invalid|data corrupt)$/i.test(text)) {
|
||||
return '单点登录会话无效,请重新登录。';
|
||||
}
|
||||
if (/^invalid (source )?url/i.test(text)) {
|
||||
return '请求地址无效,请检查后重试。';
|
||||
}
|
||||
|
||||
const messageStatus = extractStatusFromMessage(text);
|
||||
if (messageStatus) {
|
||||
return getStatusMessage(messageStatus) || text;
|
||||
}
|
||||
|
||||
if (
|
||||
status &&
|
||||
/^(bad request|unauthorized|unauthenticated|forbidden|not found|request timeout|unprocessable entity|too many requests|internal server error|bad gateway|service unavailable|gateway timeout|invalid credentials|authentication failed)$/i.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
return getStatusMessage(status) || text;
|
||||
}
|
||||
if (
|
||||
status >= 500 &&
|
||||
/\b(error|failed|unavailable|gateway|server)\b/i.test(text)
|
||||
) {
|
||||
return getStatusMessage(status) || text;
|
||||
}
|
||||
|
||||
if (/^(axioserror:\s*)?(error:\s*)?\[object object\]$/i.test(text)) {
|
||||
return '请求失败,请稍后再试。';
|
||||
}
|
||||
if (
|
||||
/^(failed|unable to|could not|cannot|can't|something went wrong|an error occurred)\b/i.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
return '请求失败,请稍后再试。';
|
||||
}
|
||||
return message;
|
||||
};
|
||||
|
||||
const extractErrorText = value => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value?.message && typeof value.message === 'string') {
|
||||
return value.message;
|
||||
}
|
||||
if (Array.isArray(value)) return extractErrorText(value[0]);
|
||||
if (value && typeof value === 'object') return JSON.stringify(value);
|
||||
return '';
|
||||
};
|
||||
|
||||
export const parseAPIErrorResponse = error => {
|
||||
if (error?.response?.data?.message) {
|
||||
return error?.response?.data?.message;
|
||||
if (typeof error === 'string') return normalizeAPIErrorText(error);
|
||||
|
||||
const status = error?.response?.status ?? error?.status;
|
||||
if (error?.code === 'ERR_NETWORK') {
|
||||
return '网络连接失败,请检查网络后重试。';
|
||||
}
|
||||
if (error?.response?.data?.error) {
|
||||
// error could be a string or an object with a message property
|
||||
if (typeof error.response.data.error === 'string') {
|
||||
return error.response.data.error;
|
||||
if (error?.code === 'ECONNABORTED') {
|
||||
return '请求超时,请稍后再试。';
|
||||
}
|
||||
if (error.response.data.error?.message) {
|
||||
return error.response.data.error.message;
|
||||
}
|
||||
return JSON.stringify(error.response.data.error);
|
||||
}
|
||||
if (error?.response?.data?.errors) {
|
||||
const first = error.response.data.errors;
|
||||
if (Array.isArray(first)) {
|
||||
return first[0];
|
||||
}
|
||||
return first;
|
||||
}
|
||||
// Fallback: return a useful default message rather than the raw error object
|
||||
if (error?.message && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
return 'Request failed. Please try again.';
|
||||
const data = error?.response?.data;
|
||||
const message = extractErrorText(data?.message);
|
||||
if (message) return normalizeAPIErrorText(message, status);
|
||||
|
||||
const apiError = extractErrorText(data?.error);
|
||||
if (apiError) return normalizeAPIErrorText(apiError, status);
|
||||
|
||||
const errors = extractErrorText(data?.errors);
|
||||
if (errors) return normalizeAPIErrorText(errors, status);
|
||||
|
||||
const errorMessage = extractErrorText(error?.message);
|
||||
if (errorMessage) return normalizeAPIErrorText(errorMessage, status);
|
||||
|
||||
return getStatusMessage(status) || '请求失败,请稍后再试。';
|
||||
};
|
||||
|
||||
export const throwErrorMessage = error => {
|
||||
|
||||
@@ -35,7 +35,31 @@ describe('#parseAPIErrorResponse', () => {
|
||||
).toBe('Error Message [error]');
|
||||
|
||||
expect(parseAPIErrorResponse('Error: 422 Failed')).toBe(
|
||||
'Request failed. Please try again.'
|
||||
'提交的信息有误,请检查后重试。'
|
||||
);
|
||||
expect(
|
||||
parseAPIErrorResponse(new Error('Request failed with status code 401'))
|
||||
).toBe('身份验证失败,请重新登录。');
|
||||
expect(
|
||||
parseAPIErrorResponse({
|
||||
response: { status: 401, data: { error: 'invalid email or password' } },
|
||||
})
|
||||
).toBe('邮箱或密码错误,请重试。');
|
||||
expect(
|
||||
parseAPIErrorResponse({
|
||||
response: {
|
||||
status: 403,
|
||||
data: { error: 'administrator role required' },
|
||||
},
|
||||
})
|
||||
).toBe('需要管理员权限才能执行此操作。');
|
||||
expect(
|
||||
parseAPIErrorResponse({
|
||||
response: { status: 500, data: { error: 'database error' } },
|
||||
})
|
||||
).toBe('服务器发生错误,请稍后再试。');
|
||||
expect(parseAPIErrorResponse(new Error('Network Error'))).toBe(
|
||||
'网络连接失败,请检查网络后重试。'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import axios from 'axios';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
const { apiHost = '' } = window.chatwootConfig || {};
|
||||
const wootAPI = axios.create({ baseURL: `${apiHost}/`, withCredentials: true });
|
||||
|
||||
if (wootAPI?.interceptors?.response) {
|
||||
wootAPI.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error && typeof error === 'object') {
|
||||
error.message = parseAPIErrorResponse(error);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default wootAPI;
|
||||
|
||||
@@ -2,23 +2,17 @@
|
||||
// utils and composables
|
||||
import { login } from '../../api/auth';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
|
||||
// components
|
||||
import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
|
||||
import FormInput from '../../components/Form/Input.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
|
||||
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
|
||||
};
|
||||
|
||||
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
|
||||
const USER_NOT_CONFIRMED_ERROR_CODE = 'user_not_confirmed';
|
||||
|
||||
@@ -27,8 +21,6 @@ export default {
|
||||
FormInput,
|
||||
Spinner,
|
||||
NextButton,
|
||||
SimpleDivider,
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
ssoAuthToken: { type: String, default: '' },
|
||||
@@ -91,7 +83,7 @@ export default {
|
||||
},
|
||||
showAlertMessage(message) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.loginApi.message = message;
|
||||
this.loginApi.message = parseAPIErrorResponse(message);
|
||||
useAlert(this.loginApi.message);
|
||||
},
|
||||
handleImpersonation() {
|
||||
@@ -134,9 +126,13 @@ export default {
|
||||
window.location = '/app/login';
|
||||
}
|
||||
this.loginApi.hasErrored = true;
|
||||
this.showAlertMessage(
|
||||
response?.message || this.$t('LOGIN.API.UNAUTH')
|
||||
);
|
||||
let errorMessage = this.$t('LOGIN.API.ERROR_MESSAGE');
|
||||
if (response?.response?.status === 401) {
|
||||
errorMessage = this.$t('LOGIN.API.UNAUTH');
|
||||
} else if (response) {
|
||||
errorMessage = parseAPIErrorResponse(response);
|
||||
}
|
||||
this.showAlertMessage(errorMessage);
|
||||
});
|
||||
},
|
||||
submitFormLogin() {
|
||||
@@ -157,9 +153,8 @@ export default {
|
||||
>
|
||||
<!-- Regular Login Section -->
|
||||
<section
|
||||
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
class="bg-white shadow sm:mx-auto mb-8 mt-15 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
:class="{
|
||||
'mb-8 mt-15': true,
|
||||
'animate-wiggle': loginApi.hasErrored,
|
||||
}"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user