diff --git a/src/app/(protected)/mypage/page.tsx b/src/app/(protected)/mypage/page.tsx index 4cf7e50..4ebb22f 100644 --- a/src/app/(protected)/mypage/page.tsx +++ b/src/app/(protected)/mypage/page.tsx @@ -14,6 +14,7 @@ import { UpdateUserRequest, UploadProfileImageResponse, } from "@/features/users/types"; +import { getPwConfirmError, getPwError, PasswordToggle } from "@/components/form/PasswordInput"; import { useQueryClient } from "@tanstack/react-query"; export default function AccountPage() { @@ -31,6 +32,11 @@ export default function AccountPage() { const [newPw, setNewPw] = useState(""); const [confirmPw, setConfirmPw] = useState(""); const [error, setError] = useState(undefined); + const [confirmError, setConfirmError] = useState(undefined); + + const [showCur, setShowCur] = useState(false); + const [showNew, setShowNew] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); const [saving, setSaving] = useState(false); const [changing, setChanging] = useState(false); @@ -64,9 +70,8 @@ export default function AccountPage() { }; // 블러 시 비밀번호 검사 - const onBlurConfirm = () => { - setError(!confirmPw || confirmPw === newPw ? undefined : "비밀번호가 일치하지 않습니다."); - }; + const onBlurNew = () => setError(getPwError(newPw)); + const onBlurConfirm = () => setConfirmError(getPwConfirmError(newPw, confirmPw)); // 프로필 저장 버튼 활성화 조건 const canSaveProfile = useMemo(() => { @@ -78,8 +83,8 @@ export default function AccountPage() { // 비밀번호 변경 버튼 활성화 조건 const canChange = useMemo( - () => curPw.length > 0 && newPw.length > 0 && confirmPw.length > 0 && !error, - [curPw, newPw, confirmPw, error], + () => curPw.length > 0 && newPw.length > 0 && confirmPw.length > 0 && !error && !confirmError, + [curPw, newPw, confirmPw, error, confirmError], ); // 프로필 저장 로직 @@ -130,6 +135,7 @@ export default function AccountPage() { setNewPw(""); setConfirmPw(""); setError(undefined); + setConfirmError(undefined); } catch (e) { const msg = e instanceof Error ? e.message : "비밀번호 변경 실패"; if (msg.includes("현재 비밀번호")) { @@ -229,49 +235,86 @@ export default function AccountPage() { label="현재 비밀번호" className="[&>label]:text-brand-gray-700 [&>label]:text-lg" > - ) => setCurPw(e.target.value)} - placeholder="비밀번호 입력" - /> +
+ ) => setCurPw(e.target.value)} + placeholder="비밀번호 입력" + rightIcon={ + setShowCur((s) => !s)} + controlsId="currentPw" + /> + } + /> +
- ) => setNewPw(e.target.value)} - placeholder="새 비밀번호 입력" - /> +
+ ) => { + setNewPw(e.target.value); + setError(getPwError(e.target.value)); + }} + onBlur={onBlurNew} + aria-invalid={!!error} + className={error ? "border-red-500" : ""} + placeholder="새 비밀번호 입력" + rightIcon={ + setShowNew((s) => !s)} + controlsId="newPw" + /> + } + /> +
- ) => { - setConfirmPw(e.target.value); - if (error) setError(undefined); - }} - onBlur={onBlurConfirm} - aria-invalid={!!error} - className={error ? "border-red-500" : ""} - placeholder="새 비밀번호 입력" - /> +
+ ) => { + setConfirmPw(e.target.value); + if (confirmError) setConfirmError(undefined); + }} + onBlur={onBlurConfirm} + aria-invalid={!!confirmError} + className={confirmError ? "border-red-500" : ""} + placeholder="새 비밀번호 입력" + rightIcon={ + setShowConfirm((s) => !s)} + controlsId="confirmPw" + /> + } + /> +
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()); @@ -39,11 +40,7 @@ export default function LoginPage() { // 블러 시 비밀번호 검사 const validatePwOnBlur = () => { - setErrors((s) => ({ - ...s, - password: - !values.password || values.password.length >= 8 ? undefined : "8자 이상 작성해 주세요.", - })); + setErrors((s) => ({ ...s, password: getPwError(values.password) })); }; const canSubmit = trueEmail(values.email) && values.password.length >= 8; @@ -51,13 +48,14 @@ export default function LoginPage() { const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (submitting) return; + setSubmitting(true); validateEmailOnBlur(); validatePwOnBlur(); if (!canSubmit) return; try { // 로그인 성공 - setSubmitting(true); const res = await fetch("/api/login", { method: "POST", @@ -74,7 +72,6 @@ export default function LoginPage() { } catch (e) { console.warn("프로필 기본 이미지 적용 실패:", e); } - router.replace("/mydashboard"); } catch (err: unknown) { // 로그인 실패 @@ -83,6 +80,9 @@ export default function LoginPage() { window.alert(message); } finally { setSubmitting(false); + // alert 닫힌 뒤 재제출 방지 + (document.activeElement as HTMLElement | null)?.blur(); + setValues((s) => ({ ...s, password: "" })); } }; @@ -108,6 +108,7 @@ export default function LoginPage() {
setShowPw((v) => !v)} + controlsId="password" + /> + } /> -
diff --git a/src/app/(public)/signup/page.tsx b/src/app/(public)/signup/page.tsx index bdd4c55..cfb6b28 100644 --- a/src/app/(public)/signup/page.tsx +++ b/src/app/(public)/signup/page.tsx @@ -11,6 +11,8 @@ import MyButton from "@/components/common/Button"; import { Modal, ModalContext, ModalFooter } from "@/components/modal/Modal"; import { signup } from "@/features/users/api"; +import { PasswordToggle, getPwError, getPwConfirmError } from "@/components/form/PasswordInput"; + type Errors = { email?: string; nickname?: string; password?: string; confirm?: string }; type Keys = "email" | "nickname" | "password" | "confirm"; @@ -32,12 +34,11 @@ export default function SignupPage() { const [errors, setErrors] = useState({}); const [showPw, setShowPw] = useState(false); const [submitting, setSubmitting] = useState(false); - // 중복 이메일 모달 상태 const [dupModal, setDupModal] = useState(false); + // 공통 onChange const onChange = (k: Keys) => (e: React.ChangeEvent) => { const v = e.target.value; - // 이전 상태(s)를 받아서 복사 후 [k] 자리에 덮어씀 setValues((s) => ({ ...s, [k]: v })); setErrors((s) => ({ ...s, [k]: undefined })); }; @@ -46,93 +47,70 @@ export default function SignupPage() { setValues((s) => ({ ...s, agree: e.target.checked })); }; - // 블러 시 이메일 검사 - const onBlurEmail = () => { - // 기존 에러 객체 s 복사 후 email 필드만 새로운 값으로 갱신 + // Blur 검증 + const onBlurEmail = () => setErrors((s) => ({ ...s, email: !values.email || trueEmail(values.email) ? undefined : "이메일 형식으로 작성해 주세요.", })); - }; - // 블러 시 닉네임 검사 - const onBlurNickname = () => { + const onBlurNickname = () => setErrors((s) => ({ ...s, nickname: !values.nickname || values.nickname.length <= MAX_NICK ? undefined - : "열 자 이하로 작성해주세요.", + : "열 자 이하로 작성해 주세요.", })); - }; - // 블러 시 비밀번호 검사 - const onBlurPw = () => { + const onBlurPw = () => setErrors((s) => ({ ...s, password: - !values.password || values.password.length >= MIN_PW - ? undefined - : "8자 이상 작성해 주세요.", + getPwError(values.password) ?? // 길이 등 공통 규칙 + (values.password.length > 0 && values.password.length < MIN_PW + ? "8자 이상 작성해 주세요." + : undefined), })); - }; - // 비밀번호 일치 확인 여부 - const onBlurConfirm = () => { + const onBlurConfirm = () => setErrors((s) => ({ ...s, - confirm: - !values.confirm || values.confirm === values.password - ? undefined - : "비밀번호가 일치하지 않습니다.", + confirm: getPwConfirmError(values.password, values.confirm), })); - }; - // 가입하기 버튼 활성화 조건 + // 버튼 활성화 조건 const activeButton = useMemo(() => { const okEmail = trueEmail(values.email); const okNickname = values.nickname.length > 0 && values.nickname.length <= MAX_NICK; - const okPw = values.password.length >= MIN_PW; - const okConfirm = values.confirm.length > 0 && values.confirm === values.password; + const okPw = values.password.length >= MIN_PW && !getPwError(values.password); + const okConfirm = + values.confirm.length > 0 && !getPwConfirmError(values.password, values.confirm); return okEmail && okNickname && okPw && okConfirm; }, [values]); const canSubmit = activeButton && values.agree && !submitting; - const pwToggle = (controlsId: string) => ( - - ); - + // 제출 const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (submitting) return; + setSubmitting(true); + onBlurEmail(); onBlurNickname(); onBlurPw(); onBlurConfirm(); - - if (!activeButton || !values.agree) return; - - setSubmitting(true); + if (!activeButton || !values.agree) { + setSubmitting(false); + return; + } try { await signup({ - email: values.email, - nickname: values.nickname, + email: values.email.trim(), + nickname: values.nickname.trim(), password: values.password, }); @@ -145,14 +123,15 @@ export default function SignupPage() { : undefined; const message = err instanceof Error ? err.message : "회원가입에 실패했습니다."; - // 409: 이미 사용중인 이메일 → 모달 if (status === 409 || message.includes("이미 사용중인")) { setDupModal(true); } else { - alert(message); // 400 등 다른 메시지 그대로 표시 (예: 이메일 형식으로 작성해주세요.) + alert(message); } } finally { setSubmitting(false); + // 재제출 방지 + setTimeout(() => (document.activeElement as HTMLElement | null)?.blur(), 0); } }; @@ -161,13 +140,16 @@ export default function SignupPage() { Taskify 텍스트 로고 +

첫 방문을 환영합니다!

+ {/* 이메일 */} + + {/* 닉네임 */} + + {/* 비밀번호 */}
setShowPw((s) => !s)} + controlsId="password" + /> + } />
+ + {/* 비밀번호 확인 */}
setShowPw((s) => !s)} + controlsId="confirm" + /> + } />
+ {/* 약관 동의 */}