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

- 비밀번호 변경 페이지에 확인 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

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