비밀번호 변경 기능 개선 및 검증 강화
- 비밀번호 변경 페이지에 확인 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:
@@ -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 = ({
|
||||
<div className="input-row" key={`existing-email-${index}`}>
|
||||
<input
|
||||
type="email"
|
||||
value={email.content}
|
||||
value={maskEmail(email.content)}
|
||||
placeholder="example@domain.com"
|
||||
readOnly
|
||||
/>
|
||||
@@ -430,24 +431,35 @@ export const UserLoginAuthInfoWrap = ({
|
||||
></button>
|
||||
</div>
|
||||
))}
|
||||
{newEmails.map((email, index) => (
|
||||
<div className="input-row" key={`new-email-${index}`}>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
placeholder="example@domain.com"
|
||||
onChange={(e) => handleNewEmailChange(index, e.target.value)}
|
||||
readOnly={readOnlyEmails.has(index) || index !== editableEmailIndex}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn minus"
|
||||
type="button"
|
||||
aria-label={t('common.delete')}
|
||||
onClick={() => handleRemoveNewEmail(index)}
|
||||
disabled={!isDeleteButtonEnabled()}
|
||||
></button>
|
||||
</div>
|
||||
))}
|
||||
{newEmails.map((email, index) => {
|
||||
const isReadOnly = readOnlyEmails.has(index) || index !== editableEmailIndex;
|
||||
const displayValue = isReadOnly && email ? maskEmail(email) : email;
|
||||
|
||||
return (
|
||||
<div className="input-row" key={`new-email-${index}`}>
|
||||
<input
|
||||
type="email"
|
||||
value={displayValue}
|
||||
placeholder="example@domain.com"
|
||||
onChange={(e) => handleNewEmailChange(index, e.target.value)}
|
||||
onFocus={() => setEditableEmailIndex(index)}
|
||||
onBlur={() => {
|
||||
if (email && isValidEmail(email)) {
|
||||
setEditableEmailIndex(-1);
|
||||
}
|
||||
}}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn minus"
|
||||
type="button"
|
||||
aria-label={t('common.delete')}
|
||||
onClick={() => handleRemoveNewEmail(index)}
|
||||
disabled={!isDeleteButtonEnabled()}
|
||||
></button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="group">
|
||||
@@ -465,7 +477,7 @@ export const UserLoginAuthInfoWrap = ({
|
||||
<div className="input-row" key={`existing-phone-${index}`}>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone.content}
|
||||
value={maskPhoneNumber(phone.content)}
|
||||
placeholder={t('account.enterPhoneNumber')}
|
||||
readOnly
|
||||
/>
|
||||
@@ -478,24 +490,35 @@ export const UserLoginAuthInfoWrap = ({
|
||||
></button>
|
||||
</div>
|
||||
))}
|
||||
{newPhones.map((phone, index) => (
|
||||
<div className="input-row" key={`new-phone-${index}`}>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
placeholder={t('account.enterPhoneNumber')}
|
||||
onChange={(e) => handleNewPhoneChange(index, e.target.value)}
|
||||
readOnly={readOnlyPhones.has(index) || index !== editablePhoneIndex}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn minus"
|
||||
type="button"
|
||||
aria-label={t('common.delete')}
|
||||
onClick={() => handleRemoveNewPhone(index)}
|
||||
disabled={!isDeleteButtonEnabled()}
|
||||
></button>
|
||||
</div>
|
||||
))}
|
||||
{newPhones.map((phone, index) => {
|
||||
const isReadOnly = readOnlyPhones.has(index) || index !== editablePhoneIndex;
|
||||
const displayValue = isReadOnly && phone ? maskPhoneNumber(phone) : phone;
|
||||
|
||||
return (
|
||||
<div className="input-row" key={`new-phone-${index}`}>
|
||||
<input
|
||||
type="tel"
|
||||
value={displayValue}
|
||||
placeholder={t('account.enterPhoneNumber')}
|
||||
onChange={(e) => handleNewPhoneChange(index, e.target.value)}
|
||||
onFocus={() => setEditablePhoneIndex(index)}
|
||||
onBlur={() => {
|
||||
if (phone && isValidPhone(phone)) {
|
||||
setEditablePhoneIndex(-1);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,23 @@ export const userCreate = async (params: UserCreateParams): Promise<UserCreateMu
|
||||
};
|
||||
try {
|
||||
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 };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "편집하기",
|
||||
|
||||
@@ -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<string>((midItem.length > 0)? userMid: '');
|
||||
const [password, setPassword] = 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({
|
||||
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<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);
|
||||
});
|
||||
|
||||
// 비밀번호 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 (
|
||||
<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-label">{t('account.newPassword')} <span className="red">*</span></div>
|
||||
<input
|
||||
className={`wid-100 ${confirmPassword && password !== confirmPassword ? 'error' : ''}`}
|
||||
className={`wid-100 ${passwordError || (confirmPassword && password !== confirmPassword) ? 'error' : ''}`}
|
||||
type="password"
|
||||
placeholder={t('account.enterPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={(e) => handlePasswordChange(e.target.value)}
|
||||
onBlur={handlePasswordBlur}
|
||||
maxLength={10}
|
||||
/>
|
||||
</div>
|
||||
{passwordError && <div className="ua-help error pt-10">{passwordError}</div>}
|
||||
<div className="ua-row">
|
||||
<div className="ua-label">{t('account.reEnterNewPassword')} <span className="red">*</span></div>
|
||||
<input
|
||||
@@ -107,7 +194,8 @@ export const PasswordModifyCancelPasswordPage = () => {
|
||||
type="password"
|
||||
placeholder={t('account.reEnterPassword')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
onChange={(e) => handleConfirmPasswordChange(e.target.value)}
|
||||
maxLength={10}
|
||||
/>
|
||||
</div>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
|
||||
@@ -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<string>('');
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>('');
|
||||
const [usrid, ] = useState<string>('nictest00');
|
||||
const [passwordError, setPasswordError] = useState<string>('');
|
||||
|
||||
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<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);
|
||||
});
|
||||
|
||||
// 비밀번호 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 (
|
||||
<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-label">{t('account.newPassword')} <span className="red">*</span></div>
|
||||
<input
|
||||
className={`wid-100 ${confirmPassword && newPassword !== confirmPassword ? 'error' : ''}`}
|
||||
className={`wid-100 ${passwordError || (confirmPassword && newPassword !== confirmPassword) ? 'error' : ''}`}
|
||||
type="password"
|
||||
placeholder=""
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
onChange={(e) => handlePasswordChange(e.target.value)}
|
||||
onBlur={handlePasswordBlur}
|
||||
/>
|
||||
</div>
|
||||
{passwordError && <div className="ua-help error pt-10">{passwordError}</div>}
|
||||
<div className="ua-row">
|
||||
<div className="ua-label">{t('account.reEnterNewPassword')} <span className="red">*</span></div>
|
||||
<input
|
||||
|
||||
@@ -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 { useLocation } from 'react-router';
|
||||
import { snackBar } from '@/shared/lib/toast';
|
||||
import { maskEmail, maskPhoneNumber } from '@/shared/lib/masking';
|
||||
import { validatePassword } from '@/shared/lib/password-validation';
|
||||
|
||||
export const UserAddAccountPage = () => {
|
||||
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<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);
|
||||
};
|
||||
|
||||
// 비밀번호 검증 함수
|
||||
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<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') }));
|
||||
} 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 {
|
||||
// 기타 에러 처리
|
||||
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()}
|
||||
></button>
|
||||
</div>
|
||||
{newEmails.map((email, index) => (
|
||||
<div className="ua-input-row" key={index}>
|
||||
<input
|
||||
className="wid-100"
|
||||
type="text"
|
||||
placeholder="example@domain.com"
|
||||
value={email}
|
||||
onChange={(e) => handleNewEmailChange(index, e.target.value)}
|
||||
readOnly={readOnlyEmails.has(index) || index !== editableEmailIndex}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn minus"
|
||||
type="button"
|
||||
aria-label={t('common.delete')}
|
||||
onClick={() => handleRemoveNewEmail(index)}
|
||||
disabled={!isDeleteButtonEnabled()}
|
||||
></button>
|
||||
</div>
|
||||
))}
|
||||
{newEmails.map((email, index) => {
|
||||
const isReadOnly = readOnlyEmails.has(index) || index !== editableEmailIndex;
|
||||
const displayValue = isReadOnly && email ? maskEmail(email) : email;
|
||||
|
||||
return (
|
||||
<div className="ua-input-row" key={index}>
|
||||
<input
|
||||
className="wid-100"
|
||||
type="text"
|
||||
placeholder="example@domain.com"
|
||||
value={displayValue}
|
||||
onChange={(e) => handleNewEmailChange(index, e.target.value)}
|
||||
onFocus={() => setEditableEmailIndex(index)}
|
||||
onBlur={() => {
|
||||
if (email && isValidEmail(email)) {
|
||||
setEditableEmailIndex(-1);
|
||||
}
|
||||
}}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn minus"
|
||||
type="button"
|
||||
aria-label={t('common.delete')}
|
||||
onClick={() => handleRemoveNewEmail(index)}
|
||||
disabled={!isDeleteButtonEnabled()}
|
||||
></button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="ua-group">
|
||||
@@ -518,25 +586,36 @@ export const UserAddAccountPage = () => {
|
||||
disabled={!isPhoneAddButtonEnabled()}
|
||||
></button>
|
||||
</div>
|
||||
{newPhones.map((phone, index) => (
|
||||
<div className="ua-input-row" key={index}>
|
||||
<input
|
||||
className="wid-100"
|
||||
type="tel"
|
||||
placeholder="01012345678"
|
||||
value={phone}
|
||||
onChange={(e) => handleNewPhoneChange(index, e.target.value)}
|
||||
readOnly={readOnlyPhones.has(index) || index !== editablePhoneIndex}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn minus"
|
||||
type="button"
|
||||
aria-label={t('common.delete')}
|
||||
onClick={() => handleRemoveNewPhone(index)}
|
||||
disabled={!isDeleteButtonEnabled()}
|
||||
></button>
|
||||
</div>
|
||||
))}
|
||||
{newPhones.map((phone, index) => {
|
||||
const isReadOnly = readOnlyPhones.has(index) || index !== editablePhoneIndex;
|
||||
const displayValue = isReadOnly && phone ? maskPhoneNumber(phone) : phone;
|
||||
|
||||
return (
|
||||
<div className="ua-input-row" key={index}>
|
||||
<input
|
||||
className="wid-100"
|
||||
type="tel"
|
||||
placeholder="01012345678"
|
||||
value={displayValue}
|
||||
onChange={(e) => handleNewPhoneChange(index, e.target.value)}
|
||||
onFocus={() => setEditablePhoneIndex(index)}
|
||||
onBlur={() => {
|
||||
if (phone && isValidPhone(phone)) {
|
||||
setEditablePhoneIndex(-1);
|
||||
}
|
||||
}}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn minus"
|
||||
type="button"
|
||||
aria-label={t('common.delete')}
|
||||
onClick={() => handleRemoveNewPhone(index)}
|
||||
disabled={!isDeleteButtonEnabled()}
|
||||
></button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './error';
|
||||
export * from './toast';
|
||||
export * from './web-view-bridge';
|
||||
export * from './masking';
|
||||
41
src/shared/lib/masking.ts
Normal file
41
src/shared/lib/masking.ts
Normal 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}`;
|
||||
};
|
||||
259
src/shared/lib/password-validation.ts
Normal file
259
src/shared/lib/password-validation.ts
Normal 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: ''
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user