비밀번호 변경 기능 개선 및 검증 강화

- 비밀번호 변경 페이지에 확인 Dialog 추가 (로그인/거래취소)
- 비밀번호 에러 코드별 상세 메시지 처리
  * INVALID_PASSWORD, UPDATE_PASSWORD_FAIL, PREVIOUS_PASSWORD
  * MERCHANT_INFO_MATCH_PASSWORD, PASSWORD_LENGHT
  * DISALLOWED_CHARACTERS_INCLUDED, DISALLOWED_WHITE_SPACE
  * NOT_ENOUGH_COMPLEXITY, REPEATED_CHARACTER_SEQUENCE
  * COMMON_PASSWORD_DETECTED
- 비밀번호 입력 검증 로직 통합 (validatePassword)
- 이메일/전화번호 마스킹 기능 추가
- 사용자 추가 페이지 에러 처리 개선
- 다국어 메시지 추가 (한국어/영어)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jay Sheen
2025-11-17 20:50:53 +09:00
parent 522ccf7464
commit 2d1dd6f9e7
10 changed files with 748 additions and 128 deletions

View File

@@ -9,6 +9,7 @@ import { useNavigate } from '@/shared/lib/hooks/use-navigate';
import { snackBar } from '@/shared/lib/toast'; import { snackBar } from '@/shared/lib/toast';
import { checkGrant } from '@/shared/lib/check-grant'; import { checkGrant } from '@/shared/lib/check-grant';
import { showAlert } from '@/widgets/show-alert'; import { showAlert } from '@/widgets/show-alert';
import { maskEmail, maskPhoneNumber } from '@/shared/lib/masking';
export const UserLoginAuthInfoWrap = ({ export const UserLoginAuthInfoWrap = ({
mid, mid,
@@ -417,7 +418,7 @@ export const UserLoginAuthInfoWrap = ({
<div className="input-row" key={`existing-email-${index}`}> <div className="input-row" key={`existing-email-${index}`}>
<input <input
type="email" type="email"
value={email.content} value={maskEmail(email.content)}
placeholder="example@domain.com" placeholder="example@domain.com"
readOnly readOnly
/> />
@@ -430,24 +431,35 @@ export const UserLoginAuthInfoWrap = ({
></button> ></button>
</div> </div>
))} ))}
{newEmails.map((email, index) => ( {newEmails.map((email, index) => {
<div className="input-row" key={`new-email-${index}`}> const isReadOnly = readOnlyEmails.has(index) || index !== editableEmailIndex;
<input const displayValue = isReadOnly && email ? maskEmail(email) : email;
type="email"
value={email} return (
placeholder="example@domain.com" <div className="input-row" key={`new-email-${index}`}>
onChange={(e) => handleNewEmailChange(index, e.target.value)} <input
readOnly={readOnlyEmails.has(index) || index !== editableEmailIndex} type="email"
/> value={displayValue}
<button placeholder="example@domain.com"
className="icon-btn minus" onChange={(e) => handleNewEmailChange(index, e.target.value)}
type="button" onFocus={() => setEditableEmailIndex(index)}
aria-label={t('common.delete')} onBlur={() => {
onClick={() => handleRemoveNewEmail(index)} if (email && isValidEmail(email)) {
disabled={!isDeleteButtonEnabled()} setEditableEmailIndex(-1);
></button> }
</div> }}
))} readOnly={isReadOnly}
/>
<button
className="icon-btn minus"
type="button"
aria-label={t('common.delete')}
onClick={() => handleRemoveNewEmail(index)}
disabled={!isDeleteButtonEnabled()}
></button>
</div>
);
})}
</div> </div>
<div className="group"> <div className="group">
@@ -465,7 +477,7 @@ export const UserLoginAuthInfoWrap = ({
<div className="input-row" key={`existing-phone-${index}`}> <div className="input-row" key={`existing-phone-${index}`}>
<input <input
type="tel" type="tel"
value={phone.content} value={maskPhoneNumber(phone.content)}
placeholder={t('account.enterPhoneNumber')} placeholder={t('account.enterPhoneNumber')}
readOnly readOnly
/> />
@@ -478,24 +490,35 @@ export const UserLoginAuthInfoWrap = ({
></button> ></button>
</div> </div>
))} ))}
{newPhones.map((phone, index) => ( {newPhones.map((phone, index) => {
<div className="input-row" key={`new-phone-${index}`}> const isReadOnly = readOnlyPhones.has(index) || index !== editablePhoneIndex;
<input const displayValue = isReadOnly && phone ? maskPhoneNumber(phone) : phone;
type="tel"
value={phone} return (
placeholder={t('account.enterPhoneNumber')} <div className="input-row" key={`new-phone-${index}`}>
onChange={(e) => handleNewPhoneChange(index, e.target.value)} <input
readOnly={readOnlyPhones.has(index) || index !== editablePhoneIndex} type="tel"
/> value={displayValue}
<button placeholder={t('account.enterPhoneNumber')}
className="icon-btn minus" onChange={(e) => handleNewPhoneChange(index, e.target.value)}
type="button" onFocus={() => setEditablePhoneIndex(index)}
aria-label={t('common.delete')} onBlur={() => {
onClick={() => handleRemoveNewPhone(index)} if (phone && isValidPhone(phone)) {
disabled={!isDeleteButtonEnabled()} setEditablePhoneIndex(-1);
></button> }
</div> }}
))} readOnly={isReadOnly}
/>
<button
className="icon-btn minus"
type="button"
aria-label={t('common.delete')}
onClick={() => handleRemoveNewPhone(index)}
disabled={!isDeleteButtonEnabled()}
></button>
</div>
);
})}
<div className="notice-bar">{t('account.tabChangeResetNotice')}</div> <div className="notice-bar">{t('account.tabChangeResetNotice')}</div>
</div> </div>
</div> </div>

