diff --git a/src/entities/account/ui/user-login-auth-info-wrap.tsx b/src/entities/account/ui/user-login-auth-info-wrap.tsx index a1baefb..7a04b63 100644 --- a/src/entities/account/ui/user-login-auth-info-wrap.tsx +++ b/src/entities/account/ui/user-login-auth-info-wrap.tsx @@ -9,6 +9,7 @@ import { useNavigate } from '@/shared/lib/hooks/use-navigate'; import { snackBar } from '@/shared/lib/toast'; import { checkGrant } from '@/shared/lib/check-grant'; import { showAlert } from '@/widgets/show-alert'; +import { maskEmail, maskPhoneNumber } from '@/shared/lib/masking'; export const UserLoginAuthInfoWrap = ({ mid, @@ -417,7 +418,7 @@ export const UserLoginAuthInfoWrap = ({
@@ -430,24 +431,35 @@ export const UserLoginAuthInfoWrap = ({ >
))} - {newEmails.map((email, index) => ( -
- handleNewEmailChange(index, e.target.value)} - readOnly={readOnlyEmails.has(index) || index !== editableEmailIndex} - /> - -
- ))} + {newEmails.map((email, index) => { + const isReadOnly = readOnlyEmails.has(index) || index !== editableEmailIndex; + const displayValue = isReadOnly && email ? maskEmail(email) : email; + + return ( +
+ handleNewEmailChange(index, e.target.value)} + onFocus={() => setEditableEmailIndex(index)} + onBlur={() => { + if (email && isValidEmail(email)) { + setEditableEmailIndex(-1); + } + }} + readOnly={isReadOnly} + /> + +
+ ); + })}
@@ -465,7 +477,7 @@ export const UserLoginAuthInfoWrap = ({
@@ -478,24 +490,35 @@ export const UserLoginAuthInfoWrap = ({ >
))} - {newPhones.map((phone, index) => ( -
- handleNewPhoneChange(index, e.target.value)} - readOnly={readOnlyPhones.has(index) || index !== editablePhoneIndex} - /> - -
- ))} + {newPhones.map((phone, index) => { + const isReadOnly = readOnlyPhones.has(index) || index !== editablePhoneIndex; + const displayValue = isReadOnly && phone ? maskPhoneNumber(phone) : phone; + + return ( +
+ handleNewPhoneChange(index, e.target.value)} + onFocus={() => setEditablePhoneIndex(index)} + onBlur={() => { + if (phone && isValidPhone(phone)) { + setEditablePhoneIndex(-1); + } + }} + readOnly={isReadOnly} + /> + +
+ ); + })}
{t('account.tabChangeResetNotice')}
diff --git a/src/entities/user/api/use-user-create-mutation.ts b/src/entities/user/api/use-user-create-mutation.ts index 58b78e3..1cdcbb7 100644 --- a/src/entities/user/api/use-user-create-mutation.ts +++ b/src/entities/user/api/use-user-create-mutation.ts @@ -37,6 +37,23 @@ export const userCreate = async (params: UserCreateParams): Promise(API_URL_USER.userCreate(), params, options); + + // 서버 응답이 성공이어도 response.data에 status: false가 있는지 확인 + const responseData = response.data as any; + if (responseData?.status === false && responseData?.error) { + return { + status: false, + error: { + root: responseData.error.root || 'USER_CREATE', + errKey: responseData.error.errKey || 'UNKNOWN_ERROR', + code: responseData.error.code || '500', + message: responseData.error.message || 'Unknown error', + timestamp: responseData.error.timestamp || new Date().toISOString(), + details: responseData.error.details || {} + } + }; + } + return { status: true, data: response.data }; } catch (error: any) { return { diff --git a/src/locales/en.json b/src/locales/en.json index c5e7a8a..3738521 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -327,6 +327,8 @@ "pleaseEnterId": "Please enter ID", "pleaseEnterPassword": "Please enter password", "pleaseEnter8OrMoreCharacters": "Please enter 8 or more characters", + "passwordFormatRequirement": "Please set a password with 8 to 20 characters including letters, numbers, and special characters.", + "passwordSecurityRequirement": "Password cannot contain consecutive numbers (3+ digits), repeated characters (2+ times), or same characters (3+ times).", "enterPassword": "Enter password", "reEnterPassword": "Re-enter password", "enterPhoneNumber": "Enter phone number", @@ -339,6 +341,7 @@ "userAddFailed": "Failed to add user.", "userInfoSavedSuccessfully": "User information saved successfully.", "userInfoSaveFailed": "Failed to save user information.", + "confirmPasswordChange": "Would you like to change your password?", "passwordChangedSuccessfully": "Password changed successfully.", "passwordChangeFailed": "Failed to change password.", "permissionSavedSuccessfully": "Permission saved successfully.", @@ -357,7 +360,26 @@ "menuPermissions": "Menu Permissions", "deleteUserConfirm": "Do you want to delete this user?", "deleteUserSuccess": "User deleted successfully.", - "deleteUserFailed": "Failed to delete user." + "deleteUserFailed": "Failed to delete user.", + "passwordNoSpaceAllowed": "Password cannot contain spaces.", + "passwordLengthRequirement": "Password must be 8-20 characters long.", + "passwordInvalidCharacter": "Password contains invalid characters.", + "passwordCombinationRequirement": "Password must include at least 2 of: letters+numbers, letters+special characters, numbers+special characters.", + "passwordNoRepeatingCharacters": "Password cannot contain the same character 3 times in a row.", + "passwordNoConsecutiveSequence": "Password cannot contain 3 or more consecutive characters or numbers.", + "passwordNoKeyboardSequence": "Password cannot contain 3 consecutive characters from keyboard layout.", + "errors": { + "invalidPassword": "Incorrect password.", + "updatePasswordFail": "Password update failed.", + "previousPassword": "Cannot use previously used password.", + "merchantInfoMatchPassword": "Password cannot match merchant information.", + "passwordLength": "Password length error", + "disallowedCharactersIncluded": "Contains disallowed characters", + "disallowedWhiteSpace": "Contains whitespace characters", + "notEnoughComplexity": "Password does not meet complexity requirements", + "repeatedCharacterSequence": "Consecutive character/number sequence error", + "commonPasswordDetected": "Password validation failed" + } }, "favorite": { "edit": "Edit", diff --git a/src/locales/ko.json b/src/locales/ko.json index 5815dce..5a37048 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -327,6 +327,8 @@ "pleaseEnterId": "ID를 입력해 주세요", "pleaseEnterPassword": "비밀번호를 입력해 주세요", "pleaseEnter8OrMoreCharacters": "8자리 이상 입력해 주세요", + "passwordFormatRequirement": "8자리 이상 20자리 이하의 영문,숫자,특수문자를 조합하여 설정해 주시기 바랍니다.", + "passwordSecurityRequirement": "패스워드는 3자리 이상의 연속된 숫자, 두 번이상 반복 문자열, 3자리 이상의 동일한 문자열을 사용할 수 없습니다.", "enterPassword": "비밀번호를 입력하세요", "reEnterPassword": "비밀번호를 다시 입력하세요", "enterPhoneNumber": "휴대폰 번호 입력", @@ -339,6 +341,7 @@ "userAddFailed": "사용자 추가에 실패했습니다.", "userInfoSavedSuccessfully": "사용자 정보가 성공적으로 저장되었습니다.", "userInfoSaveFailed": "사용자 정보 저장에 실패했습니다.", + "confirmPasswordChange": "비밀번호를 변경하시겠습니까?", "passwordChangedSuccessfully": "비밀번호가 성공적으로 변경되었습니다.", "passwordChangeFailed": "비밀번호 변경에 실패했습니다.", "permissionSavedSuccessfully": "권한이 성공적으로 저장되었습니다.", @@ -357,7 +360,26 @@ "menuPermissions": "메뉴별 권한 설정", "deleteUserConfirm": "사용자를 삭제하시겠습니까?", "deleteUserSuccess": "사용자 삭제를 성공했습니다.", - "deleteUserFailed": "사용자 삭제를 실패했습니다." + "deleteUserFailed": "사용자 삭제를 실패했습니다.", + "passwordNoSpaceAllowed": "비밀번호에 공백을 사용할 수 없습니다.", + "passwordLengthRequirement": "비밀번호는 8자 이상 20자 이하로 입력해 주세요.", + "passwordInvalidCharacter": "허용되지 않는 문자가 포함되어 있습니다.", + "passwordCombinationRequirement": "영문+숫자, 영문+특수문자, 숫자+특수문자 중 2가지 이상을 조합해 주세요.", + "passwordNoRepeatingCharacters": "동일한 문자를 3번 연속으로 사용할 수 없습니다.", + "passwordNoConsecutiveSequence": "연속된 문자 또는 숫자를 3자리 이상 사용할 수 없습니다.", + "passwordNoKeyboardSequence": "키보드 배열 순서로 3자리 연속 사용할 수 없습니다.", + "errors": { + "invalidPassword": "패스워드가 틀립니다.", + "updatePasswordFail": "패스워드 업데이트 실패입니다.", + "previousPassword": "기존 사용하던 패스워드는 사용할 수 없습니다.", + "merchantInfoMatchPassword": "패스워드는 가맹점 정보와 일치할 수 없습니다.", + "passwordLength": "비밀번호 길이 오류", + "disallowedCharactersIncluded": "입력 비허용 문자 포함", + "disallowedWhiteSpace": "공백 문자 포함", + "notEnoughComplexity": "패스워드 복잡도 요구사항 미충족", + "repeatedCharacterSequence": "연속 문자/숫자 사용 오류", + "commonPasswordDetected": "패스워드 검증 실패" + } }, "favorite": { "edit": "편집하기", diff --git a/src/pages/account/password/modify-cancel-password-page.tsx b/src/pages/account/password/modify-cancel-password-page.tsx index 811927b..a0e4f31 100644 --- a/src/pages/account/password/modify-cancel-password-page.tsx +++ b/src/pages/account/password/modify-cancel-password-page.tsx @@ -12,6 +12,9 @@ import { import { useUserChangeCancelPasswordMutation } from '@/entities/user/api/use-user-change-cancel-password-mutation'; import { useStore } from '@/shared/model/store'; import { snackBar } from '@/shared/lib/toast'; +import { overlay } from 'overlay-kit'; +import { Dialog } from '@/shared/ui/dialogs/dialog'; +import { validatePassword } from '@/shared/lib/password-validation'; export const PasswordModifyCancelPasswordPage = () => { const { t } = useTranslation(); @@ -25,6 +28,26 @@ export const PasswordModifyCancelPasswordPage = () => { const [mid, setMid] = useState((midItem.length > 0)? userMid: ''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + + // 허용된 문자만 입력 가능하도록 필터링 (영문, 숫자, 특수문자) + const allowedCharsRegex = /^[a-zA-Z0-9!@#$%^&*()\-_+=[\]{}|\\:;"'<>,.?/]*$/; + + const handlePasswordChange = (value: string) => { + if (allowedCharsRegex.test(value)) { + setPassword(value); + // 에러가 있었다면 초기화 + if (passwordError) { + setPasswordError(''); + } + } + }; + + const handleConfirmPasswordChange = (value: string) => { + if (allowedCharsRegex.test(value)) { + setConfirmPassword(value); + } + }; const changeCancelPasswordMutation = useUserChangeCancelPasswordMutation({ onSuccess: () => { @@ -36,7 +59,45 @@ export const PasswordModifyCancelPasswordPage = () => { navigate(PATHS.account.password.manage); }, onError: (error) => { - snackBar(error?.response?.data?.message || t('account.passwordChangeFailed')); + type ErrorDetail = { + errKey?: string; + message?: string; + }; + type ErrorResponse = { + error?: ErrorDetail; + errorCode?: string; + message?: string; + }; + + const responseData = error?.response?.data as ErrorResponse | undefined; + const errorCode = responseData?.error?.errKey || responseData?.errorCode; + + // 비밀번호 변경 제한 가맹점 그룹 에러 처리 + if (errorCode === 'RESTRICTED_MERCHANT_GID') { + const errorMessage = responseData?.error?.message || responseData?.message; + snackBar(errorMessage || t('account.passwordChangeRestricted')); + return; + } + + // 에러 코드별 메시지 매핑 + const errorMessageMap: Record = { + INVALID_PASSWORD: t('account.errors.invalidPassword'), + UPDATE_PASSWORD_FAIL: t('account.errors.updatePasswordFail'), + PREVIOUS_PASSWORD: t('account.errors.previousPassword'), + MERCHANT_INFO_MATCH_PASSWORD: t('account.errors.merchantInfoMatchPassword'), + PASSWORD_LENGHT: t('account.errors.passwordLength'), + DISALLOWED_CHARACTERS_INCLUDED: t('account.errors.disallowedCharactersIncluded'), + DISALLOWED_WHITE_SPACE: t('account.errors.disallowedWhiteSpace'), + NOT_ENOUGH_COMPLEXITY: t('account.errors.notEnoughComplexity'), + REPEATED_CHARACTER_SEQUENCE: t('account.errors.repeatedCharacterSequence'), + COMMON_PASSWORD_DETECTED: t('account.errors.commonPasswordDetected') + }; + + const errorMessage = errorCode && errorMessageMap[errorCode] + ? errorMessageMap[errorCode] + : t('account.passwordChangeFailed'); + + snackBar(errorMessage); } }); @@ -47,10 +108,16 @@ export const PasswordModifyCancelPasswordPage = () => { navigate(PATHS.account.password.manage); }); + // 비밀번호 blur 핸들러 + const handlePasswordBlur = () => { + const result = validatePassword(password, t); + setPasswordError(result.isValid ? '' : result.errorMessage); + }; + // 저장 버튼 활성화 조건 체크 const isFormValid = () => { return ( - password.length >= 8 && + validatePassword(password, t).isValid && confirmPassword.length >= 8 && password === confirmPassword ); @@ -60,9 +127,26 @@ export const PasswordModifyCancelPasswordPage = () => { const handleSave = () => { if (!isFormValid()) return; - changeCancelPasswordMutation.mutate({ - mid, - password: password + overlay.open(({ + isOpen, + close, + unmount + }) => { + return ( + { + changeCancelPasswordMutation.mutate({ + mid, + password: password + }); + }} + message={t('account.confirmPasswordChange')} + buttonLabel={[t('common.cancel'), t('common.confirm')]} + /> + ); }); }; @@ -93,13 +177,16 @@ export const PasswordModifyCancelPasswordPage = () => {
{t('account.newPassword')} *
setPassword(e.target.value)} + onChange={(e) => handlePasswordChange(e.target.value)} + onBlur={handlePasswordBlur} + maxLength={10} />
+ {passwordError &&
{passwordError}
}
{t('account.reEnterNewPassword')} *
{ type="password" placeholder={t('account.reEnterPassword')} value={confirmPassword} - onChange={(e) => setConfirmPassword(e.target.value)} + onChange={(e) => handleConfirmPasswordChange(e.target.value)} + maxLength={10} />
{confirmPassword && password !== confirmPassword && ( diff --git a/src/pages/account/password/modify-login-password-page.tsx b/src/pages/account/password/modify-login-password-page.tsx index 7580932..8ab5bd2 100644 --- a/src/pages/account/password/modify-login-password-page.tsx +++ b/src/pages/account/password/modify-login-password-page.tsx @@ -11,6 +11,9 @@ import { } from '@/widgets/sub-layout/use-sub-layout'; import { useUserChangePasswordMutation } from '@/entities/user/api/use-user-change-password-mutation'; import { snackBar } from '@/shared/lib/toast'; +import { overlay } from 'overlay-kit'; +import { Dialog } from '@/shared/ui/dialogs/dialog'; +import { validatePassword } from '@/shared/lib/password-validation'; export const PasswordModifyLoginPasswordPage = () => { const { t } = useTranslation(); @@ -19,6 +22,7 @@ export const PasswordModifyLoginPasswordPage = () => { const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [usrid, ] = useState('nictest00'); + const [passwordError, setPasswordError] = useState(''); const changePasswordMutation = useUserChangePasswordMutation({ onSuccess: () => { @@ -31,7 +35,38 @@ export const PasswordModifyLoginPasswordPage = () => { navigate(PATHS.account.password.manage); }, onError: (error) => { - snackBar(error?.response?.data?.message || t('account.passwordChangeFailed')); + type ErrorDetail = { + errKey?: string; + message?: string; + }; + type ErrorResponse = { + error?: ErrorDetail; + errorCode?: string; + message?: string; + }; + + const responseData = error?.response?.data as ErrorResponse | undefined; + const errorCode = responseData?.error?.errKey || responseData?.errorCode; + + // 에러 코드별 메시지 매핑 + const errorMessageMap: Record = { + INVALID_PASSWORD: t('account.errors.invalidPassword'), + UPDATE_PASSWORD_FAIL: t('account.errors.updatePasswordFail'), + PREVIOUS_PASSWORD: t('account.errors.previousPassword'), + MERCHANT_INFO_MATCH_PASSWORD: t('account.errors.merchantInfoMatchPassword'), + PASSWORD_LENGHT: t('account.errors.passwordLength'), + DISALLOWED_CHARACTERS_INCLUDED: t('account.errors.disallowedCharactersIncluded'), + DISALLOWED_WHITE_SPACE: t('account.errors.disallowedWhiteSpace'), + NOT_ENOUGH_COMPLEXITY: t('account.errors.notEnoughComplexity'), + REPEATED_CHARACTER_SEQUENCE: t('account.errors.repeatedCharacterSequence'), + COMMON_PASSWORD_DETECTED: t('account.errors.commonPasswordDetected') + }; + + const errorMessage = errorCode && errorMessageMap[errorCode] + ? errorMessageMap[errorCode] + : t('account.passwordChangeFailed'); + + snackBar(errorMessage); } }); @@ -42,11 +77,26 @@ export const PasswordModifyLoginPasswordPage = () => { navigate(PATHS.account.password.manage); }); + // 비밀번호 blur 핸들러 + const handlePasswordBlur = () => { + const result = validatePassword(newPassword, t); + setPasswordError(result.isValid ? '' : result.errorMessage); + }; + + // 비밀번호 변경 핸들러 + const handlePasswordChange = (value: string) => { + setNewPassword(value); + // 에러가 있었다면 초기화 + if (passwordError) { + setPasswordError(''); + } + }; + // 저장 버튼 활성화 조건 체크 const isFormValid = () => { return ( currentPassword.length >= 8 && - newPassword.length >= 8 && + validatePassword(newPassword, t).isValid && confirmPassword.length >= 8 && newPassword === confirmPassword ); @@ -56,11 +106,27 @@ export const PasswordModifyLoginPasswordPage = () => { const handleSave = () => { if (!isFormValid()) return; - // TODO: Validate current password before submitting - changePasswordMutation.mutate({ - usrid: usrid, - currentPassword: currentPassword, - newPassword: newPassword, + overlay.open(({ + isOpen, + close, + unmount + }) => { + return ( + { + changePasswordMutation.mutate({ + usrid: usrid, + currentPassword: currentPassword, + newPassword: newPassword, + }); + }} + message={t('account.confirmPasswordChange')} + buttonLabel={[t('common.cancel'), t('common.confirm')]} + /> + ); }); }; @@ -84,13 +150,15 @@ export const PasswordModifyLoginPasswordPage = () => {
{t('account.newPassword')} *
setNewPassword(e.target.value)} + onChange={(e) => handlePasswordChange(e.target.value)} + onBlur={handlePasswordBlur} />
+ {passwordError &&
{passwordError}
}
{t('account.reEnterNewPassword')} *
{ const { t } = useTranslation(); @@ -22,11 +24,36 @@ export const UserAddAccountPage = () => { const { mid } = location.state || {}; const { mutateAsync: userCreate, isPending } = useUserCreateMutation({ - onSuccess: () => { - snackBar(t('account.userAddedSuccessfully')); - }, onError: (error) => { - snackBar(error?.response?.data?.message || t('account.userAddFailed')); + type ErrorDetail = { + errKey?: string; + message?: string; + }; + type ErrorResponse = { + error?: ErrorDetail; + errorCode?: string; + message?: string; + }; + + const responseData = error?.response?.data as ErrorResponse | undefined; + const errorCode = responseData?.error?.errKey || responseData?.errorCode; + + // 에러 코드별 메시지 매핑 + const errorMessageMap: Record = { + MERCHANT_INFO_MATCH_PASSWORD: t('account.errors.merchantInfoMatchPassword'), + PASSWORD_LENGHT: t('account.errors.passwordLength'), + DISALLOWED_CHARACTERS_INCLUDED: t('account.errors.disallowedCharactersIncluded'), + DISALLOWED_WHITE_SPACE: t('account.errors.disallowedWhiteSpace'), + NOT_ENOUGH_COMPLEXITY: t('account.errors.notEnoughComplexity'), + REPEATED_CHARACTER_SEQUENCE: t('account.errors.repeatedCharacterSequence'), + COMMON_PASSWORD_DETECTED: t('account.errors.commonPasswordDetected') + }; + + const errorMessage = errorCode && errorMessageMap[errorCode] + ? errorMessageMap[errorCode] + : (error?.response?.data?.message || t('account.userAddFailed')); + + snackBar(errorMessage); } }); @@ -158,22 +185,15 @@ export const UserAddAccountPage = () => { setNewPhones(updated); }; - // 비밀번호 검증 함수 - const validatePassword = (password: string) => { - if (!password.trim()) { - return t('account.pleaseEnterPassword'); - } else if (password.length < 8) { - return t('account.pleaseEnter8OrMoreCharacters'); - } - return ''; - }; // 폼 입력 핸들러 const handleInputChange = (field: string, value: string) => { - setFormData(prev => ({ ...prev, [field]: value })); - - // 사용자 ID의 경우 디바운스 적용하여 자동 검증 + // 사용자 ID의 경우 영문과 숫자만 허용 if (field === 'usrid') { + // 영문과 숫자가 아닌 문자 제거 + const filteredValue = value.replace(/[^a-zA-Z0-9]/g, ''); + setFormData(prev => ({ ...prev, [field]: filteredValue })); + // 기존 타이머 클리어 if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); @@ -183,7 +203,7 @@ export const UserAddAccountPage = () => { setErrors(prev => ({ ...prev, usrid: '' })); // 값이 있으면 500ms 후 검증 실행 - if (value.trim().length > 0) { + if (filteredValue.trim().length > 0) { setIsCheckingUsrid(true); debounceTimeout.current = setTimeout(() => { setShouldCheckUsrid(true); @@ -192,6 +212,7 @@ export const UserAddAccountPage = () => { setIsCheckingUsrid(false); } } else { + setFormData(prev => ({ ...prev, [field]: value })); // 다른 필드는 에러만 초기화 if (errors[field as keyof typeof errors]) { setErrors(prev => ({ ...prev, [field]: '' })); @@ -201,10 +222,11 @@ export const UserAddAccountPage = () => { // 비밀번호 blur 핸들러 const handlePasswordBlur = () => { - const passwordError = validatePassword(formData.password); - if (passwordError) { - setErrors(prev => ({ ...prev, password: passwordError })); - } + const result = validatePassword(formData.password, t); + setErrors(prev => ({ + ...prev, + password: result.isValid ? '' : result.errorMessage + })); }; // 컴포넌트 언마운트 시 타이머 정리 @@ -281,8 +303,9 @@ export const UserAddAccountPage = () => { if (errors.usrid) return false; if (isCheckingUsrid || isUserExistsLoading) return false; - // 2. 비밀번호가 8자리 이상 - if (!formData.password.trim() || formData.password.length < 8) return false; + // 2. 비밀번호 검증 + if (!formData.password.trim()) return false; + if (!validatePassword(formData.password, t).isValid) return false; // 3. 입력된 모든 이메일과 휴대폰 번호가 유효한 형식이어야 함 const nonEmptyEmails = newEmails.filter(email => email.trim()); @@ -324,11 +347,9 @@ export const UserAddAccountPage = () => { } // 비밀번호 검증 - if (!formData.password.trim()) { - newErrors.password = t('account.pleaseEnterPassword'); - isValid = false; - } else if (formData.password.length < 8) { - newErrors.password = t('account.pleaseEnter8OrMoreCharacters'); + const passwordResult = validatePassword(formData.password, t); + if (!passwordResult.isValid) { + newErrors.password = passwordResult.errorMessage; isValid = false; } @@ -382,21 +403,57 @@ export const UserAddAccountPage = () => { const response = await userCreate(request); - if (response.status) { + if (response?.status) { // 성공 시 사용자 관리 페이지로 이동 snackBar(t('account.userAddedSuccessfully')); navigate(PATHS.account.user.manage); - } else if (response.error) { + } else if (response?.error) { // 에러 처리 - if (response.error.errKey === 'USER_DUPLICATE') { + const error = response.error; + + // 에러 코드별 메시지 매핑 + const errorMessageMap: Record = { + MERCHANT_INFO_MATCH_PASSWORD: t('account.errors.merchantInfoMatchPassword'), + PASSWORD_LENGHT: t('account.errors.passwordLength'), + DISALLOWED_CHARACTERS_INCLUDED: t('account.errors.disallowedCharactersIncluded'), + DISALLOWED_WHITE_SPACE: t('account.errors.disallowedWhiteSpace'), + NOT_ENOUGH_COMPLEXITY: t('account.errors.notEnoughComplexity'), + REPEATED_CHARACTER_SEQUENCE: t('account.errors.repeatedCharacterSequence'), + COMMON_PASSWORD_DETECTED: t('account.errors.commonPasswordDetected') + }; + + if (error.errKey === 'USER_DUPLICATE') { setErrors(prev => ({ ...prev, usrid: t('account.duplicateIdExists') })); + } else if (error.errKey && errorMessageMap[error.errKey]) { + // 비밀번호 관련 에러는 password 필드에 표시 + const errorMsg = errorMessageMap[error.errKey]; + if (errorMsg) { + setErrors(prev => ({ ...prev, password: errorMsg })); + } + } else if (error.errKey === 'VALIDATION_ERROR') { + // 검증 에러 처리 + const details = error.details as Record; + const validationErrors = details?.validationErrors as Record | undefined; + if (validationErrors) { + // 필드별 에러 메시지 설정 + const newErrors = { ...errors }; + if (validationErrors.passwordField) { + newErrors.password = validationErrors.passwordField; + } + if (validationErrors.usridField) { + newErrors.usrid = validationErrors.usridField; + } + setErrors(newErrors); + } + snackBar(error.message || t('account.userAddFailed')); } else { // 기타 에러 처리 - console.error('User creation failed:', response.error.message); + snackBar(error.message || t('account.userAddFailed')); } } } catch (error) { console.error('User creation error:', error); + // catch된 에러는 onError에서 처리됨 } }; @@ -486,25 +543,36 @@ export const UserAddAccountPage = () => { disabled={!isEmailAddButtonEnabled()} >
- {newEmails.map((email, index) => ( -
- handleNewEmailChange(index, e.target.value)} - readOnly={readOnlyEmails.has(index) || index !== editableEmailIndex} - /> - -
- ))} + {newEmails.map((email, index) => { + const isReadOnly = readOnlyEmails.has(index) || index !== editableEmailIndex; + const displayValue = isReadOnly && email ? maskEmail(email) : email; + + return ( +
+ handleNewEmailChange(index, e.target.value)} + onFocus={() => setEditableEmailIndex(index)} + onBlur={() => { + if (email && isValidEmail(email)) { + setEditableEmailIndex(-1); + } + }} + readOnly={isReadOnly} + /> + +
+ ); + })}
@@ -518,25 +586,36 @@ export const UserAddAccountPage = () => { disabled={!isPhoneAddButtonEnabled()} >
- {newPhones.map((phone, index) => ( -
- handleNewPhoneChange(index, e.target.value)} - readOnly={readOnlyPhones.has(index) || index !== editablePhoneIndex} - /> - -
- ))} + {newPhones.map((phone, index) => { + const isReadOnly = readOnlyPhones.has(index) || index !== editablePhoneIndex; + const displayValue = isReadOnly && phone ? maskPhoneNumber(phone) : phone; + + return ( +
+ handleNewPhoneChange(index, e.target.value)} + onFocus={() => setEditablePhoneIndex(index)} + onBlur={() => { + if (phone && isValidPhone(phone)) { + setEditablePhoneIndex(-1); + } + }} + readOnly={isReadOnly} + /> + +
+ ); + })} diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index a1fb8c7..03f9fff 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,3 +1,4 @@ export * from './error'; export * from './toast'; -export * from './web-view-bridge'; \ No newline at end of file +export * from './web-view-bridge'; +export * from './masking'; \ No newline at end of file diff --git a/src/shared/lib/masking.ts b/src/shared/lib/masking.ts new file mode 100644 index 0000000..e88c015 --- /dev/null +++ b/src/shared/lib/masking.ts @@ -0,0 +1,41 @@ +/** + * 전화번호 마스킹 함수 + * @param phoneNumber - 마스킹할 전화번호 (예: "01012345678") + * @returns 마스킹된 전화번호 (예: "0101234****") + */ +export const maskPhoneNumber = (phoneNumber: string): string => { + const cleaned = phoneNumber.replace(/[^0-9]/g, ''); + + if (cleaned.length === 11 && cleaned.startsWith('010')) { + const prefix = cleaned.substring(0, 7); + return `${prefix}****`; + } + + return phoneNumber; +}; + +/** + * 이메일 마스킹 함수 + * @param email - 마스킹할 이메일 (예: "hanbyeol@gmail.com") + * @returns 마스킹된 이메일 (예: "ha******@gmail.com") + */ +export const maskEmail = (email: string): string => { + const atIndex = email.indexOf('@'); + + if (atIndex === -1) { + return email; + } + + const localPart = email.substring(0, atIndex); + const domainPart = email.substring(atIndex); + + if (localPart.length <= 2) { + return email; + } + + const visiblePart = localPart.substring(0, 2); + const maskedCount = localPart.length - 2; + const maskedPart = '*'.repeat(maskedCount); + + return `${visiblePart}${maskedPart}${domainPart}`; +}; diff --git a/src/shared/lib/password-validation.ts b/src/shared/lib/password-validation.ts new file mode 100644 index 0000000..0948abe --- /dev/null +++ b/src/shared/lib/password-validation.ts @@ -0,0 +1,259 @@ +/** + * 비밀번호 검증 함수 + */ + +// 허용되는 문자 목록 +const ALLOWED_CHARS = new Set([ + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', + 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', + 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', + 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '!', '@', + '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '+', '=', '[', ']', '{', '}', + '|', '\\', ':', ';', '"', '\'', '<', '>', ',', '.', '?', '/' +]); + +// 키보드 배열 순서 (연속 3자리 검증용) +const KEYBOARD_SEQUENCES = [ + // 일반 키 + '1234567890-=', + 'qwertyuiop[]\\', + 'asdfghjkl;\'', + 'zxcvbnm,./', + // 대문자 (shift + 문자) + 'QWERTYUIOP', + 'ASDFGHJKL', + 'ZXCVBNM', + // shift 누른 특수문자 + '!@#$%^&*()_+', + '{}|', + ':"', + '<>?' +]; + +// Shift 키 매핑 (shift 누른 문자 -> 원래 문자) +const SHIFT_MAP: { [key: string]: string } = { + // 숫자열 + '!': '1', '@': '2', '#': '3', '$': '4', '%': '5', + '^': '6', '&': '7', '*': '8', '(': '9', ')': '0', + '_': '-', '+': '=', + // 2번째 줄 + '{': '[', '}': ']', '|': '\\', + // 3번째 줄 + ':': ';', '"': '\'', + // 4번째 줄 + '<': ',', '>': '.', '?': '/' +}; + +// 문자를 shift 미적용 버전으로 변환 +const unshiftChar = (char: string): string => { + return SHIFT_MAP[char] || char; +}; + +export interface PasswordValidationResult { + isValid: boolean; + errorMessage: string; +} + +/** + * 비밀번호 검증 함수 + * @param password - 검증할 비밀번호 + * @param t - 번역 함수 + * @returns 검증 결과 객체 + */ +export const validatePassword = ( + password: string, + t: (key: string) => string +): PasswordValidationResult => { + // 1. 공백 검증 + if (!password || !password.trim() || /\s/.test(password)) { + return { + isValid: false, + errorMessage: t('account.passwordNoSpaceAllowed') + }; + } + + // 2. 길이 검증 (8-20자) + if (password.length < 8 || password.length > 20) { + return { + isValid: false, + errorMessage: t('account.passwordLengthRequirement') + }; + } + + // 3. 허용된 문자만 사용했는지 검증 + for (const char of password) { + if (!ALLOWED_CHARS.has(char)) { + return { + isValid: false, + errorMessage: t('account.passwordInvalidCharacter') + }; + } + } + + // 4. 문자 조합 검증: 영문+숫자, 영문+특수문자, 숫자+특수문자 중 2가지 이상 + const hasLetter = /[a-zA-Z]/.test(password); + const hasNumber = /\d/.test(password); + const hasSpecial = /[!@#$%^&*()\-_+=[\]{}|\\:;"'<>,.?/]/.test(password); + + const combinationCount = [hasLetter, hasNumber, hasSpecial].filter(Boolean).length; + + if (combinationCount < 2) { + return { + isValid: false, + errorMessage: t('account.passwordCombinationRequirement') + }; + } + + // 5. 동일 문자 3번 연속 금지 + for (let i = 0; i < password.length - 2; i++) { + if (password[i] === password[i + 1] && password[i] === password[i + 2]) { + return { + isValid: false, + errorMessage: t('account.passwordNoRepeatingCharacters') + }; + } + } + + // 5-1. 연속된 알파벳/숫자 3자리 금지 (abc, 123, xyz, 987 등, 대소문자/shift 문자 포함) + for (let i = 0; i < password.length - 2; i++) { + const char1 = password[i]; + const char2 = password[i + 1]; + const char3 = password[i + 2]; + + if (!char1 || !char2 || !char3) continue; + + // 1) 원본 문자 검사 + const code1 = char1.charCodeAt(0); + const code2 = char2.charCodeAt(0); + const code3 = char3.charCodeAt(0); + + // 연속 증가하는 패턴 (abc, 123) + if (code2 === code1 + 1 && code3 === code2 + 1) { + return { + isValid: false, + errorMessage: t('account.passwordNoConsecutiveSequence') + }; + } + + // 연속 감소하는 패턴 (cba, 321) + if (code2 === code1 - 1 && code3 === code2 - 1) { + return { + isValid: false, + errorMessage: t('account.passwordNoConsecutiveSequence') + }; + } + + // 2) 대소문자 구분 없이 검사 (예: dFG -> dfg) + const lower1 = char1.toLowerCase(); + const lower2 = char2.toLowerCase(); + const lower3 = char3.toLowerCase(); + + const lowerCode1 = lower1.charCodeAt(0); + const lowerCode2 = lower2.charCodeAt(0); + const lowerCode3 = lower3.charCodeAt(0); + + // 소문자 버전에서 연속 증가하는 패턴 + if (lowerCode2 === lowerCode1 + 1 && lowerCode3 === lowerCode2 + 1) { + return { + isValid: false, + errorMessage: t('account.passwordNoConsecutiveSequence') + }; + } + + // 소문자 버전에서 연속 감소하는 패턴 + if (lowerCode2 === lowerCode1 - 1 && lowerCode3 === lowerCode2 - 1) { + return { + isValid: false, + errorMessage: t('account.passwordNoConsecutiveSequence') + }; + } + + // 3) shift 미적용 버전 검사 (예: !2# -> 123) + const unshifted1 = unshiftChar(char1); + const unshifted2 = unshiftChar(char2); + const unshifted3 = unshiftChar(char3); + + const unshiftedCode1 = unshifted1.charCodeAt(0); + const unshiftedCode2 = unshifted2.charCodeAt(0); + const unshiftedCode3 = unshifted3.charCodeAt(0); + + // shift 미적용 버전에서 연속 증가하는 패턴 + if (unshiftedCode2 === unshiftedCode1 + 1 && unshiftedCode3 === unshiftedCode2 + 1) { + return { + isValid: false, + errorMessage: t('account.passwordNoConsecutiveSequence') + }; + } + + // shift 미적용 버전에서 연속 감소하는 패턴 + if (unshiftedCode2 === unshiftedCode1 - 1 && unshiftedCode3 === unshiftedCode2 - 1) { + return { + isValid: false, + errorMessage: t('account.passwordNoConsecutiveSequence') + }; + } + + // 4) shift 미적용 + 소문자 버전 검사 (예: !@C -> 123 -> abc 형태도 체크) + const unshiftedLower1 = unshiftChar(lower1); + const unshiftedLower2 = unshiftChar(lower2); + const unshiftedLower3 = unshiftChar(lower3); + + const unshiftedLowerCode1 = unshiftedLower1.charCodeAt(0); + const unshiftedLowerCode2 = unshiftedLower2.charCodeAt(0); + const unshiftedLowerCode3 = unshiftedLower3.charCodeAt(0); + + // shift 미적용 + 소문자에서 연속 증가하는 패턴 + if (unshiftedLowerCode2 === unshiftedLowerCode1 + 1 && unshiftedLowerCode3 === unshiftedLowerCode2 + 1) { + return { + isValid: false, + errorMessage: t('account.passwordNoConsecutiveSequence') + }; + } + + // shift 미적용 + 소문자에서 연속 감소하는 패턴 + if (unshiftedLowerCode2 === unshiftedLowerCode1 - 1 && unshiftedLowerCode3 === unshiftedLowerCode2 - 1) { + return { + isValid: false, + errorMessage: t('account.passwordNoConsecutiveSequence') + }; + } + } + + // 6. 키보드 순서 3자리 연속 금지 (대소문자 구분 없이 검사, shift 문자 포함) + for (let i = 0; i < password.length - 2; i++) { + const threeChars = password.substring(i, i + 3); + + // 원본 문자 (소문자) + const threeCharsLower = threeChars.toLowerCase(); + const threeCharsReversedLower = threeChars.split('').reverse().join('').toLowerCase(); + + // shift 미적용 버전 (소문자) + const threeCharsUnshifted = threeChars.split('').map(unshiftChar).join('').toLowerCase(); + const threeCharsUnshiftedReversed = threeChars.split('').reverse().map(unshiftChar).join('').toLowerCase(); + + for (const sequence of KEYBOARD_SEQUENCES) { + const sequenceLower = sequence.toLowerCase(); + + // 원본 문자 검사 + if (sequenceLower.includes(threeCharsLower) || sequenceLower.includes(threeCharsReversedLower)) { + return { + isValid: false, + errorMessage: t('account.passwordNoKeyboardSequence') + }; + } + + // shift 미적용 버전 검사 (예: !@# -> 123) + if (sequenceLower.includes(threeCharsUnshifted) || sequenceLower.includes(threeCharsUnshiftedReversed)) { + return { + isValid: false, + errorMessage: t('account.passwordNoKeyboardSequence') + }; + } + } + } + + return { + isValid: true, + errorMessage: '' + }; +};