feat: ID·이메일 칸에도 검증기 적용 — 규칙을 화면과 서버가 한 벌로 본다
비밀번호에 이어 ID·이메일도 `@fox/core/validation` 위에 올렸다. 핵심은 검증기를 화면이 아니라 **도메인이 소유**한다는 점이다 — `ADMIN_LOGIN_ID_VALIDATORS`·`adminEmailValidators()`를 `admin-member-form.ts`가 내보내고, 팝업의 입력 칸과 Server Action의 검증이 **같은 배열**을 돌린다. 규칙이 한 군데뿐이라 화면은 통과시키는데 저장은 거부하는 상태가 생기지 않고, 서버가 돌려주는 문구와 입력 중에 보이는 문구가 같다. ID의 "영어 소문자와 숫자를 조합"은 두 조각으로 나뉜다 — 허용 문자는 `pattern`이 소문자·숫자로 묶고, 그 안에서 둘 다 있어야 한다는 것은 `characterKinds(2)`가 본다. 종전의 손으로 쓴 세 갈래 분기(`validateAdminLoginId`)와 `isValidEmail`은 지웠다. 이메일은 필수 여부만 두 시안이 달라(등록에 `*`, 수정에 없음) 그것만 인자로 받는다. 등록 팝업의 이메일은 칸이 둘로 나뉘어 있어 검증 대상이 합친 값이다. FoxInput 한 칸이 스스로 판정할 수 없으므로 팝업이 돌리고 `touched`도 직접 든다(FoxInput의 blur 규칙과 같은 시점). 아울러 **로컬부가 비면 빈 이메일로 본다** — 도메인만 고른 `@naver.com`을 값으로 보내면 "형식이 올바르지 않습니다"가 뜨지만, 사용자가 한 일은 아무것도 입력하지 않은 것이다. 브라우저 확인: ID는 대문자(`Admin`)·22자에서 각각 다른 사유가 뜨고 `admin01`에서 사라진다. 이메일은 빈 로컬부에서 "이메일을 입력해 주세요.", 입력하면 사라지며 hidden 값이 채워진다. Co-Authored-By: Claude Opus 5
@96c750e100bc9bc0224e94e51539ad54d7d75c48
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
... | ... | @@ -13,15 +13,23 @@ |
| 13 | 13 |
import { FoxInput } from '@fox/core/components/fox-input';
|
| 14 | 14 |
import { FoxSelect } from '@fox/core/components/fox-select';
|
| 15 | 15 |
import { FoxModal } from '@fox/core/components/fox-modal';
|
| 16 |
-import { foxPasswordValidator } from '@fox/core/validation';
|
|
| 16 |
+import {
|
|
| 17 |
+ foxPasswordValidator, |
|
| 18 |
+ foxValidationMessage, |
|
| 19 |
+ foxValidators, |
|
| 20 |
+} from '@fox/core/validation'; |
|
| 17 | 21 |
import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
|
| 18 | 22 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 19 | 23 |
import { DEFAULT_ADMIN_ROLE_CODE } from '@/lib/domain/admin-member';
|
| 20 | 24 |
import {
|
| 25 |
+ ADMIN_EMAIL_MESSAGES, |
|
| 21 | 26 |
ADMIN_LOGIN_ID_HELP_TEXT, |
| 27 |
+ ADMIN_LOGIN_ID_MESSAGES, |
|
| 28 |
+ ADMIN_LOGIN_ID_VALIDATORS, |
|
| 22 | 29 |
ADMIN_PASSWORD_HELP_TEXT, |
| 23 | 30 |
ADMIN_PASSWORD_POLICY, |
| 24 | 31 |
INITIAL_ADMIN_MEMBER_FORM_STATE, |
| 32 |
+ adminEmailValidators, |
|
| 25 | 33 |
} from '@/lib/domain/admin-member-form'; |
| 26 | 34 |
import {
|
| 27 | 35 |
checkAdminLoginId, |
... | ... | @@ -85,6 +93,9 @@ |
| 85 | 93 |
const [emailLocal, setEmailLocal] = useState('');
|
| 86 | 94 |
const [emailDomain, setEmailDomain] = useState(EMAIL_DOMAIN_OPTIONS[0]); |
| 87 | 95 |
const [isCustomDomain, setIsCustomDomain] = useState(false); |
| 96 |
+ // 이메일은 칸이 둘로 나뉘어 있어 검증 대상이 **합친 값**이다 — FoxInput 한 칸이 스스로 |
|
| 97 |
+ // 판정할 수 없어 여기서 돌리고, `touched`도 직접 든다(FoxInput의 blur 규칙과 같은 시점). |
|
| 98 |
+ const [isEmailTouched, setIsEmailTouched] = useState(false); |
|
| 88 | 99 |
const [roleCode, setRoleCode] = useState<string>(DEFAULT_ADMIN_ROLE_CODE); |
| 89 | 100 |
const [menuCodes, setMenuCodes] = useState<string[]>([]); |
| 90 | 101 |
|
... | ... | @@ -100,7 +111,18 @@ |
| 100 | 111 |
|
| 101 | 112 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 102 | 113 |
|
| 103 |
- const email = emailLocal || emailDomain ? `${emailLocal}@${emailDomain}` : '';
|
|
| 114 |
+ // 둘 중 하나라도 비면 **빈 이메일**로 본다 — 도메인만 고른 상태(`@naver.com`)를 값으로 보내면 |
|
| 115 |
+ // "형식이 올바르지 않습니다"가 뜨지만, 사용자가 한 일은 아무것도 입력하지 않은 것이다. |
|
| 116 |
+ const email = emailLocal && emailDomain ? `${emailLocal}@${emailDomain}` : '';
|
|
| 117 |
+ |
|
| 118 |
+ const emailMessage = |
|
| 119 |
+ errors.email ?? |
|
| 120 |
+ (isEmailTouched |
|
| 121 |
+ ? foxValidationMessage( |
|
| 122 |
+ foxValidators.compose(adminEmailValidators(true))(email), |
|
| 123 |
+ ADMIN_EMAIL_MESSAGES |
|
| 124 |
+ ) |
|
| 125 |
+ : undefined); |
|
| 104 | 126 |
|
| 105 | 127 |
// 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다. |
| 106 | 128 |
// 값이 나쁜 것(unavailable)과 확인을 못 한 것(failed)은 사유가 다르므로 둘 다 문구로 낸다. |
... | ... | @@ -157,7 +179,10 @@ |
| 157 | 179 |
onChange={setLoginId}
|
| 158 | 180 |
message={loginIdMessage}
|
| 159 | 181 |
messageIcon={<FoxChatCenteredDotsIcon />}
|
| 182 |
+ // 중복 확인 결과·서버 오류는 화면 규칙이 알 수 없는 사유라 이쪽이 이긴다. |
|
| 160 | 183 |
invalid={Boolean(loginIdFailure) || Boolean(errors.loginId)}
|
| 184 |
+ validators={ADMIN_LOGIN_ID_VALIDATORS}
|
|
| 185 |
+ validationMessages={ADMIN_LOGIN_ID_MESSAGES}
|
|
| 161 | 186 |
/> |
| 162 | 187 |
<FoxButton |
| 163 | 188 |
type="secondary" |
... | ... | @@ -221,7 +246,8 @@ |
| 221 | 246 |
placeholder="1234-5678" |
| 222 | 247 |
value={emailLocal}
|
| 223 | 248 |
onChange={setEmailLocal}
|
| 224 |
- invalid={Boolean(errors.email)}
|
|
| 249 |
+ onBlur={() => setIsEmailTouched(true)}
|
|
| 250 |
+ invalid={Boolean(emailMessage)}
|
|
| 225 | 251 |
/> |
| 226 | 252 |
</span> |
| 227 | 253 |
<span aria-hidden="true">@</span> |
... | ... | @@ -233,7 +259,8 @@ |
| 233 | 259 |
placeholder="직접입력" |
| 234 | 260 |
value={emailDomain}
|
| 235 | 261 |
onChange={setEmailDomain}
|
| 236 |
- invalid={Boolean(errors.email)}
|
|
| 262 |
+ onBlur={() => setIsEmailTouched(true)}
|
|
| 263 |
+ invalid={Boolean(emailMessage)}
|
|
| 237 | 264 |
/> |
| 238 | 265 |
) : ( |
| 239 | 266 |
<FoxSelect |
... | ... | @@ -247,6 +274,7 @@ |
| 247 | 274 |
]} |
| 248 | 275 |
value={emailDomain}
|
| 249 | 276 |
onValueChange={(next) => {
|
| 277 |
+ setIsEmailTouched(true); |
|
| 250 | 278 |
if (next === CUSTOM_EMAIL_DOMAIN) {
|
| 251 | 279 |
setIsCustomDomain(true); |
| 252 | 280 |
setEmailDomain('');
|
... | ... | @@ -254,12 +282,16 @@ |
| 254 | 282 |
} |
| 255 | 283 |
setEmailDomain(next); |
| 256 | 284 |
}} |
| 257 |
- error={Boolean(errors.email)}
|
|
| 285 |
+ error={Boolean(emailMessage)}
|
|
| 258 | 286 |
/> |
| 259 | 287 |
)} |
| 260 | 288 |
</span> |
| 261 | 289 |
</div> |
| 262 |
- {errors.email && <p role="alert">{errors.email}</p>}
|
|
| 290 |
+ {emailMessage && (
|
|
| 291 |
+ <p className={styles.fieldError} role="alert">
|
|
| 292 |
+ {emailMessage}
|
|
| 293 |
+ </p> |
|
| 294 |
+ )} |
|
| 263 | 295 |
</div> |
| 264 | 296 |
<input type="hidden" name="email" value={email} />
|
| 265 | 297 |
</> |
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
... | ... | @@ -12,9 +12,11 @@ |
| 12 | 12 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 13 | 13 |
import { DEFAULT_ADMIN_ROLE_CODE, type AdminMember } from '@/lib/domain/admin-member';
|
| 14 | 14 |
import {
|
| 15 |
+ ADMIN_EMAIL_MESSAGES, |
|
| 15 | 16 |
ADMIN_PASSWORD_HELP_TEXT, |
| 16 | 17 |
ADMIN_PASSWORD_POLICY, |
| 17 | 18 |
INITIAL_ADMIN_MEMBER_FORM_STATE, |
| 19 |
+ adminEmailValidators, |
|
| 18 | 20 |
toPhoneDigits, |
| 19 | 21 |
} from '@/lib/domain/admin-member-form'; |
| 20 | 22 |
import { updateAdminMemberAction } from '../_actions';
|
... | ... | @@ -168,6 +170,9 @@ |
| 168 | 170 |
onChange={setEmail}
|
| 169 | 171 |
message={errors.email}
|
| 170 | 172 |
invalid={Boolean(errors.email)}
|
| 173 |
+ // 수정 시안에는 `*`가 없다 — 비우면 이메일을 지우는 것이고 오류가 아니다. |
|
| 174 |
+ validators={adminEmailValidators(false)}
|
|
| 175 |
+ validationMessages={ADMIN_EMAIL_MESSAGES}
|
|
| 171 | 176 |
/> |
| 172 | 177 |
} |
| 173 | 178 |
/> |
--- app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss
+++ app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss
... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 |
// 값은 전부 @fox 토큰을 거치므로 없는 이름을 쓰면 빌드가 실패한다. |
| 3 | 3 |
|
| 4 | 4 |
@use "@fox/styles/abstracts" as fox; |
| 5 |
+@use "@fox/styles/form-field" as field; |
|
| 5 | 6 |
|
| 6 | 7 |
/// 필드 한 벌씩 세로로 쌓는다. 라벨·상자·헬퍼 사이 간격은 각 @fox 컴포넌트가 이미 갖는다. |
| 7 | 8 |
/// 모달 contents는 `align-items: flex-start`라 자식이 늘어나지 않는다 — 폭을 주지 않으면 폼이 |
... | ... | @@ -47,3 +48,11 @@ |
| 47 | 48 |
gap: fox.gap(3); |
| 48 | 49 |
inline-size: 100%; |
| 49 | 50 |
} |
| 51 |
+ |
|
| 52 |
+/// 칸 여럿을 묶은 필드(이메일)의 오류 문구. FoxInput은 자기 칸의 값만 판정할 수 있어 |
|
| 53 |
+/// 합친 값의 사유는 바깥에서 그린다 — 모양은 입력 헬퍼와 같은 조각을 쓴다. |
|
| 54 |
+.fieldError {
|
|
| 55 |
+ @include field.message; |
|
| 56 |
+ |
|
| 57 |
+ color: fox.color(font-system-danger); |
|
| 58 |
+} |
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
... | ... | @@ -20,6 +20,12 @@ |
| 20 | 20 |
*/ |
| 21 | 21 |
|
| 22 | 22 |
import {
|
| 23 |
+ foxValidationMessage, |
|
| 24 |
+ foxValidators, |
|
| 25 |
+ type FoxValidationMessages, |
|
| 26 |
+ type FoxValidator, |
|
| 27 |
+} from '@fox/core/validation'; |
|
| 28 |
+import {
|
|
| 23 | 29 |
ADMIN_MENU_OPTIONS, |
| 24 | 30 |
ADMIN_ROLE_OPTIONS, |
| 25 | 31 |
type AdminRoleCode, |
... | ... | @@ -35,6 +41,31 @@ |
| 35 | 41 |
const LOGIN_ID_MAX_LENGTH = 20; |
| 36 | 42 |
|
| 37 | 43 |
/** |
| 44 |
+ * ID 규칙 — 검증기와 문구를 한 벌로 내보낸다. 등록 팝업의 입력 칸이 이것을 그대로 얹고, |
|
| 45 |
+ * 아래 `validateAdminLoginId`(서버 검증·중복 확인)도 같은 배열을 돌린다. 규칙이 한 군데뿐이라 |
|
| 46 |
+ * 화면은 통과시키는데 저장은 거부하는 상태가 생기지 않는다. |
|
| 47 |
+ * |
|
| 48 |
+ * "영어 소문자와 숫자를 조합"은 두 조각으로 나뉜다 — 허용 문자를 `pattern`이 소문자·숫자로 |
|
| 49 |
+ * 묶고, 그 안에서 **두 종류가 다 있어야 한다**를 `characterKinds(2)`가 본다. |
|
| 50 |
+ */ |
|
| 51 |
+export const ADMIN_LOGIN_ID_VALIDATORS: FoxValidator[] = [ |
|
| 52 |
+ foxValidators.required, |
|
| 53 |
+ foxValidators.minLength(LOGIN_ID_MIN_LENGTH), |
|
| 54 |
+ foxValidators.maxLength(LOGIN_ID_MAX_LENGTH), |
|
| 55 |
+ foxValidators.pattern(/^[a-z0-9]+$/), |
|
| 56 |
+ foxValidators.characterKinds(2), |
|
| 57 |
+]; |
|
| 58 |
+ |
|
| 59 |
+/** 위 검증기의 오류를 시안 문구로 옮긴다. 길이 두 종류는 같은 말이라 한 문장으로 합친다. */ |
|
| 60 |
+export const ADMIN_LOGIN_ID_MESSAGES: FoxValidationMessages = {
|
|
| 61 |
+ required: 'ID를 입력해 주세요.', |
|
| 62 |
+ minlength: `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`,
|
|
| 63 |
+ maxlength: `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`,
|
|
| 64 |
+ pattern: ADMIN_LOGIN_ID_HELP_TEXT, |
|
| 65 |
+ characterKinds: ADMIN_LOGIN_ID_HELP_TEXT, |
|
| 66 |
+}; |
|
| 67 |
+ |
|
| 68 |
+/** |
|
| 38 | 69 |
* 비밀번호 규칙 — **화면과 서버가 같은 값을 본다.** 팝업은 이 값을 `foxPasswordValidator`에 |
| 39 | 70 |
* 그대로 넘겨 입력 중에 안내하고, 서버 검증은 아래 `validateEditableValues`가 같은 값으로 판정한다. |
| 40 | 71 |
* 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다. |
... | ... | @@ -45,6 +76,24 @@ |
| 45 | 76 |
} as const; |
| 46 | 77 |
const NAME_MAX_LENGTH = 50; |
| 47 | 78 |
const EMAIL_MAX_LENGTH = 100; |
| 79 |
+ |
|
| 80 |
+/** |
|
| 81 |
+ * 이메일 규칙. **필수 여부만 두 시안이 다르다** — 등록(102_p)에는 `*`가 있고 수정(103_p)에는 |
|
| 82 |
+ * 없어서, 그 하나만 인자로 받고 나머지는 공유한다. |
|
| 83 |
+ */ |
|
| 84 |
+export function adminEmailValidators(required: boolean): FoxValidator[] {
|
|
| 85 |
+ return [ |
|
| 86 |
+ ...(required ? [foxValidators.required] : []), |
|
| 87 |
+ foxValidators.email, |
|
| 88 |
+ foxValidators.maxLength(EMAIL_MAX_LENGTH), |
|
| 89 |
+ ]; |
|
| 90 |
+} |
|
| 91 |
+ |
|
| 92 |
+export const ADMIN_EMAIL_MESSAGES: FoxValidationMessages = {
|
|
| 93 |
+ required: '이메일을 입력해 주세요.', |
|
| 94 |
+ email: '이메일 형식이 올바르지 않습니다.', |
|
| 95 |
+ maxlength: `이메일은 ${EMAIL_MAX_LENGTH}자 이내로 입력해 주세요.`,
|
|
| 96 |
+}; |
|
| 48 | 97 |
|
| 49 | 98 |
/** 앞 3자리 + 가운데 3~4자리 + 끝 4자리. 화면은 숫자만 다루고 하이픈은 여기서 붙인다. */ |
| 50 | 99 |
const PHONE_DIGITS_PATTERN = /^(\d{3})(\d{3,4})(\d{4})$/;
|
... | ... | @@ -104,22 +153,12 @@ |
| 104 | 153 |
* ID 형식 검증 — 문제가 있으면 안내 문구, 없으면 null. |
| 105 | 154 |
* |
| 106 | 155 |
* 중복 확인(`checkAdminLoginId`)도 이 함수를 그대로 쓴다 — 중복을 묻기 전에 형식부터 봐야 하고, |
| 107 |
- * 그 판단 기준이 등록 시점과 달라지면 안 되기 때문이다. |
|
| 156 |
+ * 그 판단 기준이 등록 시점과 달라지면 안 되기 때문이다. 화면이 얹는 검증기와 **같은 배열**을 |
|
| 157 |
+ * 돌리므로 서버가 돌려주는 문구와 입력 중에 보이는 문구가 같다. |
|
| 108 | 158 |
*/ |
| 109 | 159 |
export function validateAdminLoginId(loginId: string): string | null {
|
| 110 |
- const value = loginId.trim(); |
|
| 111 |
- |
|
| 112 |
- if (!value) {
|
|
| 113 |
- return 'ID를 입력해 주세요.'; |
|
| 114 |
- } |
|
| 115 |
- if (value.length < LOGIN_ID_MIN_LENGTH || value.length > LOGIN_ID_MAX_LENGTH) {
|
|
| 116 |
- return `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`;
|
|
| 117 |
- } |
|
| 118 |
- // "영어 소문자, 숫자를 조합" — 허용 문자를 두 종류로 제한하고, 둘 다 포함되어야 한다. |
|
| 119 |
- if (!/^[a-z0-9]+$/.test(value) || !/[a-z]/.test(value) || !/\d/.test(value)) {
|
|
| 120 |
- return ADMIN_LOGIN_ID_HELP_TEXT; |
|
| 121 |
- } |
|
| 122 |
- return null; |
|
| 160 |
+ const errors = foxValidators.compose(ADMIN_LOGIN_ID_VALIDATORS)(loginId.trim()); |
|
| 161 |
+ return foxValidationMessage(errors, ADMIN_LOGIN_ID_MESSAGES) ?? null; |
|
| 123 | 162 |
} |
| 124 | 163 |
|
| 125 | 164 |
/** |
... | ... | @@ -137,14 +176,6 @@ |
| 137 | 176 |
/** 저장된 번호에서 숫자만 남긴다(수정 팝업의 초기값). */ |
| 138 | 177 |
export function toPhoneDigits(phoneNumber: string | null): string {
|
| 139 | 178 |
return (phoneNumber ?? '').replace(/\D/g, ''); |
| 140 |
-} |
|
| 141 |
- |
|
| 142 |
-function isValidEmail(email: string): boolean {
|
|
| 143 |
- // 공백 없는 `로컬부@도메인.최상위` 정도만 본다 — 이메일의 완전한 문법 검증은 정규식으로 |
|
| 144 |
- // 할 수 없고, 실제 유효성은 발송으로만 확인된다. 오탈자를 걸러내는 것이 목적이다. |
|
| 145 |
- return ( |
|
| 146 |
- /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= EMAIL_MAX_LENGTH |
|
| 147 |
- ); |
|
| 148 | 179 |
} |
| 149 | 180 |
|
| 150 | 181 |
/** |
... | ... | @@ -179,13 +210,12 @@ |
| 179 | 210 |
errors.phoneNumber = '휴대전화 번호를 정확히 입력해 주세요.'; |
| 180 | 211 |
} |
| 181 | 212 |
|
| 182 |
- // 이메일 필수 여부는 두 시안이 다르다 — 등록(102_p)에는 `*`가 있고 수정(103_p)에는 없다. |
|
| 183 |
- if (!email) {
|
|
| 184 |
- if (options.emailRequired) {
|
|
| 185 |
- errors.email = '이메일을 입력해 주세요.'; |
|
| 186 |
- } |
|
| 187 |
- } else if (!isValidEmail(email)) {
|
|
| 188 |
- errors.email = '이메일 형식이 올바르지 않습니다.'; |
|
| 213 |
+ const emailError = foxValidationMessage( |
|
| 214 |
+ foxValidators.compose(adminEmailValidators(options.emailRequired))(email), |
|
| 215 |
+ ADMIN_EMAIL_MESSAGES |
|
| 216 |
+ ); |
|
| 217 |
+ if (emailError) {
|
|
| 218 |
+ errors.email = emailError; |
|
| 189 | 219 |
} |
| 190 | 220 |
|
| 191 | 221 |
if (!ADMIN_ROLE_OPTIONS.some((option) => option.value === values.roleCode)) {
|
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?