0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(console,core,phrases): improve error handling in ac profile (#3317)

This commit is contained in:
Charles Zhao 2023-03-08 16:00:11 +08:00 committed by GitHub
parent 43470c41f1
commit 01735d1647
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 190 additions and 61 deletions

View file

@ -1,3 +1,4 @@
import { passwordRegEx } from '@logto/core-kit';
import type { KeyboardEventHandler } from 'react'; import type { KeyboardEventHandler } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -12,8 +13,10 @@ import IconButton from '@/components/IconButton';
import TextInput from '@/components/TextInput'; import TextInput from '@/components/TextInput';
import { adminTenantEndpoint, meApi } from '@/consts'; import { adminTenantEndpoint, meApi } from '@/consts';
import { useStaticApi } from '@/hooks/use-api'; import { useStaticApi } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import MainFlowLikeModal from '../../components/MainFlowLikeModal'; import MainFlowLikeModal from '../../components/MainFlowLikeModal';
import { handleError } from '../../utils';
type FormFields = { type FormFields = {
newPassword: string; newPassword: string;
@ -30,6 +33,7 @@ const defaultValues: FormFields = {
const ChangePasswordModal = () => { const ChangePasswordModal = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate(); const navigate = useNavigate();
const { show: showModal } = useConfirmModal();
const { state } = useLocation(); const { state } = useLocation();
const { const {
watch, watch,
@ -44,19 +48,38 @@ const ChangePasswordModal = () => {
defaultValues, defaultValues,
}); });
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator }); const api = useStaticApi({
prefixUrl: adminTenantEndpoint,
resourceIndicator: meApi.indicator,
hideErrorToast: true,
});
const onClose = () => { const onClose = () => {
reset();
navigate('/profile'); navigate('/profile');
}; };
const onSubmit = () => { const onSubmit = () => {
clearErrors(); clearErrors();
void handleSubmit(async ({ newPassword }) => { void handleSubmit(async ({ newPassword }) => {
await api.post(`me/password`, { json: { password: newPassword } }); try {
toast.success(t('profile.password_changed')); await api.post(`me/password`, { json: { password: newPassword } });
reset(); toast.success(t('profile.password_changed'));
onClose(); onClose();
} catch (error: unknown) {
void handleError(error, async (code, message) => {
if (code === 'session.verification_failed') {
await showModal({
ModalContent: message,
type: 'alert',
cancelButtonText: 'general.got_it',
});
onClose();
return true;
}
});
}
})(); })();
}; };
@ -79,8 +102,12 @@ const ChangePasswordModal = () => {
{...register('newPassword', { {...register('newPassword', {
required: t('profile.password.required'), required: t('profile.password.required'),
minLength: { minLength: {
value: 6, value: 8,
message: t('profile.password.min_length', { min: 6 }), message: t('profile.password.min_length', { min: 8 }),
},
pattern: {
value: passwordRegEx,
message: t('errors.password_pattern_error'),
}, },
})} })}
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
@ -123,7 +150,13 @@ const ChangePasswordModal = () => {
setShowPassword((value) => !value); setShowPassword((value) => !value);
}} }}
/> />
<Button type="primary" title="general.create" isLoading={isSubmitting} onClick={onSubmit} /> <Button
type="primary"
title="general.create"
size="large"
isLoading={isSubmitting}
onClick={onSubmit}
/>
</MainFlowLikeModal> </MainFlowLikeModal>
); );
}; };

View file

