diff --git a/packages/console/src/pages/Profile/containers/ChangePasswordModal/index.tsx b/packages/console/src/pages/Profile/containers/ChangePasswordModal/index.tsx index 1ed2cbb32..c812a336e 100644 --- a/packages/console/src/pages/Profile/containers/ChangePasswordModal/index.tsx +++ b/packages/console/src/pages/Profile/containers/ChangePasswordModal/index.tsx @@ -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 }) => { - await api.post(`me/password`, { json: { password: newPassword } }); - toast.success(t('profile.password_changed')); - reset(); - onClose(); + try { + await api.post(`me/password`, { json: { password: newPassword } }); + toast.success(t('profile.password_changed')); + 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); }} /> - + ); }; diff --git a/packages/console/src/pages/Profile/containers/VerificationCodeModal/index.tsx b/packages/console/src/pages/Profile/containers/VerificationCodeModal/index.tsx index 440a6841f..f71518eaf 100644 --- a/packages/console/src/pages/Profile/containers/VerificationCodeModal/index.tsx +++ b/packages/console/src/pages/Profile/containers/VerificationCodeModal/index.tsx @@ -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([]); @@ -74,14 +74,34 @@ const VerificationCodeModal = () => { navigate('../change-password', { state }); } } catch (error: unknown) { - if (error instanceof HTTPError) { - const logtoError = await error.response.json(); - 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)) { diff --git a/packages/console/src/pages/Profile/containers/VerifyPasswordModal/index.tsx b/packages/console/src/pages/Profile/containers/VerifyPasswordModal/index.tsx index 7e062dd43..997ada230 100644 --- a/packages/console/src/pages/Profile/containers/VerifyPasswordModal/index.tsx +++ b/packages/console/src/pages/Profile/containers/VerifyPasswordModal/index.tsx @@ -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({ 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 }) => { - await api.post(`me/password/verify`, { json: { password } }); - reset(); - navigate('../change-password', { state }); + 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} > { ]} /> - - - {t('profile.delete_account.description')} - { - setShowDeleteAccountModal(true); + {isCloud && ( + + + {t('profile.delete_account.description')} + { + setShowDeleteAccountModal(true); + }} + /> + + { + setShowDeleteAccountModal(false); }} /> - - { - setShowDeleteAccountModal(false); - }} - /> - + + )} )} diff --git a/packages/console/src/pages/Profile/utils.ts b/packages/console/src/pages/Profile/utils.ts index 1d5f90594..c8ed9cecf 100644 --- a/packages/console/src/pages/Profile/utils.ts +++ b/packages/console/src/pages/Profile/utils.ts @@ -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 +) => { + if (error instanceof HTTPError) { + const logtoError = await error.response.json(); + const { code, message } = logtoError; + + const handled = await exec?.(code, message); + + if (handled) { + return; + } + + if (error.response.status !== 401) { + toast.error(message); + + return; + } + } + throw error; +}; diff --git a/packages/console/src/scss/modal.module.scss b/packages/console/src/scss/modal.module.scss index 78e449300..3dcf28ac3 100644 --- a/packages/console/src/scss/modal.module.scss +++ b/packages/console/src/scss/modal.module.scss @@ -6,6 +6,7 @@ inset: 0; overflow-y: auto; padding: _.unit(12) 0; + z-index: 101; } .content { diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index bbeed25b8..b84e99e16 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -33,17 +33,17 @@ export const encryptUserPassword = async ( }; export const verifyUserPassword = async (user: Nullable, password: string): Promise => { - 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; }; diff --git a/packages/core/src/routes-me/verification-code.ts b/packages/core/src/routes-me/verification-code.ts index f2e5add15..a9c8e2e20 100644 --- a/packages/core/src/routes-me/verification-code.ts +++ b/packages/core/src/routes-me/verification-code.ts @@ -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( ) { const codeType = VerificationCodeType.Generic; const { - passcodes: { createPasscode, sendPasscode, verifyPasscode }, - } = tenant.libraries; + queries: { + users: { findUserById }, + }, + libraries: { + passcodes: { createPasscode, sendPasscode, verifyPasscode }, + 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( 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(); diff --git a/packages/phrases/src/locales/de/translation/admin-console/errors.ts b/packages/phrases/src/locales/de/translation/admin-console/errors.ts index 5bd0cc354..8a52c8a2b 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/errors.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/errors.ts @@ -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 diff --git a/packages/phrases/src/locales/en/translation/admin-console/errors.ts b/packages/phrases/src/locales/en/translation/admin-console/errors.ts index 640de6b40..9b8d6d7f3 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/errors.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/errors.ts @@ -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', diff --git a/packages/phrases/src/locales/fr/translation/admin-console/errors.ts b/packages/phrases/src/locales/fr/translation/admin-console/errors.ts index 44063f387..704da9b3e 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/errors.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/errors.ts @@ -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 diff --git a/packages/phrases/src/locales/ko/translation/admin-console/errors.ts b/packages/phrases/src/locales/ko/translation/admin-console/errors.ts index 219897fa0..0ee56e5a7 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/errors.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/errors.ts @@ -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 diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/errors.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/errors.ts index 965f4d219..3cde8f608 100644 --- a/packages/phrases/src/locales/pt-br/translation/admin-console/errors.ts +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/errors.ts @@ -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 diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/errors.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/errors.ts index d498a6b92..221a87073 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/errors.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/errors.ts @@ -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 diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/errors.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/errors.ts index 8405d599f..1adf7cec6 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/errors.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/errors.ts @@ -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 diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/errors.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/errors.ts index cd30be742..ea2aa2e2b 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/errors.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/errors.ts @@ -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