View File

@@ -37,6 +37,23 @@ export const userCreate = async (params: UserCreateParams): Promise<UserCreateMu
}; };
try { try {
const response = await axios.post<UserCreateResponse>(API_URL_USER.userCreate(), params, options); const response = await axios.post<UserCreateResponse>(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 }; return { status: true, data: response.data };
} catch (error: any) { } catch (error: any) {
return { return {

View File

@@ -327,6 +327,8 @@
"pleaseEnterId": "Please enter ID", "pleaseEnterId": "Please enter ID",
"pleaseEnterPassword": "Please enter password", "pleaseEnterPassword": "Please enter password",
"pleaseEnter8OrMoreCharacters": "Please enter 8 or more characters", "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", "enterPassword": "Enter password",
"reEnterPassword": "Re-enter password", "reEnterPassword": "Re-enter password",
"enterPhoneNumber": "Enter phone number", "enterPhoneNumber": "Enter phone number",
@@ -339,6 +341,7 @@
"userAddFailed": "Failed to add user.", "userAddFailed": "Failed to add user.",
"userInfoSavedSuccessfully": "User information saved successfully.", "userInfoSavedSuccessfully": "User information saved successfully.",
"userInfoSaveFailed": "Failed to save user information.", "userInfoSaveFailed": "Failed to save user information.",
"confirmPasswordChange": "Would you like to change your password?",
"passwordChangedSuccessfully": "Password changed successfully.", "passwordChangedSuccessfully": "Password changed successfully.",
"passwordChangeFailed": "Failed to change password.", "passwordChangeFailed": "Failed to change password.",
"permissionSavedSuccessfully": "Permission saved successfully.", "permissionSavedSuccessfully": "Permission saved successfully.",
@@ -357,7 +360,26 @@
"menuPermissions": "Menu Permissions", "menuPermissions": "Menu Permissions",
"deleteUserConfirm": "Do you want to delete this user?", "deleteUserConfirm": "Do you want to delete this user?",
"deleteUserSuccess": "User deleted successfully.", "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": { "favorite": {
"edit": "Edit", "edit": "Edit",

View File

@@ -327,6 +327,8 @@
"pleaseEnterId": "ID를 입력해 주세요", "pleaseEnterId": "ID를 입력해 주세요",
"pleaseEnterPassword": "비밀번호를 입력해 주세요", "pleaseEnterPassword": "비밀번호를 입력해 주세요",
"pleaseEnter8OrMoreCharacters": "8자리 이상 입력해 주세요", "pleaseEnter8OrMoreCharacters": "8자리 이상 입력해 주세요",
"passwordFormatRequirement": "8자리 이상 20자리 이하의 영문,숫자,특수문자를 조합하여 설정해 주시기 바랍니다.",
"passwordSecurityRequirement": "패스워드는 3자리 이상의 연속된 숫자, 두 번이상 반복 문자열, 3자리 이상의 동일한 문자열을 사용할 수 없습니다.",
"enterPassword": "비밀번호를 입력하세요", "enterPassword": "비밀번호를 입력하세요",
"reEnterPassword": "비밀번호를 다시 입력하세요", "reEnterPassword": "비밀번호를 다시 입력하세요",
"enterPhoneNumber": "휴대폰 번호 입력", "enterPhoneNumber": "휴대폰 번호 입력",
@@ -339,6 +341,7 @@
"userAddFailed": "사용자 추가에 실패했습니다.", "userAddFailed": "사용자 추가에 실패했습니다.",
"userInfoSavedSuccessfully": "사용자 정보가 성공적으로 저장되었습니다.", "userInfoSavedSuccessfully": "사용자 정보가 성공적으로 저장되었습니다.",
"userInfoSaveFailed": "사용자 정보 저장에 실패했습니다.", "userInfoSaveFailed": "사용자 정보 저장에 실패했습니다.",
"confirmPasswordChange": "비밀번호를 변경하시겠습니까?",
"passwordChangedSuccessfully": "비밀번호가 성공적으로 변경되었습니다.", "passwordChangedSuccessfully": "비밀번호가 성공적으로 변경되었습니다.",
"passwordChangeFailed": "비밀번호 변경에 실패했습니다.", "passwordChangeFailed": "비밀번호 변경에 실패했습니다.",
"permissionSavedSuccessfully": "권한이 성공적으로 저장되었습니다.", "permissionSavedSuccessfully": "권한이 성공적으로 저장되었습니다.",
@@ -357,7 +360,26 @@
"menuPermissions": "메뉴별 권한 설정", "menuPermissions": "메뉴별 권한 설정",
"deleteUserConfirm": "사용자를 삭제하시겠습니까?", "deleteUserConfirm": "사용자를 삭제하시겠습니까?",
"deleteUserSuccess": "사용자 삭제를 성공했습니다.", "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": { "favorite": {
"edit": "편집하기", "edit": "편집하기",

View File

@@ -12,6 +12,9 @@ import {
import { useUserChangeCancelPasswordMutation } from '@/entities/user/api/use-user-change-cancel-password-mutation'; import { useUserChangeCancelPasswordMutation } from '@/entities/user/api/use-user-change-cancel-password-mutation';
import { useStore } from '@/shared/model/store'; import { useStore } from '@/shared/model/store';
import { snackBar } from '@/shared/lib/toast'; 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 = () => { export const PasswordModifyCancelPasswordPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -25,6 +28,26 @@ export const PasswordModifyCancelPasswordPage = () => {
const [mid, setMid] = useState<string>((midItem.length > 0)? userMid: ''); const [mid, setMid] = useState<string>((midItem.length > 0)? userMid: '');
const [password, setPassword] = useState<string>(''); const [password, setPassword] = useState<string>('');
const [confirmPassword, setConfirmPassword] = useState<string>(''); const [confirmPassword, setConfirmPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
// 허용된 문자만 입력 가능하도록 필터링 (영문, 숫자, 특수문자)
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({ const changeCancelPasswordMutation = useUserChangeCancelPasswordMutation({
onSuccess: () => { onSuccess: () => {
@@ -36,7 +59,45 @@ export const PasswordModifyCancelPasswordPage = () => {
navigate(PATHS.account.password.manage); navigate(PATHS.account.password.manage);
}, },
onError: (error) => { 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<string, string> = {
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); navigate(PATHS.account.password.manage);
}); });
// 비밀번호 blur 핸들러
const handlePasswordBlur = () => {
const result = validatePassword(password, t);
setPasswordError(result.isValid ? '' : result.errorMessage);
};
// 저장 버튼 활성화 조건 체크 // 저장 버튼 활성화 조건 체크
const isFormValid = () => { const isFormValid = () => {
return ( return (
password.length >= 8 && validatePassword(password, t).isValid &&
confirmPassword.length >= 8 && confirmPassword.length >= 8 &&
password === confirmPassword password === confirmPassword
); );
@@ -60,9 +127,26 @@ export const PasswordModifyCancelPasswordPage = () => {
const handleSave = () => { const handleSave = () => {
if (!isFormValid()) return; if (!isFormValid()) return;
changeCancelPasswordMutation.mutate({ overlay.open(({
mid, isOpen,
password: password close,
unmount
}) => {
return (
<Dialog
afterLeave={unmount}
open={isOpen}
onClose={close}
onConfirmClick={() => {
changeCancelPasswordMutation.mutate({
mid,
password: password
});
}}
message={t('account.confirmPasswordChange')}
buttonLabel={[t('common.cancel'), t('common.confirm')]}
/>
);
}); });
}; };
@@ -93,13 +177,16 @@ export const PasswordModifyCancelPasswordPage = () => {
<div className="ua-row"> <div className="ua-row">
<div className="ua-label">{t('account.newPassword')} <span className="red">*</span></div> <div className="ua-label">{t('account.newPassword')} <span className="red">*</span></div>
<input <input
className={`wid-100 ${confirmPassword && password !== confirmPassword ? 'error' : ''}`} className={`wid-100 ${passwordError || (confirmPassword && password !== confirmPassword) ? 'error' : ''}`}
type="password" type="password"
placeholder={t('account.enterPassword')} placeholder={t('account.enterPassword')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => handlePasswordChange(e.target.value)}
onBlur={handlePasswordBlur}
maxLength={10}
/> />
</div> </div>
{passwordError && <div className="ua-help error pt-10">{passwordError}</div>}
<div className="ua-row"> <div className="ua-row">
<div className="ua-label">{t('account.reEnterNewPassword')} <span className="red">*</span></div> <div className="ua-label">{t('account.reEnterNewPassword')} <span className="red">*</span></div>
<input <input
@@ -107,7 +194,8 @@ export const PasswordModifyCancelPasswordPage = () => {
type="password" type="password"
placeholder={t('account.reEnterPassword')} placeholder={t('account.reEnterPassword')}
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => handleConfirmPasswordChange(e.target.value)}
maxLength={10}
/> />
</div> </div>
{confirmPassword && password !== confirmPassword && ( {confirmPassword && password !== confirmPassword && (

View File

@@ -11,6 +11,9 @@ import {
} from '@/widgets/sub-layout/use-sub-layout'; } from '@/widgets/sub-layout/use-sub-layout';
import { useUserChangePasswordMutation } from '@/entities/user/api/use-user-change-password-mutation'; import { useUserChangePasswordMutation } from '@/entities/user/api/use-user-change-password-mutation';
import { snackBar } from '@/shared/lib/toast'; 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 = () => { export const PasswordModifyLoginPasswordPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -19,6 +22,7 @@ export const PasswordModifyLoginPasswordPage = () => {
const [newPassword, setNewPassword] = useState<string>(''); const [newPassword, setNewPassword] = useState<string>('');
const [confirmPassword, setConfirmPassword] = useState<string>(''); const [confirmPassword, setConfirmPassword] = useState<string>('');
const [usrid, ] = useState<string>('nictest00'); const [usrid, ] = useState<string>('nictest00');
const [passwordError, setPasswordError] = useState<string>('');
const changePasswordMutation = useUserChangePasswordMutation({ const changePasswordMutation = useUserChangePasswordMutation({
onSuccess: () => { onSuccess: () => {
@@ -31,7 +35,38 @@ export const PasswordModifyLoginPasswordPage = () => {
navigate(PATHS.account.password.manage); navigate(PATHS.account.password.manage);
}, },
onError: (error) => { 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<string, string> = {
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); 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 = () => { const isFormValid = () => {
return ( return (
currentPassword.length >= 8 && currentPassword.length >= 8 &&
newPassword.length >= 8 && validatePassword(newPassword, t).isValid &&
confirmPassword.length >= 8 && confirmPassword.length >= 8 &&
newPassword === confirmPassword newPassword === confirmPassword
); );
@@ -56,11 +106,27 @@ export const PasswordModifyLoginPasswordPage = () => {
const handleSave = () => { const handleSave = () => {
if (!isFormValid()) return; if (!isFormValid()) return;
// TODO: Validate current password before submitting overlay.open(({
changePasswordMutation.mutate({ isOpen,
usrid: usrid, close,
currentPassword: currentPassword, unmount
newPassword: newPassword, }) => {
return (
<Dialog
afterLeave={unmount}
open={isOpen}
onClose={close}
onConfirmClick={() => {
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 = () => {
<div className="ua-row"> <div className="ua-row">
<div className="ua-label">{t('account.newPassword')} <span className="red">*</span></div> <div className="ua-label">{t('account.newPassword')} <span className="red">*</span></div>
<input <input
className={`wid-100 ${confirmPassword && newPassword !== confirmPassword ? 'error' : ''}`} className={`wid-100 ${passwordError || (confirmPassword && newPassword !== confirmPassword) ? 'error' : ''}`}
type="password" type="password"
placeholder="" placeholder=""
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => handlePasswordChange(e.target.value)}
onBlur={handlePasswordBlur}
/> />
</div> </div>
{passwordError && <div className="ua-help error pt-10">{passwordError}</div>}
<div className="ua-row"> <div className="ua-row">
<div className="ua-label">{t('account.reEnterNewPassword')} <span className="red">*</span></div> <div className="ua-label">{t('account.reEnterNewPassword')} <span className="red">*</span></div>
<input <input

View File

@@ -14,6 +14,8 @@ import { useUserCreateMutation } from '@/entities/user/api/use-user-create-mutat
import { useUserExistsUseridQuery } from '@/entities/user/api/use-user-exists-userid-query'; import { useUserExistsUseridQuery } from '@/entities/user/api/use-user-exists-userid-query';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
import { snackBar } from '@/shared/lib/toast'; import { snackBar } from '@/shared/lib/toast';
import { maskEmail, maskPhoneNumber } from '@/shared/lib/masking';
import { validatePassword } from '@/shared/lib/password-validation';
export const UserAddAccountPage = () => { export const UserAddAccountPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -22,11 +24,36 @@ export const UserAddAccountPage = () => {
const { mid } = location.state || {}; const { mid } = location.state || {};
const { mutateAsync: userCreate, isPending } = useUserCreateMutation({ const { mutateAsync: userCreate, isPending } = useUserCreateMutation({
onSuccess: () => {
snackBar(t('account.userAddedSuccessfully'));
},
onError: (error) => { 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<string, string> = {
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); 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) => { const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value })); // 사용자 ID의 경우 영문과 숫자만 허용
// 사용자 ID의 경우 디바운스 적용하여 자동 검증
if (field === 'usrid') { if (field === 'usrid') {
// 영문과 숫자가 아닌 문자 제거
const filteredValue = value.replace(/[^a-zA-Z0-9]/g, '');
setFormData(prev => ({ ...prev, [field]: filteredValue }));
// 기존 타이머 클리어 // 기존 타이머 클리어
if (debounceTimeout.current) { if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current); clearTimeout(debounceTimeout.current);
@@ -183,7 +203,7 @@ export const UserAddAccountPage = () => {
setErrors(prev => ({ ...prev, usrid: '' })); setErrors(prev => ({ ...prev, usrid: '' }));
// 값이 있으면 500ms 후 검증 실행 // 값이 있으면 500ms 후 검증 실행
if (value.trim().length > 0) { if (filteredValue.trim().length > 0) {
setIsCheckingUsrid(true); setIsCheckingUsrid(true);
debounceTimeout.current = setTimeout(() => { debounceTimeout.current = setTimeout(() => {
setShouldCheckUsrid(true); setShouldCheckUsrid(true);
@@ -192,6 +212,7 @@ export const UserAddAccountPage = () => {
setIsCheckingUsrid(false); setIsCheckingUsrid(false);
} }
} else { } else {
setFormData(prev => ({ ...prev, [field]: value }));
// 다른 필드는 에러만 초기화 // 다른 필드는 에러만 초기화
if (errors[field as keyof typeof errors]) { if (errors[field as keyof typeof errors]) {
setErrors(prev => ({ ...prev, [field]: '' })); setErrors(prev => ({ ...prev, [field]: '' }));
@@ -201,10 +222,11 @@ export const UserAddAccountPage = () => {
// 비밀번호 blur 핸들러 // 비밀번호 blur 핸들러
const handlePasswordBlur = () => { const handlePasswordBlur = () => {
const passwordError = validatePassword(formData.password); const result = validatePassword(formData.password, t);
if (passwordError) { setErrors(prev => ({
setErrors(prev => ({ ...prev, password: passwordError })); ...prev,
} password: result.isValid ? '' : result.errorMessage
}));
}; };
// 컴포넌트 언마운트 시 타이머 정리 // 컴포넌트 언마운트 시 타이머 정리
@@ -281,8 +303,9 @@ export const UserAddAccountPage = () => {
if (errors.usrid) return false; if (errors.usrid) return false;
if (isCheckingUsrid || isUserExistsLoading) return false; if (isCheckingUsrid || isUserExistsLoading) return false;
// 2. 비밀번호가 8자리 이상 // 2. 비밀번호 검증
if (!formData.password.trim() || formData.password.length < 8) return false; if (!formData.password.trim()) return false;
if (!validatePassword(formData.password, t).isValid) return false;
// 3. 입력된 모든 이메일과 휴대폰 번호가 유효한 형식이어야 함 // 3. 입력된 모든 이메일과 휴대폰 번호가 유효한 형식이어야 함
const nonEmptyEmails = newEmails.filter(email => email.trim()); const nonEmptyEmails = newEmails.filter(email => email.trim());
@@ -324,11 +347,9 @@ export const UserAddAccountPage = () => {
} }
// 비밀번호 검증 // 비밀번호 검증
if (!formData.password.trim()) { const passwordResult = validatePassword(formData.password, t);
newErrors.password = t('account.pleaseEnterPassword'); if (!passwordResult.isValid) {
isValid = false; newErrors.password = passwordResult.errorMessage;
} else if (formData.password.length < 8) {
newErrors.password = t('account.pleaseEnter8OrMoreCharacters');
isValid = false; isValid = false;
} }
@@ -382,21 +403,57 @@ export const UserAddAccountPage = () => {
const response = await userCreate(request); const response = await userCreate(request);
if (response.status) { if (response?.status) {
// 성공 시 사용자 관리 페이지로 이동 // 성공 시 사용자 관리 페이지로 이동
snackBar(t('account.userAddedSuccessfully')); snackBar(t('account.userAddedSuccessfully'));
navigate(PATHS.account.user.manage); 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<string, string> = {
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') })); 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<string, unknown>;
const validationErrors = details?.validationErrors as Record<string, string> | 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 { } else {
// 기타 에러 처리 // 기타 에러 처리
console.error('User creation failed:', response.error.message); snackBar(error.message || t('account.userAddFailed'));
} }
} }
} catch (error) { } catch (error) {
console.error('User creation error:', error); console.error('User creation error:', error);
// catch된 에러는 onError에서 처리됨
} }
}; };
@@ -486,25 +543,36 @@ export const UserAddAccountPage = () => {
disabled={!isEmailAddButtonEnabled()} disabled={!isEmailAddButtonEnabled()}
></button> ></button>
</div> </div>
{newEmails.map((email, index) => ( {newEmails.map((email, index) => {
<div className="ua-input-row" key={index}> const isReadOnly = readOnlyEmails.has(index) || index !== editableEmailIndex;
<input const displayValue = isReadOnly && email ? maskEmail(email) : email;
className="wid-100"
type="text" return (
placeholder="example@domain.com" <div className="ua-input-row" key={index}>
value={email} <input
onChange={(e) => handleNewEmailChange(index, e.target.value)} className="wid-100"
readOnly={readOnlyEmails.has(index) || index !== editableEmailIndex} type="text"
/> placeholder="example@domain.com"
<button value={displayValue}
className="icon-btn minus" onChange={(e) => handleNewEmailChange(index, e.target.value)}
type="button" onFocus={() => setEditableEmailIndex(index)}
aria-label={t('common.delete')} onBlur={() => {
onClick={() => handleRemoveNewEmail(index)} if (email && isValidEmail(email)) {
disabled={!isDeleteButtonEnabled()} setEditableEmailIndex(-1);
></button> }
</div> }}
))} readOnly={isReadOnly}
/>
<button
className="icon-btn minus"
type="button"
aria-label={t('common.delete')}
onClick={() => handleRemoveNewEmail(index)}
disabled={!isDeleteButtonEnabled()}
></button>
</div>
);
})}
</div> </div>
<div className="ua-group"> <div className="ua-group">
@@ -518,25 +586,36 @@ export const UserAddAccountPage = () => {
disabled={!isPhoneAddButtonEnabled()} disabled={!isPhoneAddButtonEnabled()}
></button> ></button>
</div> </div>
{newPhones.map((phone, index) => ( {newPhones.map((phone, index) => {
<div className="ua-input-row" key={index}> const isReadOnly = readOnlyPhones.has(index) || index !== editablePhoneIndex;
<input const displayValue = isReadOnly && phone ? maskPhoneNumber(phone) : phone;
className="wid-100"
type="tel" return (
placeholder="01012345678" <div className="ua-input-row" key={index}>
value={phone} <input
onChange={(e) => handleNewPhoneChange(index, e.target.value)} className="wid-100"
readOnly={readOnlyPhones.has(index) || index !== editablePhoneIndex} type="tel"
/> placeholder="01012345678"
<button value={displayValue}
className="icon-btn minus" onChange={(e) => handleNewPhoneChange(index, e.target.value)}
type="button" onFocus={() => setEditablePhoneIndex(index)}
aria-label={t('common.delete')} onBlur={() => {
onClick={() => handleRemoveNewPhone(index)} if (phone && isValidPhone(phone)) {
disabled={!isDeleteButtonEnabled()} setEditablePhoneIndex(-1);
></button> }
</div> }}
))} readOnly={isReadOnly}
/>
<button
className="icon-btn minus"
type="button"
aria-label={t('common.delete')}
onClick={() => handleRemoveNewPhone(index)}
disabled={!isDeleteButtonEnabled()}
></button>
</div>
);
})}
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
export * from './error'; export * from './error';
export * from './toast'; export * from './toast';
export * from './web-view-bridge'; export * from './web-view-bridge';
export * from './masking';

41
src/shared/lib/masking.ts Normal file
View File

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

View File

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