@ -1,6 +1,4 @@
import type { RequestErrorBody } from '@logto/schemas';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { HTTPError } from 'ky';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -12,10 +10,11 @@ import TextLink from '@/components/TextLink';
import VerificationCodeInput, { defaultLength } from '@/components/VerificationCodeInput'; import VerificationCodeInput, { defaultLength } from '@/components/VerificationCodeInput';
import { adminTenantEndpoint, meApi } from '@/consts'; import { adminTenantEndpoint, meApi } from '@/consts';
import { useStaticApi } from '@/hooks/use-api'; import { useStaticApi } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useCurrentUser from '@/hooks/use-current-user'; import useCurrentUser from '@/hooks/use-current-user';
import MainFlowLikeModal from '../../components/MainFlowLikeModal'; import MainFlowLikeModal from '../../components/MainFlowLikeModal';
import { checkLocationState } from '../../utils'; import { checkLocationState, handleError } from '../../utils';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
export const resendTimeout = 59; export const resendTimeout = 59;
@ -30,6 +29,7 @@ const getTimeout = () => {
const VerificationCodeModal = () => { const VerificationCodeModal = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate(); const navigate = useNavigate();
const { show: showModal } = useConfirmModal();
const { state } = useLocation(); const { state } = useLocation();
const { reload } = useCurrentUser(); const { reload } = useCurrentUser();
const [code, setCode] = useState<string[]>([]); const [code, setCode] = useState<string[]>([]);
@ -74,14 +74,34 @@ const VerificationCodeModal = () => {
navigate('../change-password', { state }); navigate('../change-password', { state });
} }
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof HTTPError) { void handleError(error, async (code, message) => {
const logtoError = await error.response.json<RequestErrorBody>(); // The following errors will be displayed as inline error message.
setError(logtoError.message); if (
} else { [
setError(String(error)); 'verification_code.code_mismatch',
} 'verification_code.exceed_max_try',
'verification_code.not_found',
].includes(code)
) {
setError(message);
return true;
}
// Other verification code errors will be displayed in a popup modal.
if (code.startsWith('verification_code.')) {
await showModal({
ModalContent: message,
type: 'alert',
cancelButtonText: 'general.got_it',
});
onClose();
return true;
}
});
} }
}, [code, email, api, action, t, onClose, navigate, state]); }, [code, email, api, action, t, onClose, navigate, state, showModal]);
useEffect(() => { useEffect(() => {
if (code.length === defaultLength && code.every(Boolean)) { if (code.length === defaultLength && code.every(Boolean)) {

View file

@ -15,7 +15,7 @@ import { adminTenantEndpoint, meApi } from '@/consts';
import { useStaticApi } from '@/hooks/use-api'; import { useStaticApi } from '@/hooks/use-api';
import MainFlowLikeModal from '../../components/MainFlowLikeModal'; import MainFlowLikeModal from '../../components/MainFlowLikeModal';
import { checkLocationState } from '../../utils'; import { checkLocationState, handleError } from '../../utils';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
type FormFields = { type FormFields = {
@ -30,12 +30,17 @@ const VerifyPasswordModal = () => {
register, register,
reset, reset,
clearErrors, clearErrors,
setError,
handleSubmit, handleSubmit,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<FormFields>({ } = useForm<FormFields>({
reValidateMode: 'onBlur', reValidateMode: 'onBlur',
}); });
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator }); const api = useStaticApi({
prefixUrl: adminTenantEndpoint,
resourceIndicator: meApi.indicator,
hideErrorToast: true,
});
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const email = conditional(checkLocationState(state) && state.email); const email = conditional(checkLocationState(state) && state.email);
@ -46,9 +51,19 @@ const VerifyPasswordModal = () => {
const onSubmit = () => { const onSubmit = () => {
clearErrors(); clearErrors();
void handleSubmit(async ({ password }) => { void handleSubmit(async ({ password }) => {
await api.post(`me/password/verify`, { json: { password } }); try {
reset(); await api.post(`me/password/verify`, { json: { password } });
navigate('../change-password', { state }); reset();
navigate('../change-password', { state });
} catch (error: unknown) {
void handleError(error, async (code, message) => {
if (code === 'session.invalid_credentials') {
setError('password', { type: 'custom', message });
return true;
}
});
}
})(); })();
}; };
@ -60,13 +75,7 @@ const VerifyPasswordModal = () => {
onGoBack={onClose} onGoBack={onClose}
> >
<TextInput <TextInput
{...register('password', { {...register('password', { required: t('profile.password.required') })}
required: t('profile.password.required'),
minLength: {
value: 6,
message: t('profile.password.min_length', { min: 6 }),
},
})}
errorMessage={errors.password?.message} errorMessage={errors.password?.message}
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
suffix={ suffix={

View file

@ -7,6 +7,7 @@ import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle'; import CardTitle from '@/components/CardTitle';
import FormCard from '@/components/FormCard'; import FormCard from '@/components/FormCard';
import { adminTenantEndpoint, meApi } from '@/consts'; import { adminTenantEndpoint, meApi } from '@/consts';
import { isCloud } from '@/consts/cloud';
import { useStaticApi } from '@/hooks/use-api'; import { useStaticApi } from '@/hooks/use-api';
import useCurrentUser from '@/hooks/use-current-user'; import useCurrentUser from '@/hooks/use-current-user';
import * as resourcesStyles from '@/scss/resources.module.scss'; import * as resourcesStyles from '@/scss/resources.module.scss';
@ -74,23 +75,25 @@ const Profile = () => {
]} ]}
/> />
</FormCard> </FormCard>
<FormCard title="profile.delete_account.title"> {isCloud && (
<div className={styles.deleteAccount}> <FormCard title="profile.delete_account.title">
<div className={styles.description}>{t('profile.delete_account.description')}</div> <div className={styles.deleteAccount}>
<Button <div className={styles.description}>{t('profile.delete_account.description')}</div>
title="profile.delete_account.button" <Button
onClick={() => { title="profile.delete_account.button"
setShowDeleteAccountModal(true); onClick={() => {
setShowDeleteAccountModal(true);
}}
/>
</div>
<DeleteAccountModal
isOpen={showDeleteAccountModal}
onClose={() => {
setShowDeleteAccountModal(false);
}} }}
/> />
</div> </FormCard>
<DeleteAccountModal )}
isOpen={showDeleteAccountModal}
onClose={() => {
setShowDeleteAccountModal(false);
}}
/>
</FormCard>
</div> </div>
)} )}
</div> </div>

View file

@ -1,3 +1,7 @@
import type { RequestErrorBody } from '@logto/schemas';
import { HTTPError } from 'ky';
import { toast } from 'react-hot-toast';
export type LocationState = { export type LocationState = {
email: string; email: string;
action: 'changePassword' | 'changeEmail'; action: 'changePassword' | 'changeEmail';
@ -39,3 +43,26 @@ export const popupWindow = (url: string, windowName: string, width: number, heig
].join(',') ].join(',')
); );
}; };
export const handleError = async (
error: unknown,
exec?: (errorCode: string, message: string) => Promise<boolean | undefined>
) => {
if (error instanceof HTTPError) {
const logtoError = await error.response.json<RequestErrorBody>();
const { code, message } = logtoError;
const handled = await exec?.(code, message);
if (handled) {
return;
}
if (error.response.status !== 401) {
toast.error(message);
return;
}
}
throw error;
};

