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 { 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>
);
};

View file

@ -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)) {

View file

@ -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={

View file

@ -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>

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 = {
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;
};

View file

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

View file

@ -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;
};

View file

@ -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();

View file

@ -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

View file

@ -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',

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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