feat: @fox 입력 검증기(Angular Validators 계약) + 중복 확인 실패 사유 표시
두 변경이 등록 팝업의 같은 파일에 겹쳐 한 커밋으로 둔다.
## 중복 확인 실패 사유
확인 호출이 백엔드에서 깨지면 `isAdminLoginIdTaken`이 던진 예외가 클라이언트 transition의
처리되지 않은 rejection으로 사라졌다 — 버튼만 원복되고 아무 말도 남지 않아 "눌러도 아무 일이
없는" 상태로 보였다. 확인 결과에 `failed`를 더해 **값의 판정(unavailable)과 확인 실패(failed)를
구분**하고, 백엔드 메시지를 ID 칸 아래 같은 자리에 그대로 띄운다.
## @fox/core/validation
Angular `Validators`의 계약을 옮겼다 — 검증기는 값을 받아 통과면 `null`, 아니면 **오류 객체**를
돌려주는 순수 함수이고, 키가 오류 이름, 값이 문구를 만들 맥락이다
(`{ minlength: { requiredLength, actualLength } }`). 인자가 `AbstractControl`이 아니라 문자열인
것만 다르다 — @fox에는 폼 모델이 없고 검증 대상이 칸의 값 하나뿐이다.
`required`를 뺀 검증기는 빈 값을 통과시키고(Angular와 같다), `compose`는 오류를 **병합**해
"10자 이상"과 "2종류 이상"을 함께 판정할 수 있게 한다.
**문구는 검증기가 갖지 않는다**(Angular와 같은 이유 — 같은 규칙도 자리마다 다른 말이 필요하다).
`foxValidationMessage(errors, overrides)`가 옮기고, 한 번에 한 문구만 낸다.
비밀번호처럼 규칙이 여러 개인 칸을 위해 `foxPasswordValidator(policy)`를 둔다 — 길이·조합에
더해 같은 문자 연속·잇따르는 문자·특정 문자열 포함 금지를 옵션으로 받는다.
## FoxInput 연결
`validators` · `validationMessages` · `validateOn` · `onValidationChange`.
- `message`는 평소 헬퍼이고 검증이 걸리면 그 자리에 사유가 들어간다. 호출부가 `invalid`를 직접
켜면 `message`가 이긴다 — 서버가 준 사유(중복·권한)는 화면 규칙이 알 수 없어 덮이면 안 된다.
- 오류는 **한 번 포커스를 벗어난 뒤부터** 보이고 그 뒤로는 입력할 때마다 갱신된다
(Angular의 `touched`. `updateOn` 기본값만 다르다 — 한 글자에 "10자 이상"이 뜨면 방해가 된다).
## 사용
비밀번호 규칙을 `ADMIN_PASSWORD_POLICY` 한 곳으로 모아 **화면과 Server Action이 같은 값을 본다**
— 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다. 등록은 `required: true`,
수정은 비우면 "바꾸지 않음"이라 걸지 않는다.
Co-Authored-By: Claude Opus 5
@bf0a8aae3000cd596414e97442a1b4bcacf9a780
--- @fox/README.md
+++ @fox/README.md
... | ... | @@ -16,6 +16,7 @@ |
| 16 | 16 |
_mixins.scss fox.pc / fox.mobile |
| 17 | 17 |
core/ |
| 18 | 18 |
components/ React 래퍼 (선택 — 아래 "React 없이 쓰기" 참고) |
| 19 |
+ validation/ 입력 검증기 (프레임워크 무관 — 아래 "입력 검증" 참고) |
|
| 19 | 20 |
utils/ cx() 등 의존성 없는 유틸 |
| 20 | 21 |
dev-test/ 개발 전용 테스트·감사 화면 |
| 21 | 22 |
tools/build-tokens.py Figma JSON → SCSS 변환기 |
... | ... | @@ -64,6 +65,45 @@ |
| 64 | 65 |
6. **폰트를 공급합니다** — 아래 "호스트 앱과의 계약" 참고. 빠뜨리면 컴포넌트가 OS 기본 |
| 65 | 66 |
서체로 그려져 글자 폭이 시안과 어긋납니다. |
| 66 | 67 |
|
| 68 |
+## 입력 검증 |
|
| 69 |
+ |
|
| 70 |
+`@fox/core/validation`은 Angular의 `Validators` 계약을 그대로 옮긴 것입니다. **검증기는 값을 받아 |
|
| 71 |
+통과면 `null`, 아니면 오류 객체를 돌려주는 순수 함수**이고, 오류 객체의 키가 오류의 이름입니다. |
|
| 72 |
+ |
|
| 73 |
+```ts |
|
| 74 |
+import { foxValidators, foxPasswordValidator, foxValidationMessage } from "@fox/core/validation";
|
|
| 75 |
+ |
|
| 76 |
+foxValidators.minLength(10)("abc");
|
|
| 77 |
+// → { minlength: { requiredLength: 10, actualLength: 3 } }
|
|
| 78 |
+``` |
|
| 79 |
+ |
|
| 80 |
+문구는 검증기가 정하지 않습니다(Angular와 같은 이유입니다) — 같은 규칙도 자리에 따라 다른 말로 |
|
| 81 |
+안내해야 하기 때문입니다. `foxValidationMessage(errors, overrides)`가 오류를 문구 하나로 옮기고, |
|
| 82 |
+`overrides`로 자리마다 덮어씁니다. |
|
| 83 |
+ |
|
| 84 |
+`FoxInput`은 이것을 `validators`·`validationMessages`·`validateOn`·`onValidationChange`로 받습니다. |
|
| 85 |
+`type="password"`처럼 규칙이 여러 개인 칸은 `foxPasswordValidator`가 한 벌로 묶어 줍니다. |
|
| 86 |
+ |
|
| 87 |
+```tsx |
|
| 88 |
+<FoxInput |
|
| 89 |
+ type="password" |
|
| 90 |
+ label="비밀번호" |
|
| 91 |
+ message="영문·숫자·특수문자 중 2종류 이상, 10자리 이상" // 평소엔 헬퍼 |
|
| 92 |
+ validators={[foxPasswordValidator({ minLength: 10, kinds: 2, required: true })]}
|
|
| 93 |
+/> |
|
| 94 |
+``` |
|
| 95 |
+ |
|
| 96 |
+- `message`는 평소 **헬퍼**이고, 검증이 걸리면 그 자리에 사유가 대신 들어갑니다. 호출부가 |
|
| 97 |
+ `invalid`를 직접 켠 경우에는 `message`가 이깁니다 — 서버가 돌려준 사유(중복·권한 등)는 화면 |
|
| 98 |
+ 규칙이 알 수 없는 것이라 덮이면 안 됩니다. |
|
| 99 |
+- 오류는 **한 번 포커스를 벗어난 뒤부터** 보입니다(`validateOn="blur"`, 기본값). 한 글자 쳤을 때 |
|
| 100 |
+ "10자 이상"이 뜨면 안내가 아니라 방해가 되기 때문입니다. 벗어난 뒤로는 입력할 때마다 갱신됩니다 |
|
| 101 |
+ (Angular의 `touched`와 같은 판단입니다 — 다만 Angular `updateOn`의 기본값은 `change`입니다). |
|
| 102 |
+- **화면 검증은 안내일 뿐 신뢰 경계가 아닙니다.** 최종 판정은 저장 직전의 서버 검증입니다. |
|
| 103 |
+ |
|
| 104 |
+검증기는 React에 의존하지 않으므로 다른 프레임워크·서버 코드에서도 그대로 씁니다 — 실제로 이 |
|
| 105 |
+저장소는 같은 규칙 값을 화면과 Server Action이 함께 봅니다. |
|
| 106 |
+ |
|
| 67 | 107 |
## React 없이 쓰기 |
| 68 | 108 |
|
| 69 | 109 |
**디자인 레이어는 프레임워크에 의존하지 않습니다.** 토큰도 컴포넌트 스타일도 순수 SCSS이고, |
--- @fox/core/components/fox-input/fox-input.tsx
+++ @fox/core/components/fox-input/fox-input.tsx
... | ... | @@ -1,16 +1,25 @@ |
| 1 | 1 |
"use client"; |
| 2 | 2 |
|
| 3 | 3 |
import {
|
| 4 |
+ useEffect, |
|
| 4 | 5 |
useId, |
| 5 | 6 |
useRef, |
| 6 | 7 |
useState, |
| 7 | 8 |
type ChangeEvent, |
| 8 | 9 |
type ComponentPropsWithRef, |
| 10 |
+ type FocusEvent, |
|
| 9 | 11 |
type FormEvent, |
| 10 | 12 |
type ReactNode, |
| 11 | 13 |
} from "react"; |
| 12 | 14 |
import { cx } from "../../utils";
|
| 13 | 15 |
import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
|
| 16 |
+import {
|
|
| 17 |
+ foxValidationMessage, |
|
| 18 |
+ foxValidators, |
|
| 19 |
+ type FoxValidationErrors, |
|
| 20 |
+ type FoxValidationMessages, |
|
| 21 |
+ type FoxValidator, |
|
| 22 |
+} from "../../validation"; |
|
| 14 | 23 |
|
| 15 | 24 |
export type FoxInputSize = "lg" | "md" | "sm"; |
| 16 | 25 |
export type FoxInputState = "default" | "focused" | "completed" | "error" | "disabled" | "view"; |
... | ... | @@ -46,6 +55,24 @@ |
| 46 | 55 |
invalid?: boolean; |
| 47 | 56 |
/** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ |
| 48 | 57 |
state?: FoxInputState; |
| 58 |
+ |
|
| 59 |
+ /** |
|
| 60 |
+ * 값 검증기. 여럿이면 오류가 합쳐지고(`foxValidators.compose`와 같다) 그중 하나가 문구가 된다. |
|
| 61 |
+ * `type="password"`에는 `foxPasswordValidator(...)`를 그대로 얹으면 된다. |
|
| 62 |
+ * |
|
| 63 |
+ * 화면 검증은 **안내일 뿐 신뢰 경계가 아니다** — 저장 직전의 서버 검증이 최종 판정이다. |
|
| 64 |
+ */ |
|
| 65 |
+ validators?: FoxValidator[]; |
|
| 66 |
+ /** 오류 키별 문구 덮어쓰기. 주지 않으면 @fox의 기본 문구가 나온다. */ |
|
| 67 |
+ validationMessages?: FoxValidationMessages; |
|
| 68 |
+ /** |
|
| 69 |
+ * 언제부터 오류를 보여줄지. 기본 `blur` — 한 글자 쳤을 때 "10자 이상"이 뜨면 안내가 아니라 |
|
| 70 |
+ * 방해가 된다. 한 번 벗어난 뒤로는 두 모드 모두 입력할 때마다 갱신된다(Angular의 `touched`와 |
|
| 71 |
+ * 같은 판단이다. 다만 Angular의 `updateOn` 기본값은 `change`라 그 점만 다르다). |
|
| 72 |
+ */ |
|
| 73 |
+ validateOn?: "blur" | "change"; |
|
| 74 |
+ /** 검증 결과가 바뀔 때. 호출부가 저장 버튼을 잠그는 데 쓴다. */ |
|
| 75 |
+ onValidationChange?: (errors: FoxValidationErrors | null) => void; |
|
| 49 | 76 |
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ |
| 50 | 77 |
hidden?: boolean; |
| 51 | 78 |
/** 배치 조정용. */ |
... | ... | @@ -67,6 +94,11 @@ |
| 67 | 94 |
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` |
| 68 | 95 |
* (또는 개별 파티셜)로 한 번 불러와야 한다. |
| 69 | 96 |
*/ |
| 97 |
+/** 오류 객체를 비교 가능한 문자열로. 매 렌더 새 객체가 나와 참조로는 견줄 수 없다. */ |
|
| 98 |
+function errorKey(errors: FoxValidationErrors | null): string {
|
|
| 99 |
+ return errors ? Object.keys(errors).sort().join("|") : "";
|
|
| 100 |
+} |
|
| 101 |
+ |
|
| 70 | 102 |
export function FoxInput({
|
| 71 | 103 |
size = "md", |
| 72 | 104 |
onChange, |
... | ... | @@ -80,6 +112,10 @@ |
| 80 | 112 |
clearLabel = "입력 지우기", |
| 81 | 113 |
invalid = false, |
| 82 | 114 |
state, |
| 115 |
+ validators, |
|
| 116 |
+ validationMessages, |
|
| 117 |
+ validateOn = "blur", |
|
| 118 |
+ onValidationChange, |
|
| 83 | 119 |
hidden = false, |
| 84 | 120 |
className, |
| 85 | 121 |
id, |
... | ... | @@ -94,6 +130,28 @@ |
| 94 | 130 |
const [typedIn, setTypedIn] = useState(() => String(defaultValue ?? "").length > 0); |
| 95 | 131 |
const fieldRef = useRef<HTMLInputElement>(null); |
| 96 | 132 |
|
| 133 |
+ // 값을 밖에서 들지 않는(비제어) 호출부도 있어 검증할 값을 직접 따라간다. |
|
| 134 |
+ const [draft, setDraft] = useState(() => String(defaultValue ?? "")); |
|
| 135 |
+ const [touched, setTouched] = useState(false); |
|
| 136 |
+ |
|
| 137 |
+ const validationErrors = |
|
| 138 |
+ validators && validators.length > 0 |
|
| 139 |
+ ? foxValidators.compose(validators)( |
|
| 140 |
+ value !== undefined ? String(value) : draft |
|
| 141 |
+ ) |
|
| 142 |
+ : null; |
|
| 143 |
+ |
|
| 144 |
+ // 알림은 렌더가 끝난 뒤에 낸다 — 렌더 도중 부모 상태를 바꾸면 React가 경고한다. |
|
| 145 |
+ // 매 렌더 새 객체가 나오므로 의존은 오류 **키 목록**으로 잡는다(값 자체는 ref로 읽는다). |
|
| 146 |
+ const errorsKey = errorKey(validationErrors); |
|
| 147 |
+ const latestRef = useRef({ errors: validationErrors, notify: onValidationChange });
|
|
| 148 |
+ useEffect(() => {
|
|
| 149 |
+ latestRef.current = { errors: validationErrors, notify: onValidationChange };
|
|
| 150 |
+ }); |
|
| 151 |
+ useEffect(() => {
|
|
| 152 |
+ latestRef.current.notify?.(latestRef.current.errors); |
|
| 153 |
+ }, [errorsKey]); |
|
| 154 |
+ |
|
| 97 | 155 |
if (hidden) {
|
| 98 | 156 |
return null; |
| 99 | 157 |
} |
... | ... | @@ -101,7 +159,19 @@ |
| 101 | 159 |
const fieldId = id ?? `${autoId}-field`;
|
| 102 | 160 |
const messageId = `${autoId}-message`;
|
| 103 | 161 |
const hasValue = value !== undefined ? String(value).length > 0 : typedIn; |
| 104 |
- const errored = invalid || state === "error"; |
|
| 162 |
+ |
|
| 163 |
+ const showValidation = validateOn === "change" || touched; |
|
| 164 |
+ const validationText = showValidation |
|
| 165 |
+ ? foxValidationMessage(validationErrors, validationMessages) |
|
| 166 |
+ : undefined; |
|
| 167 |
+ |
|
| 168 |
+ // `message`는 평소엔 **헬퍼**이고 검증이 걸리면 그 문구에 자리를 내준다(시안이 그 자리에 |
|
| 169 |
+ // 규칙 안내를 두고, 규칙을 어기면 같은 자리가 사유로 바뀐다). |
|
| 170 |
+ // 다만 호출부가 `invalid`를 직접 켰으면 그쪽이 이긴다 — 서버가 돌려준 사유는 화면 규칙이 |
|
| 171 |
+ // 알 수 없는 것이라(중복·권한 등) 덮이면 안 된다. |
|
| 172 |
+ const callerErrored = invalid || state === "error"; |
|
| 173 |
+ const shownMessage = callerErrored ? message : (validationText ?? message); |
|
| 174 |
+ const errored = callerErrored || validationText !== undefined; |
|
| 105 | 175 |
|
| 106 | 176 |
// 호출부 ref와 내부 ref를 함께 채운다 — 지우기가 비제어 입력의 DOM 값을 비워야 한다. |
| 107 | 177 |
const attachField = (node: HTMLInputElement | null) => {
|
... | ... | @@ -117,7 +187,13 @@ |
| 117 | 187 |
if (value === undefined) {
|
| 118 | 188 |
setTypedIn(event.target.value.length > 0); |
| 119 | 189 |
} |
| 190 |
+ setDraft(event.target.value); |
|
| 120 | 191 |
onChange?.(event.target.value, event); |
| 192 |
+ }; |
|
| 193 |
+ |
|
| 194 |
+ const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
|
| 195 |
+ setTouched(true); |
|
| 196 |
+ rest.onBlur?.(event); |
|
| 121 | 197 |
}; |
| 122 | 198 |
|
| 123 | 199 |
const handleInput = (event: FormEvent<HTMLInputElement>) => {
|
... | ... | @@ -132,6 +208,7 @@ |
| 132 | 208 |
field.focus(); |
| 133 | 209 |
} |
| 134 | 210 |
setTypedIn(false); |
| 211 |
+ setDraft("");
|
|
| 135 | 212 |
onChange?.("");
|
| 136 | 213 |
}; |
| 137 | 214 |
|
... | ... | @@ -155,9 +232,10 @@ |
| 155 | 232 |
value={value}
|
| 156 | 233 |
defaultValue={defaultValue}
|
| 157 | 234 |
aria-invalid={errored || undefined}
|
| 158 |
- aria-describedby={message ? messageId : undefined}
|
|
| 235 |
+ aria-describedby={shownMessage ? messageId : undefined}
|
|
| 159 | 236 |
onChange={handleChange}
|
| 160 | 237 |
onInput={handleInput}
|
| 238 |
+ onBlur={handleBlur}
|
|
| 161 | 239 |
/> |
| 162 | 240 |
|
| 163 | 241 |
{clearable && hasValue && (
|
... | ... | @@ -182,14 +260,14 @@ |
| 182 | 260 |
)} |
| 183 | 261 |
</div> |
| 184 | 262 |
|
| 185 |
- {message && (
|
|
| 263 |
+ {shownMessage && (
|
|
| 186 | 264 |
<p className="fox-input__message" id={messageId}>
|
| 187 | 265 |
{messageIcon && (
|
| 188 | 266 |
<span className="fox-input__message-icon" aria-hidden="true"> |
| 189 | 267 |
{messageIcon}
|
| 190 | 268 |
</span> |
| 191 | 269 |
)} |
| 192 |
- {message}
|
|
| 270 |
+ {shownMessage}
|
|
| 193 | 271 |
</p> |
| 194 | 272 |
)} |
| 195 | 273 |
</div> |
+++ @fox/core/validation/fox-validation-messages.ts
... | ... | @@ -0,0 +1,97 @@ |
| 1 | +/** | |
| 2 | + * 오류 객체 → 화면 문구. | |
| 3 | + * | |
| 4 | + * 검증기와 분리한 이유는 Angular가 `Validators`에 문구를 두지 않은 이유와 같다 — 같은 규칙도 | |
| 5 | + * 자리에 따라 다른 말로 안내해야 한다("10자 이상"과 "비밀번호는 10자 이상"). 검증기는 무엇이 | |
| 6 | + * 틀렸는지만 말하고, 그것을 무슨 말로 옮길지는 여기서(또는 호출부의 `overrides`로) 정한다. | |
| 7 | + * | |
| 8 | + * 한 번에 **한 문구만** 낸다 — 입력 칸 아래 헬퍼 자리가 한 줄이고, 오류를 쌓아 보여주면 어느 | |
| 9 | + * 것부터 고쳐야 할지 알기 어렵다. 순서는 `MESSAGE_ORDER`가 정한다(비어 있음 → 길이 → 조합). | |
| 10 | + */ | |
| 11 | + | |
| 12 | +import type { FoxValidationErrors } from './fox-validators'; | |
| 13 | + | |
| 14 | +/** 오류 키별 기본 문구. 값이 필요한 것은 오류 객체의 맥락을 받아 만든다. */ | |
| 15 | +type MessageFactory = (detail: unknown) => string; | |
| 16 | + | |
| 17 | +function detailOf<T>(detail: unknown): T { | |
| 18 | + return detail as T; | |
| 19 | +} | |
| 20 | + | |
| 21 | +const DEFAULT_MESSAGES: Record<string, MessageFactory> = { | |
| 22 | + required: () => '필수 입력 항목입니다.', | |
| 23 | + minlength: (detail) => | |
| 24 | + `${detailOf<{ requiredLength: number }>(detail).requiredLength}자 이상 입력해 주세요.`, | |
| 25 | + maxlength: (detail) => | |
| 26 | + `${detailOf<{ requiredLength: number }>(detail).requiredLength}자 이내로 입력해 주세요.`, | |
| 27 | + pattern: () => '형식이 올바르지 않습니다.', | |
| 28 | + email: () => '이메일 형식이 올바르지 않습니다.', | |
| 29 | + characterKinds: (detail) => | |
| 30 | + `영문, 숫자, 특수문자 중 ${ | |
| 31 | + detailOf<{ requiredKinds: number }>(detail).requiredKinds | |
| 32 | + }종류 이상을 섞어 주세요.`, | |
| 33 | + repeatedCharacters: (detail) => | |
| 34 | + `같은 문자를 ${detailOf<{ max: number }>(detail).max}번까지만 연달아 쓸 수 있습니다.`, | |
| 35 | + sequentialCharacters: (detail) => | |
| 36 | + `연속된 문자나 숫자를 ${ | |
| 37 | + detailOf<{ max: number }>(detail).max | |
| 38 | + }자를 넘겨 쓸 수 없습니다.`, | |
| 39 | + notContaining: (detail) => | |
| 40 | + `${detailOf<{ forbidden: string }>(detail).forbidden}은(는) 포함할 수 없습니다.`, | |
| 41 | + equalTo: () => '값이 일치하지 않습니다.', | |
| 42 | +}; | |
| 43 | + | |
| 44 | +/** 먼저 고쳐야 하는 것부터. 여기 없는 키는 뒤에 남은 순서대로 본다. */ | |
| 45 | +const MESSAGE_ORDER = [ | |
| 46 | + 'required', | |
| 47 | + 'minlength', | |
| 48 | + 'maxlength', | |
| 49 | + 'pattern', | |
| 50 | + 'email', | |
| 51 | + 'characterKinds', | |
| 52 | + 'repeatedCharacters', | |
| 53 | + 'sequentialCharacters', | |
| 54 | + 'notContaining', | |
| 55 | + 'equalTo', | |
| 56 | +]; | |
| 57 | + | |
| 58 | +/** 오류 키 → 문구 덮어쓰기. 문자열이면 그대로, 함수면 맥락을 받아 만든다. */ | |
| 59 | +export type FoxValidationMessages = Record< | |
| 60 | + string, | |
| 61 | + string | ((detail: unknown) => string) | |
| 62 | +>; | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * 오류 객체에서 보여줄 문구 하나를 고른다. 오류가 없으면 `undefined`. | |
| 66 | + * 덮어쓰기에 없는 키는 기본 문구로 떨어지고, 기본 문구에도 없으면 키를 그대로 쓰지 않고 | |
| 67 | + * `undefined`를 돌려준다 — 정체불명의 영문 키를 사용자에게 보이지 않기 위해서다. | |
| 68 | + */ | |
| 69 | +export function foxValidationMessage( | |
| 70 | + errors: FoxValidationErrors | null, | |
| 71 | + overrides: FoxValidationMessages = {} | |
| 72 | +): string | undefined { | |
| 73 | + if (!errors) { | |
| 74 | + return undefined; | |
| 75 | + } | |
| 76 | + | |
| 77 | + const keys = Object.keys(errors); | |
| 78 | + const ordered = [ | |
| 79 | + ...MESSAGE_ORDER.filter((key) => keys.includes(key)), | |
| 80 | + ...keys.filter((key) => !MESSAGE_ORDER.includes(key)), | |
| 81 | + ]; | |
| 82 | + | |
| 83 | + for (const key of ordered) { | |
| 84 | + const override = overrides[key]; | |
| 85 | + if (typeof override === 'string') { | |
| 86 | + return override; | |
| 87 | + } | |
| 88 | + if (typeof override === 'function') { | |
| 89 | + return override(errors[key]); | |
| 90 | + } | |
| 91 | + const fallback = DEFAULT_MESSAGES[key]; | |
| 92 | + if (fallback) { | |
| 93 | + return fallback(errors[key]); | |
| 94 | + } | |
| 95 | + } | |
| 96 | + return undefined; | |
| 97 | +} |
+++ @fox/core/validation/fox-validators.ts
... | ... | @@ -0,0 +1,235 @@ |
| 1 | +/** | |
| 2 | + * @fox 입력 검증기 — Angular `Validators`의 계약을 그대로 옮겼다. | |
| 3 | + * | |
| 4 | + * 검증기는 **값을 받아 통과면 `null`, 아니면 오류 객체**를 돌려주는 순수 함수다. 오류 객체의 | |
| 5 | + * 키가 곧 오류의 이름이고(`{ minlength: … }`), 값에는 문구를 만드는 데 필요한 맥락을 담는다 | |
| 6 | + * (`{ requiredLength, actualLength }`). Angular가 이 모양을 쓰는 이유가 여기서도 그대로다 — | |
| 7 | + * **문구를 검증기가 정하지 않기 때문에** 같은 규칙을 화면마다 다른 말로 안내할 수 있다. | |
| 8 | + * 기본 문구는 `fox-validation-messages.ts`가 따로 갖는다. | |
| 9 | + * | |
| 10 | + * Angular와 다른 점은 하나다: 인자가 `AbstractControl`이 아니라 문자열이다. @fox에는 폼 모델이 | |
| 11 | + * 없고 검증 대상이 입력 칸의 값 하나뿐이라, 컨트롤 객체를 두면 감싸는 비용만 늘어난다. | |
| 12 | + * | |
| 13 | + * `required`를 뺀 모든 검증기는 **빈 값을 통과시킨다**(Angular와 같다) — "비어 있음"의 판정은 | |
| 14 | + * `required` 하나가 맡아야 빈 칸에 오류가 두 개씩 뜨지 않는다. | |
| 15 | + */ | |
| 16 | + | |
| 17 | +export type FoxValidationErrors = Record<string, unknown>; | |
| 18 | + | |
| 19 | +/** 통과면 `null`, 아니면 오류 객체. */ | |
| 20 | +export type FoxValidator = (value: string) => FoxValidationErrors | null; | |
| 21 | + | |
| 22 | +function isEmpty(value: string): boolean { | |
| 23 | + return value.trim().length === 0; | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** 값이 비어 있으면 `{ required: true }`. */ | |
| 27 | +function required(value: string): FoxValidationErrors | null { | |
| 28 | + return isEmpty(value) ? { required: true } : null; | |
| 29 | +} | |
| 30 | + | |
| 31 | +function minLength(requiredLength: number): FoxValidator { | |
| 32 | + return (value) => { | |
| 33 | + if (isEmpty(value) || value.length >= requiredLength) { | |
| 34 | + return null; | |
| 35 | + } | |
| 36 | + return { minlength: { requiredLength, actualLength: value.length } }; | |
| 37 | + }; | |
| 38 | +} | |
| 39 | + | |
| 40 | +function maxLength(requiredLength: number): FoxValidator { | |
| 41 | + return (value) => { | |
| 42 | + if (value.length <= requiredLength) { | |
| 43 | + return null; | |
| 44 | + } | |
| 45 | + return { maxlength: { requiredLength, actualLength: value.length } }; | |
| 46 | + }; | |
| 47 | +} | |
| 48 | + | |
| 49 | +function pattern(requiredPattern: RegExp): FoxValidator { | |
| 50 | + return (value) => { | |
| 51 | + if (isEmpty(value) || requiredPattern.test(value)) { | |
| 52 | + return null; | |
| 53 | + } | |
| 54 | + return { pattern: { requiredPattern: requiredPattern.source, actualValue: value } }; | |
| 55 | + }; | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** 오탈자를 걸러내는 수준만 본다 — 이메일의 완전한 문법은 정규식으로 판정할 수 없다. */ | |
| 59 | +function email(value: string): FoxValidationErrors | null { | |
| 60 | + if (isEmpty(value) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { | |
| 61 | + return null; | |
| 62 | + } | |
| 63 | + return { email: true }; | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** 비밀번호 조합에 쓰는 문자 종류. 대문자는 소문자와 한 종류로 센다(시안이 그렇게 묻는다). */ | |
| 67 | +const CHARACTER_KIND_PATTERNS = { | |
| 68 | + letter: /[a-zA-Z]/, | |
| 69 | + digit: /\d/, | |
| 70 | + symbol: /[^a-zA-Z0-9]/, | |
| 71 | +} as const; | |
| 72 | + | |
| 73 | +export type FoxCharacterKind = keyof typeof CHARACTER_KIND_PATTERNS; | |
| 74 | + | |
| 75 | +/** 영문·숫자·특수문자 중 몇 종류가 섞였는지 센다. */ | |
| 76 | +function countCharacterKinds(value: string): FoxCharacterKind[] { | |
| 77 | + return (Object.keys(CHARACTER_KIND_PATTERNS) as FoxCharacterKind[]).filter( | |
| 78 | + (kind) => CHARACTER_KIND_PATTERNS[kind].test(value) | |
| 79 | + ); | |
| 80 | +} | |
| 81 | + | |
| 82 | +/** "영문·숫자·특수문자 중 N종류 이상" — 비밀번호 규칙의 가장 흔한 형태다. */ | |
| 83 | +function characterKinds(requiredKinds: number): FoxValidator { | |
| 84 | + return (value) => { | |
| 85 | + if (isEmpty(value)) { | |
| 86 | + return null; | |
| 87 | + } | |
| 88 | + const kinds = countCharacterKinds(value); | |
| 89 | + if (kinds.length >= requiredKinds) { | |
| 90 | + return null; | |
| 91 | + } | |
| 92 | + return { characterKinds: { requiredKinds, actualKinds: kinds.length, kinds } }; | |
| 93 | + }; | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** 같은 문자가 `max`회를 넘겨 연달아 오면 오류(`aaa`). */ | |
| 97 | +function noRepeatedCharacters(max: number): FoxValidator { | |
| 98 | + return (value) => { | |
| 99 | + if (isEmpty(value)) { | |
| 100 | + return null; | |
| 101 | + } | |
| 102 | + let run = 1; | |
| 103 | + for (let i = 1; i < value.length; i += 1) { | |
| 104 | + run = value[i] === value[i - 1] ? run + 1 : 1; | |
| 105 | + if (run > max) { | |
| 106 | + return { repeatedCharacters: { max, character: value[i] } }; | |
| 107 | + } | |
| 108 | + } | |
| 109 | + return null; | |
| 110 | + }; | |
| 111 | +} | |
| 112 | + | |
| 113 | +/** 코드값이 잇따르는 문자가 `max`회를 넘기면 오류(`abcd`·`4321`). 오름·내림 둘 다 본다. */ | |
| 114 | +function noSequentialCharacters(max: number): FoxValidator { | |
| 115 | + return (value) => { | |
| 116 | + if (isEmpty(value)) { | |
| 117 | + return null; | |
| 118 | + } | |
| 119 | + let ascending = 1; | |
| 120 | + let descending = 1; | |
| 121 | + for (let i = 1; i < value.length; i += 1) { | |
| 122 | + const step = value.charCodeAt(i) - value.charCodeAt(i - 1); | |
| 123 | + ascending = step === 1 ? ascending + 1 : 1; | |
| 124 | + descending = step === -1 ? descending + 1 : 1; | |
| 125 | + if (ascending > max || descending > max) { | |
| 126 | + return { sequentialCharacters: { max, at: i - max } }; | |
| 127 | + } | |
| 128 | + } | |
| 129 | + return null; | |
| 130 | + }; | |
| 131 | +} | |
| 132 | + | |
| 133 | +/** | |
| 134 | + * 지정한 문자열을 품고 있으면 오류. 비밀번호에 ID·이름을 넣지 못하게 하는 규칙이다. | |
| 135 | + * | |
| 136 | + * 값을 함수로도 받는 이유는 그 대상이 **다른 칸의 지금 값**이기 때문이다 — 배열로 굳혀 두면 | |
| 137 | + * ID를 고친 뒤에도 옛 ID로 검사한다. | |
| 138 | + */ | |
| 139 | +function notContaining( | |
| 140 | + forbidden: string[] | (() => string[]), | |
| 141 | + options: { caseSensitive?: boolean } = {} | |
| 142 | +): FoxValidator { | |
| 143 | + return (value) => { | |
| 144 | + if (isEmpty(value)) { | |
| 145 | + return null; | |
| 146 | + } | |
| 147 | + const list = typeof forbidden === 'function' ? forbidden() : forbidden; | |
| 148 | + const haystack = options.caseSensitive ? value : value.toLowerCase(); | |
| 149 | + | |
| 150 | + for (const raw of list) { | |
| 151 | + const needle = options.caseSensitive ? raw : raw.toLowerCase(); | |
| 152 | + if (needle.length > 0 && haystack.includes(needle)) { | |
| 153 | + return { notContaining: { forbidden: raw } }; | |
| 154 | + } | |
| 155 | + } | |
| 156 | + return null; | |
| 157 | + }; | |
| 158 | +} | |
| 159 | + | |
| 160 | +/** 다른 칸과 값이 같아야 한다(비밀번호 확인). 대상이 바뀌므로 함수로 받는다. */ | |
| 161 | +function equalTo(other: () => string): FoxValidator { | |
| 162 | + return (value) => (isEmpty(value) || value === other() ? null : { equalTo: true }); | |
| 163 | +} | |
| 164 | + | |
| 165 | +/** | |
| 166 | + * 여러 검증기를 하나로 합친다. **통과하지 못한 것들의 오류 객체를 전부 병합해** 돌려준다 | |
| 167 | + * (Angular의 `Validators.compose`와 같다) — 첫 오류에서 멈추지 않아야 "10자 이상"과 | |
| 168 | + * "2종류 이상"을 함께 안내할 수 있다. | |
| 169 | + */ | |
| 170 | +function compose(validators: FoxValidator[]): FoxValidator { | |
| 171 | + return (value) => { | |
| 172 | + const merged = validators.reduce<FoxValidationErrors>((acc, validate) => { | |
| 173 | + const errors = validate(value); | |
| 174 | + return errors ? { ...acc, ...errors } : acc; | |
| 175 | + }, {}); | |
| 176 | + return Object.keys(merged).length > 0 ? merged : null; | |
| 177 | + }; | |
| 178 | +} | |
| 179 | + | |
| 180 | +export const foxValidators = { | |
| 181 | + required, | |
| 182 | + minLength, | |
| 183 | + maxLength, | |
| 184 | + pattern, | |
| 185 | + email, | |
| 186 | + characterKinds, | |
| 187 | + noRepeatedCharacters, | |
| 188 | + noSequentialCharacters, | |
| 189 | + notContaining, | |
| 190 | + equalTo, | |
| 191 | + compose, | |
| 192 | +}; | |
| 193 | + | |
| 194 | +export interface FoxPasswordPolicy { | |
| 195 | + /** 기본 8. 시안이 더 길게 요구하면 그 값을 준다. */ | |
| 196 | + minLength?: number; | |
| 197 | + maxLength?: number; | |
| 198 | + /** 영문·숫자·특수문자 중 최소 몇 종류를 섞을지. 기본 2. */ | |
| 199 | + kinds?: number; | |
| 200 | + /** 같은 문자 연속 허용 횟수. 주지 않으면 검사하지 않는다. */ | |
| 201 | + maxRepeated?: number; | |
| 202 | + /** 잇따르는 문자 허용 길이(`abc`). 주지 않으면 검사하지 않는다. */ | |
| 203 | + maxSequential?: number; | |
| 204 | + /** 비밀번호에 들어가면 안 되는 값(보통 ID·이름). 지금 값을 읽도록 함수로 줄 수 있다. */ | |
| 205 | + forbidden?: string[] | (() => string[]); | |
| 206 | + /** 참이면 비어 있는 것도 오류다. 수정 화면처럼 "비우면 유지"인 자리에서는 끈다. */ | |
| 207 | + required?: boolean; | |
| 208 | +} | |
| 209 | + | |
| 210 | +/** | |
| 211 | + * 비밀번호 칸의 규칙 한 벌. `type="password"` 입력에 그대로 얹으라고 둔 조합이다 — | |
| 212 | + * 규칙 자체는 위 검증기들이고, 이 함수는 자주 쓰는 묶음에 이름을 붙인 것뿐이다. | |
| 213 | + */ | |
| 214 | +export function foxPasswordValidator(policy: FoxPasswordPolicy = {}): FoxValidator { | |
| 215 | + const { | |
| 216 | + minLength: min = 8, | |
| 217 | + maxLength: max, | |
| 218 | + kinds = 2, | |
| 219 | + maxRepeated, | |
| 220 | + maxSequential, | |
| 221 | + forbidden, | |
| 222 | + required: isRequired = false, | |
| 223 | + } = policy; | |
| 224 | + | |
| 225 | + const validators: FoxValidator[] = []; | |
| 226 | + if (isRequired) validators.push(required); | |
| 227 | + validators.push(minLength(min)); | |
| 228 | + if (max !== undefined) validators.push(maxLength(max)); | |
| 229 | + validators.push(characterKinds(kinds)); | |
| 230 | + if (maxRepeated !== undefined) validators.push(noRepeatedCharacters(maxRepeated)); | |
| 231 | + if (maxSequential !== undefined) validators.push(noSequentialCharacters(maxSequential)); | |
| 232 | + if (forbidden !== undefined) validators.push(notContaining(forbidden)); | |
| 233 | + | |
| 234 | + return compose(validators); | |
| 235 | +} |
+++ @fox/core/validation/index.ts
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +export { | |
| 2 | + foxValidators, | |
| 3 | + foxPasswordValidator, | |
| 4 | + type FoxValidator, | |
| 5 | + type FoxValidationErrors, | |
| 6 | + type FoxCharacterKind, | |
| 7 | + type FoxPasswordPolicy, | |
| 8 | +} from "./fox-validators"; | |
| 9 | +export { | |
| 10 | + foxValidationMessage, | |
| 11 | + type FoxValidationMessages, | |
| 12 | +} from "./fox-validation-messages"; |
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
... | ... | @@ -41,6 +41,8 @@ |
| 41 | 41 |
const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; |
| 42 | 42 |
const SELF_DELETE_MESSAGE = '현재 로그인한 본인 계정은 삭제할 수 없습니다.'; |
| 43 | 43 |
const DUPLICATE_LOGIN_ID_MESSAGE = '이미 사용 중인 ID입니다.'; |
| 44 |
+const LOGIN_ID_CHECK_FAILED_MESSAGE = |
|
| 45 |
+ '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.'; |
|
| 44 | 46 |
|
| 45 | 47 |
function readString(formData: FormData, key: string): string {
|
| 46 | 48 |
const value = formData.get(key); |
... | ... | @@ -201,7 +203,10 @@ |
| 201 | 203 |
export type LoginIdCheckResult = |
| 202 | 204 |
| { status: 'idle' }
|
| 203 | 205 |
| { status: 'available'; loginId: string }
|
| 204 |
- | { status: 'unavailable'; message: string };
|
|
| 206 |
+ /** 쓸 수 없는 ID — 형식 오류이거나 이미 선점됐다. */ |
|
| 207 |
+ | { status: 'unavailable'; message: string }
|
|
| 208 |
+ /** 확인 자체를 못 했다(통신·권한 등). 값의 판정이 아니라 **확인 실패**다. */ |
|
| 209 |
+ | { status: 'failed'; message: string };
|
|
| 205 | 210 |
|
| 206 | 211 |
/** |
| 207 | 212 |
* 시안 ADM_ADM_102_p ① — ID 중복 확인. |
... | ... | @@ -226,8 +231,17 @@ |
| 226 | 231 |
return { status: 'unavailable', message: formatError };
|
| 227 | 232 |
} |
| 228 | 233 |
|
| 229 |
- if (await isAdminLoginIdTaken(normalized)) {
|
|
| 230 |
- return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
|
|
| 234 |
+ // 확인 호출이 실패하면 그 사유를 화면에 그대로 돌려준다 — 예외로 두면 클라이언트의 |
|
| 235 |
+ // transition에서 처리되지 않은 rejection이 되어 버튼만 원복되고 아무 말도 남지 않는다. |
|
| 236 |
+ try {
|
|
| 237 |
+ if (await isAdminLoginIdTaken(normalized)) {
|
|
| 238 |
+ return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
|
|
| 239 |
+ } |
|
| 240 |
+ } catch (error) {
|
|
| 241 |
+ if (error instanceof BackendRequestError) {
|
|
| 242 |
+ return { status: 'failed', message: error.message };
|
|
| 243 |
+ } |
|
| 244 |
+ return { status: 'failed', message: LOGIN_ID_CHECK_FAILED_MESSAGE };
|
|
| 231 | 245 |
} |
| 232 | 246 |
|
| 233 | 247 |
return { status: 'available', loginId: normalized };
|
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
... | ... | @@ -13,12 +13,14 @@ |
| 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 | 17 |
import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
|
| 17 | 18 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 18 | 19 |
import { DEFAULT_ADMIN_ROLE_CODE } from '@/lib/domain/admin-member';
|
| 19 | 20 |
import {
|
| 20 | 21 |
ADMIN_LOGIN_ID_HELP_TEXT, |
| 21 | 22 |
ADMIN_PASSWORD_HELP_TEXT, |
| 23 |
+ ADMIN_PASSWORD_POLICY, |
|
| 22 | 24 |
INITIAL_ADMIN_MEMBER_FORM_STATE, |
| 23 | 25 |
} from '@/lib/domain/admin-member-form'; |
| 24 | 26 |
import {
|
... | ... | @@ -101,12 +103,16 @@ |
| 101 | 103 |
const email = emailLocal || emailDomain ? `${emailLocal}@${emailDomain}` : '';
|
| 102 | 104 |
|
| 103 | 105 |
// 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다. |
| 104 |
- const loginIdMessage = |
|
| 105 |
- checkResult.status === 'unavailable' |
|
| 106 |
+ // 값이 나쁜 것(unavailable)과 확인을 못 한 것(failed)은 사유가 다르므로 둘 다 문구로 낸다. |
|
| 107 |
+ const loginIdFailure = |
|
| 108 |
+ checkResult.status === 'unavailable' || checkResult.status === 'failed' |
|
| 106 | 109 |
? checkResult.message |
| 107 |
- : isLoginIdVerified |
|
| 108 |
- ? '사용할 수 있는 ID입니다.' |
|
| 109 |
- : (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT); |
|
| 110 |
+ : undefined; |
|
| 111 |
+ const loginIdMessage = |
|
| 112 |
+ loginIdFailure ?? |
|
| 113 |
+ (isLoginIdVerified |
|
| 114 |
+ ? '사용할 수 있는 ID입니다.' |
|
| 115 |
+ : (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT)); |
|
| 110 | 116 |
|
| 111 | 117 |
return ( |
| 112 | 118 |
<FoxModal |
... | ... | @@ -151,9 +157,7 @@ |
| 151 | 157 |
onChange={setLoginId}
|
| 152 | 158 |
message={loginIdMessage}
|
| 153 | 159 |
messageIcon={<FoxChatCenteredDotsIcon />}
|
| 154 |
- invalid={
|
|
| 155 |
- checkResult.status === 'unavailable' || Boolean(errors.loginId) |
|
| 156 |
- } |
|
| 160 |
+ invalid={Boolean(loginIdFailure) || Boolean(errors.loginId)}
|
|
| 157 | 161 |
/> |
| 158 | 162 |
<FoxButton |
| 159 | 163 |
type="secondary" |
... | ... | @@ -164,7 +168,16 @@ |
| 164 | 168 |
disabled={loginId.trim().length === 0}
|
| 165 | 169 |
onAction={() =>
|
| 166 | 170 |
startChecking(async () => {
|
| 167 |
- setCheckResult(await checkAdminLoginId(loginId)); |
|
| 171 |
+ try {
|
|
| 172 |
+ setCheckResult(await checkAdminLoginId(loginId)); |
|
| 173 |
+ } catch {
|
|
| 174 |
+ // Server Action 호출 자체가 깨진 경우(네트워크 등). 조용히 끝나면 버튼만 |
|
| 175 |
+ // 원복되고 아무 말도 남지 않아, 사유를 알 수 없어도 실패는 알린다. |
|
| 176 |
+ setCheckResult({
|
|
| 177 |
+ status: 'failed', |
|
| 178 |
+ message: '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.', |
|
| 179 |
+ }); |
|
| 180 |
+ } |
|
| 168 | 181 |
}) |
| 169 | 182 |
} |
| 170 | 183 |
/> |
... | ... | @@ -181,6 +194,9 @@ |
| 181 | 194 |
message={errors.password ?? ADMIN_PASSWORD_HELP_TEXT}
|
| 182 | 195 |
messageIcon={<FoxChatCenteredDotsIcon />}
|
| 183 | 196 |
invalid={Boolean(errors.password)}
|
| 197 |
+ // 규칙은 도메인이 한 벌로 갖는다 — 저장 직전 서버 검증이 같은 값을 본다. |
|
| 198 |
+ validators={[foxPasswordValidator({ ...ADMIN_PASSWORD_POLICY, required: true })]}
|
|
| 199 |
+ validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
|
|
| 184 | 200 |
/> |
| 185 | 201 |
|
| 186 | 202 |
<AdminMemberFormFields |
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
... | ... | @@ -7,11 +7,13 @@ |
| 7 | 7 |
import { FoxInput } from '@fox/core/components/fox-input';
|
| 8 | 8 |
import { FoxModal } from '@fox/core/components/fox-modal';
|
| 9 | 9 |
import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
|
| 10 |
+import { foxPasswordValidator } from '@fox/core/validation';
|
|
| 10 | 11 |
import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
|
| 11 | 12 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 12 | 13 |
import { DEFAULT_ADMIN_ROLE_CODE, type AdminMember } from '@/lib/domain/admin-member';
|
| 13 | 14 |
import {
|
| 14 | 15 |
ADMIN_PASSWORD_HELP_TEXT, |
| 16 |
+ ADMIN_PASSWORD_POLICY, |
|
| 15 | 17 |
INITIAL_ADMIN_MEMBER_FORM_STATE, |
| 16 | 18 |
toPhoneDigits, |
| 17 | 19 |
} from '@/lib/domain/admin-member-form'; |
... | ... | @@ -133,6 +135,9 @@ |
| 133 | 135 |
isPasswordEditable ? <FoxChatCenteredDotsIcon /> : undefined |
| 134 | 136 |
} |
| 135 | 137 |
invalid={Boolean(errors.password)}
|
| 138 |
+ // 비우면 "바꾸지 않음"이라 required를 걸지 않는다(등록 팝업과 다른 점). |
|
| 139 |
+ validators={[foxPasswordValidator(ADMIN_PASSWORD_POLICY)]}
|
|
| 140 |
+ validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
|
|
| 136 | 141 |
/> |
| 137 | 142 |
<FoxButton |
| 138 | 143 |
type="secondary" |
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
... | ... | @@ -33,7 +33,16 @@ |
| 33 | 33 |
|
| 34 | 34 |
const LOGIN_ID_MIN_LENGTH = 4; |
| 35 | 35 |
const LOGIN_ID_MAX_LENGTH = 20; |
| 36 |
-const PASSWORD_MIN_LENGTH = 10; |
|
| 36 |
+ |
|
| 37 |
+/** |
|
| 38 |
+ * 비밀번호 규칙 — **화면과 서버가 같은 값을 본다.** 팝업은 이 값을 `foxPasswordValidator`에 |
|
| 39 |
+ * 그대로 넘겨 입력 중에 안내하고, 서버 검증은 아래 `validateEditableValues`가 같은 값으로 판정한다. |
|
| 40 |
+ * 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다. |
|
| 41 |
+ */ |
|
| 42 |
+export const ADMIN_PASSWORD_POLICY = {
|
|
| 43 |
+ minLength: 10, |
|
| 44 |
+ kinds: 2, |
|
| 45 |
+} as const; |
|
| 37 | 46 |
const NAME_MAX_LENGTH = 50; |
| 38 | 47 |
const EMAIL_MAX_LENGTH = 100; |
| 39 | 48 |
|
... | ... | @@ -160,8 +169,8 @@ |
| 160 | 169 |
errors.password = ADMIN_PASSWORD_HELP_TEXT; |
| 161 | 170 |
} |
| 162 | 171 |
} else if ( |
| 163 |
- password.length < PASSWORD_MIN_LENGTH || |
|
| 164 |
- countCharacterKinds(password) < 2 |
|
| 172 |
+ password.length < ADMIN_PASSWORD_POLICY.minLength || |
|
| 173 |
+ countCharacterKinds(password) < ADMIN_PASSWORD_POLICY.kinds |
|
| 165 | 174 |
) {
|
| 166 | 175 |
errors.password = ADMIN_PASSWORD_HELP_TEXT; |
| 167 | 176 |
} |
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?