View file

@ -6,6 +6,7 @@
inset: 0; inset: 0;
overflow-y: auto; overflow-y: auto;
padding: _.unit(12) 0; padding: _.unit(12) 0;
z-index: 101;
} }
.content { .content {

View file

@ -33,17 +33,17 @@ export const encryptUserPassword = async (
}; };
export const verifyUserPassword = async (user: Nullable<User>, password: string): Promise<User> => { export const verifyUserPassword = async (user: Nullable<User>, password: string): Promise<User> => {
assertThat(user, new RequestError({ code: 'session.invalid_credentials', status: 401 })); assertThat(user, new RequestError({ code: 'session.invalid_credentials', status: 422 }));
const { passwordEncrypted, passwordEncryptionMethod } = user; const { passwordEncrypted, passwordEncryptionMethod } = user;
assertThat( assertThat(
passwordEncrypted && passwordEncryptionMethod, passwordEncrypted && passwordEncryptionMethod,
new RequestError({ code: 'session.invalid_credentials', status: 401 }) new RequestError({ code: 'session.invalid_credentials', status: 422 })
); );
const result = await argon2Verify({ password, hash: passwordEncrypted }); const result = await argon2Verify({ password, hash: passwordEncrypted });
assertThat(result, new RequestError({ code: 'session.invalid_credentials', status: 401 })); assertThat(result, new RequestError({ code: 'session.invalid_credentials', status: 422 }));
return user; return user;
}; };

View file

@ -1,9 +1,12 @@
import { VerificationCodeType } from '@logto/connector-kit'; import { VerificationCodeType } from '@logto/connector-kit';
import { emailRegEx } from '@logto/core-kit'; import { emailRegEx } from '@logto/core-kit';
import { object, string } from 'zod'; import { literal, object, string, union } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import type { RouterInitArgs } from '#src/routes/types.js'; import type { RouterInitArgs } from '#src/routes/types.js';
import assertThat from '#src/utils/assert-that.js';
import { convertCookieToMap } from '#src/utils/cookie.js';
import type { AuthedMeRouter } from './types.js'; import type { AuthedMeRouter } from './types.js';
@ -12,13 +15,20 @@ export default function verificationCodeRoutes<T extends AuthedMeRouter>(
) { ) {
const codeType = VerificationCodeType.Generic; const codeType = VerificationCodeType.Generic;
const { const {
passcodes: { createPasscode, sendPasscode, verifyPasscode }, queries: {
} = tenant.libraries; users: { findUserById },
},
libraries: {
passcodes: { createPasscode, sendPasscode, verifyPasscode },
verificationStatuses: { createVerificationStatus },
},
} = tenant;
router.post( router.post(
'/verification-codes', '/verification-codes',
koaGuard({ koaGuard({
body: object({ email: string().regex(emailRegEx) }), body: object({ email: string().regex(emailRegEx) }),
status: 204,
}), }),
async (ctx, next) => { async (ctx, next) => {
const code = await createPasscode(undefined, codeType, ctx.guard.body); const code = await createPasscode(undefined, codeType, ctx.guard.body);
@ -33,12 +43,31 @@ export default function verificationCodeRoutes<T extends AuthedMeRouter>(
router.post( router.post(
'/verification-codes/verify', '/verification-codes/verify',
koaGuard({ koaGuard({
body: object({ email: string().regex(emailRegEx), verificationCode: string().min(1) }), body: object({
email: string().regex(emailRegEx),
verificationCode: string().min(1),
action: union([literal('changeEmail'), literal('changePassword')]),
}),
status: 204,
}), }),
async (ctx, next) => { async (ctx, next) => {
const { verificationCode, ...identifier } = ctx.guard.body; const { id: userId } = ctx.auth;
const { verificationCode, action, ...identifier } = ctx.guard.body;
await verifyPasscode(undefined, codeType, verificationCode, identifier); await verifyPasscode(undefined, codeType, verificationCode, identifier);
if (action === 'changePassword') {
// Store password verification status
const cookieMap = convertCookieToMap(ctx.request.headers.cookie);
const sessionId = cookieMap.get('_session');
assertThat(sessionId, new RequestError({ code: 'session.not_found', status: 401 }));
const user = await findUserById(userId);
assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
await createVerificationStatus(userId, sessionId);
}
ctx.status = 204; ctx.status = 204;
return next(); return next();

View file

@ -13,7 +13,8 @@ const errors = {
more_details: 'Mehr Details', more_details: 'Mehr Details',
username_pattern_error: username_pattern_error:
'Der Benutzername sollte nur Buchstaben, Zahlen oder Unterstriche enthalten und nicht mit einer Zahl beginnen.', 'Der Benutzername sollte nur Buchstaben, Zahlen oder Unterstriche enthalten und nicht mit einer Zahl beginnen.',
password_pattern_error: 'Das Passwort muss aus mindestens 6 Zeichen lang sein', password_pattern_error:
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
insecure_contexts: 'Unsichere Kontexte (nicht-HTTPS) werden nicht unterstützt.', insecure_contexts: 'Unsichere Kontexte (nicht-HTTPS) werden nicht unterstützt.',
unexpected_error: 'Ein unerwarteter Fehler ist aufgetreten', unexpected_error: 'Ein unerwarteter Fehler ist aufgetreten',
not_found: '404 not found', // UNTRANSLATED not_found: '404 not found', // UNTRANSLATED

View file

@ -13,7 +13,8 @@ const errors = {
more_details: 'More details', more_details: 'More details',
username_pattern_error: username_pattern_error:
'Username should only contain letters, numbers, or underscore and should not start with a number.', 'Username should only contain letters, numbers, or underscore and should not start with a number.',
password_pattern_error: 'Password requires a minimum of 6 characters', password_pattern_error:
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.',
insecure_contexts: 'Insecure contexts (non-HTTPS) are not supported.', insecure_contexts: 'Insecure contexts (non-HTTPS) are not supported.',
unexpected_error: 'An unexpected error occurred', unexpected_error: 'An unexpected error occurred',
not_found: '404 not found', not_found: '404 not found',

View file

@ -13,7 +13,8 @@ const errors = {
more_details: 'Plus de détails', more_details: 'Plus de détails',
username_pattern_error: username_pattern_error:
"Le nom d'utilisateur ne doit contenir que des lettres, des chiffres ou des traits de soulignement et ne doit pas commencer par un chiffre.", "Le nom d'utilisateur ne doit contenir que des lettres, des chiffres ou des traits de soulignement et ne doit pas commencer par un chiffre.",
password_pattern_error: 'Le mot de passe doit comporter un minimum de 6 caractères', password_pattern_error:
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
insecure_contexts: 'Les contextes non sécurisés (non HTTPS) ne sont pas pris en charge.', insecure_contexts: 'Les contextes non sécurisés (non HTTPS) ne sont pas pris en charge.',
unexpected_error: "Une erreur inattendue s'est produite", unexpected_error: "Une erreur inattendue s'est produite",
not_found: '404 not found', // UNTRANSLATED not_found: '404 not found', // UNTRANSLATED

View file

@ -13,7 +13,8 @@ const errors = {
more_details: '자세히', more_details: '자세히',
username_pattern_error: username_pattern_error:
'아이디는 반드시 문자, 숫자, _ 만으로 이루어져야 하며, 숫자로 시작하면 안 돼요.', '아이디는 반드시 문자, 숫자, _ 만으로 이루어져야 하며, 숫자로 시작하면 안 돼요.',
password_pattern_error: '비밀번호는 최소 6자리로 이루어져야 해요.', password_pattern_error:
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
insecure_contexts: '비보안 연결(non-HTTPS)는 지원하지 않아요.', insecure_contexts: '비보안 연결(non-HTTPS)는 지원하지 않아요.',
unexpected_error: '알 수 없는 오류가 발생했어요.', unexpected_error: '알 수 없는 오류가 발생했어요.',
not_found: '404 not found', // UNTRANSLATED not_found: '404 not found', // UNTRANSLATED

View file

@ -13,7 +13,8 @@ const errors = {
more_details: 'Mais detalhes', more_details: 'Mais detalhes',
username_pattern_error: username_pattern_error:
'O nome de usuário deve conter apenas letras, números ou sublinhado e não deve começar com um número.', 'O nome de usuário deve conter apenas letras, números ou sublinhado e não deve começar com um número.',
password_pattern_error: 'A senha requer um mínimo de 6 caracteres', password_pattern_error:
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
insecure_contexts: 'Contextos inseguros (não-HTTPS) não são suportados.', insecure_contexts: 'Contextos inseguros (não-HTTPS) não são suportados.',
unexpected_error: 'Um erro inesperado ocorreu', unexpected_error: 'Um erro inesperado ocorreu',
not_found: '404 not found', // UNTRANSLATED not_found: '404 not found', // UNTRANSLATED

View file

@ -13,7 +13,8 @@ const errors = {
more_details: 'Mais detalhes', more_details: 'Mais detalhes',
username_pattern_error: username_pattern_error:
'O nome de utilizador deve conter apenas letras, números ou underscores e não deve começar com um número.', 'O nome de utilizador deve conter apenas letras, números ou underscores e não deve começar com um número.',
password_pattern_error: 'A password requer um mínimo de 6 caracteres', password_pattern_error:
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
insecure_contexts: 'Contextos inseguros (não HTTPS) não são compatíveis.', insecure_contexts: 'Contextos inseguros (não HTTPS) não são compatíveis.',
unexpected_error: 'Um erro inesperado ocorreu', unexpected_error: 'Um erro inesperado ocorreu',
not_found: '404 not found', // UNTRANSLATED not_found: '404 not found', // UNTRANSLATED

View file

@ -13,7 +13,8 @@ const errors = {
more_details: 'Daha çok detay', more_details: 'Daha çok detay',
username_pattern_error: username_pattern_error:
'Kullanıcı adı yalnızca harf, sayı veya alt çizgi içermeli ve bir sayı ile başlamamalıdır.', 'Kullanıcı adı yalnızca harf, sayı veya alt çizgi içermeli ve bir sayı ile başlamamalıdır.',
password_pattern_error: 'Şifre minimum 6 karakter olmalı', password_pattern_error:
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
insecure_contexts: 'Güvenli olmayan bağlamlar (HTTPS olmayan) desteklenmez.', insecure_contexts: 'Güvenli olmayan bağlamlar (HTTPS olmayan) desteklenmez.',
unexpected_error: 'Beklenmedik bir hata oluştu', unexpected_error: 'Beklenmedik bir hata oluştu',
not_found: '404 not found', // UNTRANSLATED not_found: '404 not found', // UNTRANSLATED

View file

@ -12,7 +12,7 @@ const errors = {
required_field_missing_plural: '至少需要输入一个{{field}}', required_field_missing_plural: '至少需要输入一个{{field}}',
more_details: '查看详情', more_details: '查看详情',
username_pattern_error: '用户名只能包含英文字母、数字或下划线,且不以数字开头。', username_pattern_error: '用户名只能包含英文字母、数字或下划线,且不以数字开头。',
password_pattern_error: '密码应不少于 6 位', password_pattern_error: '密码至少需要 {{min}} 个字符,且必须包含字母、数字和符号。',
insecure_contexts: '不支持不安全的上下文(非 HTTPS。', insecure_contexts: '不支持不安全的上下文(非 HTTPS。',
unexpected_error: '发生未知错误', unexpected_error: '发生未知错误',
not_found: '404 not found', // UNTRANSLATED not_found: '404 not found', // UNTRANSLATED