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:
parent
43470c41f1
commit
01735d1647
16 changed files with 190 additions and 61 deletions
|
@ -1,3 +1,4 @@
|
|||
import { passwordRegEx } from '@logto/core-kit';
|
||||
import type { KeyboardEventHandler } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
@ -12,8 +13,10 @@ import IconButton from '@/components/IconButton';
|
|||
import TextInput from '@/components/TextInput';
|
||||
import { adminTenantEndpoint, meApi } from '@/consts';
|
||||
import { useStaticApi } from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
|
||||
import MainFlowLikeModal from '../../components/MainFlowLikeModal';
|
||||
import { handleError } from '../../utils';
|
||||
|
||||
type FormFields = {
|
||||
newPassword: string;
|
||||
|
@ -30,6 +33,7 @@ const defaultValues: FormFields = {
|
|||
const ChangePasswordModal = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const navigate = useNavigate();
|
||||
const { show: showModal } = useConfirmModal();
|
||||
const { state } = useLocation();
|
||||
const {
|
||||
watch,
|
||||
|
@ -44,19 +48,38 @@ const ChangePasswordModal = () => {
|
|||
defaultValues,
|
||||
});
|
||||
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 = () => {
|
||||
reset();
|
||||
navigate('/profile');
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
clearErrors();
|
||||
void handleSubmit(async ({ newPassword }) => {
|
||||
try {
|
||||
await api.post(`me/password`, { json: { password: newPassword } });
|
||||
toast.success(t('profile.password_changed'));
|
||||
reset();
|
||||
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', {
|
||||
required: t('profile.password.required'),
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: t('profile.password.min_length', { min: 6 }),
|
||||
value: 8,
|
||||
message: t('profile.password.min_length', { min: 8 }),
|
||||
},
|
||||
pattern: {
|
||||
value: passwordRegEx,
|
||||
message: t('errors.password_pattern_error'),
|
||||
},
|
||||
})}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
|
@ -123,7 +150,13 @@ const ChangePasswordModal = () => {
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import type { RequestErrorBody } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { HTTPError } from 'ky';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -12,10 +10,11 @@ import TextLink from '@/components/TextLink';
|
|||
import VerificationCodeInput, { defaultLength } from '@/components/VerificationCodeInput';
|
||||
import { adminTenantEndpoint, meApi } from '@/consts';
|
||||
import { useStaticApi } from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import useCurrentUser from '@/hooks/use-current-user';
|
||||
|
||||
import MainFlowLikeModal from '../../components/MainFlowLikeModal';
|
||||
import { checkLocationState } from '../../utils';
|
||||
import { checkLocationState, handleError } from '../../utils';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export const resendTimeout = 59;
|
||||
|
@ -30,6 +29,7 @@ const getTimeout = () => {
|
|||
const VerificationCodeModal = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const navigate = useNavigate();
|
||||
const { show: showModal } = useConfirmModal();
|
||||
const { state } = useLocation();
|
||||
const { reload } = useCurrentUser();
|
||||
const [code, setCode] = useState<string[]>([]);
|
||||
|
@ -74,14 +74,34 @@ const VerificationCodeModal = () => {
|
|||
navigate('../change-password', { state });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof HTTPError) {
|
||||
const logtoError = await error.response.json<RequestErrorBody>();
|
||||
setError(logtoError.message);
|
||||
} else {
|
||||
setError(String(error));
|
||||
void handleError(error, async (code, message) => {
|
||||
// The following errors will be displayed as inline error message.
|
||||
if (
|
||||
[
|
||||
'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(() => {
|
||||
if (code.length === defaultLength && code.every(Boolean)) {
|
||||
|
|
|
@ -15,7 +15,7 @@ import { adminTenantEndpoint, meApi } from '@/consts';
|
|||
import { useStaticApi } from '@/hooks/use-api';
|
||||
|
||||
import MainFlowLikeModal from '../../components/MainFlowLikeModal';
|
||||
import { checkLocationState } from '../../utils';
|
||||
import { checkLocationState, handleError } from '../../utils';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type FormFields = {
|
||||
|
@ -30,12 +30,17 @@ const VerifyPasswordModal = () => {
|
|||
register,
|
||||
reset,
|
||||
clearErrors,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormFields>({
|
||||
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 email = conditional(checkLocationState(state) && state.email);
|
||||
|
||||
|
@ -46,9 +51,19 @@ const VerifyPasswordModal = () => {
|
|||
const onSubmit = () => {
|
||||
clearErrors();
|
||||
void handleSubmit(async ({ password }) => {
|
||||
try {
|
||||
await api.post(`me/password/verify`, { json: { password } });
|
||||
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}
|
||||
>
|
||||
<TextInput
|
||||
{...register('password', {
|
||||
required: t('profile.password.required'),
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: t('profile.password.min_length', { min: 6 }),
|
||||
},
|
||||
})}
|
||||
{...register('password', { required: t('profile.password.required') })}
|
||||
errorMessage={errors.password?.message}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
suffix={
|
||||
|
|
|
@ -7,6 +7,7 @@ import Button from '@/components/Button';
|
|||
import CardTitle from '@/components/CardTitle';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import { adminTenantEndpoint, meApi } from '@/consts';
|
||||
import { isCloud } from '@/consts/cloud';
|
||||
import { useStaticApi } from '@/hooks/use-api';
|
||||
import useCurrentUser from '@/hooks/use-current-user';
|
||||
import * as resourcesStyles from '@/scss/resources.module.scss';
|
||||
|
@ -74,6 +75,7 @@ const Profile = () => {
|
|||
]}
|
||||
/>
|
||||
</FormCard>
|
||||
{isCloud && (
|
||||
<FormCard title="profile.delete_account.title">
|
||||
<div className={styles.deleteAccount}>
|
||||
<div className={styles.description}>{t('profile.delete_account.description')}</div>
|
||||
|
@ -91,6 +93,7 @@ const Profile = () => {
|
|||
}}
|
||||
/>
|
||||
</FormCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
import type { RequestErrorBody } from '@logto/schemas';
|
||||
import { HTTPError } from 'ky';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export type LocationState = {
|
||||
email: string;
|
||||
action: 'changePassword' | 'changeEmail';
|
||||
|
@ -39,3 +43,26 @@ export const popupWindow = (url: string, windowName: string, width: number, heig
|
|||
].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;
|
||||
};
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
inset: 0;
|
||||
overflow-y: auto;
|
||||
padding: _.unit(12) 0;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
|
|
@ -33,17 +33,17 @@ export const encryptUserPassword = async (
|
|||
};
|
||||
|
||||
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;
|
||||
|
||||
assertThat(
|
||||
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 });
|
||||
|
||||
assertThat(result, new RequestError({ code: 'session.invalid_credentials', status: 401 }));
|
||||
assertThat(result, new RequestError({ code: 'session.invalid_credentials', status: 422 }));
|
||||
|
||||
return user;
|
||||
};
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import { VerificationCodeType } from '@logto/connector-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 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';
|
||||
|
||||
|
@ -12,13 +15,20 @@ export default function verificationCodeRoutes<T extends AuthedMeRouter>(
|
|||
) {
|
||||
const codeType = VerificationCodeType.Generic;
|
||||
const {
|
||||
queries: {
|
||||
users: { findUserById },
|
||||
},
|
||||
libraries: {
|
||||
passcodes: { createPasscode, sendPasscode, verifyPasscode },
|
||||
} = tenant.libraries;
|
||||
verificationStatuses: { createVerificationStatus },
|
||||
},
|
||||
} = tenant;
|
||||
|
||||
router.post(
|
||||
'/verification-codes',
|
||||
koaGuard({
|
||||
body: object({ email: string().regex(emailRegEx) }),
|
||||
status: 204,
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const code = await createPasscode(undefined, codeType, ctx.guard.body);
|
||||
|
@ -33,12 +43,31 @@ export default function verificationCodeRoutes<T extends AuthedMeRouter>(
|
|||
router.post(
|
||||
'/verification-codes/verify',
|
||||
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) => {
|
||||
const { verificationCode, ...identifier } = ctx.guard.body;
|
||||
const { id: userId } = ctx.auth;
|
||||
const { verificationCode, action, ...identifier } = ctx.guard.body;
|
||||
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;
|
||||
|
||||
return next();
|
||||
|
|
|
@ -13,7 +13,8 @@ const errors = {
|
|||
more_details: 'Mehr Details',
|
||||
username_pattern_error:
|
||||
'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.',
|
||||
unexpected_error: 'Ein unerwarteter Fehler ist aufgetreten',
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
|
|
|
@ -13,7 +13,8 @@ const errors = {
|
|||
more_details: 'More details',
|
||||
username_pattern_error:
|
||||
'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.',
|
||||
unexpected_error: 'An unexpected error occurred',
|
||||
not_found: '404 not found',
|
||||
|
|
|
@ -13,7 +13,8 @@ const errors = {
|
|||
more_details: 'Plus de détails',
|
||||
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.",
|
||||
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.',
|
||||
unexpected_error: "Une erreur inattendue s'est produite",
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
|
|
|
@ -13,7 +13,8 @@ const errors = {
|
|||
more_details: '자세히',
|
||||
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)는 지원하지 않아요.',
|
||||
unexpected_error: '알 수 없는 오류가 발생했어요.',
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
|
|
|
@ -13,7 +13,8 @@ const errors = {
|
|||
more_details: 'Mais detalhes',
|
||||
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.',
|
||||
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.',
|
||||
unexpected_error: 'Um erro inesperado ocorreu',
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
|
|
|
@ -13,7 +13,8 @@ const errors = {
|
|||
more_details: 'Mais detalhes',
|
||||
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.',
|
||||
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.',
|
||||
unexpected_error: 'Um erro inesperado ocorreu',
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
|
|
|
@ -13,7 +13,8 @@ const errors = {
|
|||
more_details: 'Daha çok detay',
|
||||
username_pattern_error:
|
||||
'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.',
|
||||
unexpected_error: 'Beklenmedik bir hata oluştu',
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
|
|
|
@ -12,7 +12,7 @@ const errors = {
|
|||
required_field_missing_plural: '至少需要输入一个{{field}}',
|
||||
more_details: '查看详情',
|
||||
username_pattern_error: '用户名只能包含英文字母、数字或下划线,且不以数字开头。',
|
||||
password_pattern_error: '密码应不少于 6 位',
|
||||
password_pattern_error: '密码至少需要 {{min}} 个字符,且必须包含字母、数字和符号。',
|
||||
insecure_contexts: '不支持不安全的上下文(非 HTTPS)。',
|
||||
unexpected_error: '发生未知错误',
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
|
|
Loading…
Reference in a new issue