-
Notifications
You must be signed in to change notification settings - Fork 1
feat: 로그인 폼에 FormInput 컴포넌트 추가 및 상태 관리 개선 #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ee718c7
41b58c1
ffc4e90
7d25582
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # API 서버 URL (기본값: 프로덕션 서버) | ||
| NEXT_PUBLIC_API_URL=https://srv.comatching.site | ||
|
|
||
| # Firebase Config (예시 값 - 실제 값은 .env.local에 작성) | ||
| NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key | ||
| NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com | ||
| NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id | ||
| NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your-project.firebasestorage.app | ||
| NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your-sender-id | ||
| NEXT_PUBLIC_FIREBASE_APP_ID=your-app-id | ||
| NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=your-measurement-id | ||
| NEXT_PUBLIC_FIREBASE_VAPID_KEY=your-vapid-key | ||
|
|
||
| # 로컬 개발 환경 설정은 .env.local 파일에 작성하세요 |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,59 +1,53 @@ | ||||||||||||||||||||||||||||||||||||||||||
| "use client"; | ||||||||||||||||||||||||||||||||||||||||||
| import BubbleDiv from "@/app/_components/BubbleDiv"; | ||||||||||||||||||||||||||||||||||||||||||
| import Button from "@/components/ui/Button"; | ||||||||||||||||||||||||||||||||||||||||||
| import FormInput from "@/components/ui/FormInput"; | ||||||||||||||||||||||||||||||||||||||||||
| import { User } from "lucide-react"; | ||||||||||||||||||||||||||||||||||||||||||
| import React, { useActionState } from "react"; | ||||||||||||||||||||||||||||||||||||||||||
| import React, { useActionState, useState } from "react"; | ||||||||||||||||||||||||||||||||||||||||||
| import Link from "next/link"; | ||||||||||||||||||||||||||||||||||||||||||
| import { loginAction } from "@/lib/actions/loginAction"; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const INPUT_STYLE = { | ||||||||||||||||||||||||||||||||||||||||||
| background: | ||||||||||||||||||||||||||||||||||||||||||
| "linear-gradient(180deg, rgba(248, 248, 248, 0.03) 0%, rgba(248, 248, 248, 0.24) 100%)", | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| const INPUT_CLASSNAME = | ||||||||||||||||||||||||||||||||||||||||||
| "all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] leading-[19px] placeholder:text-[#B3B3B3]"; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export const LoginForm = () => { | ||||||||||||||||||||||||||||||||||||||||||
| // React 19: useActionState로 폼 상태 및 팬딩 처리 관리 | ||||||||||||||||||||||||||||||||||||||||||
| const [state, formAction, isPending] = useActionState(loginAction, { | ||||||||||||||||||||||||||||||||||||||||||
| success: false, | ||||||||||||||||||||||||||||||||||||||||||
| message: "", | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const [email, setEmail] = useState(""); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||||
| <section className="mt-10 flex w-full flex-1 flex-col items-start gap-6"> | ||||||||||||||||||||||||||||||||||||||||||
| <form className="flex w-full flex-col gap-4" action={formAction}> | ||||||||||||||||||||||||||||||||||||||||||
| <div className="flex w-full flex-col gap-2"> | ||||||||||||||||||||||||||||||||||||||||||
| <label htmlFor="email" className="typo-14-500 text-gray-700"> | ||||||||||||||||||||||||||||||||||||||||||
| 아이디(이메일) | ||||||||||||||||||||||||||||||||||||||||||
| </label> | ||||||||||||||||||||||||||||||||||||||||||
| <input | ||||||||||||||||||||||||||||||||||||||||||
| <FormInput | ||||||||||||||||||||||||||||||||||||||||||
| id="email" | ||||||||||||||||||||||||||||||||||||||||||
| type="email" | ||||||||||||||||||||||||||||||||||||||||||
| name="email" | ||||||||||||||||||||||||||||||||||||||||||
| placeholder="이메일 입력" | ||||||||||||||||||||||||||||||||||||||||||
| required | ||||||||||||||||||||||||||||||||||||||||||
| autoComplete="email" | ||||||||||||||||||||||||||||||||||||||||||
| className={INPUT_CLASSNAME} | ||||||||||||||||||||||||||||||||||||||||||
| style={INPUT_STYLE} | ||||||||||||||||||||||||||||||||||||||||||
| value={email} | ||||||||||||||||||||||||||||||||||||||||||
| onChange={(e) => setEmail(e.target.value)} | ||||||||||||||||||||||||||||||||||||||||||
dasosann marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+26
to
35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 서버 액션과
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| <div className="relative mb-6 flex w-full flex-col gap-2"> | ||||||||||||||||||||||||||||||||||||||||||
| <label htmlFor="password" className="typo-14-500 text-gray-700"> | ||||||||||||||||||||||||||||||||||||||||||
| 비밀번호 | ||||||||||||||||||||||||||||||||||||||||||
| </label> | ||||||||||||||||||||||||||||||||||||||||||
| <input | ||||||||||||||||||||||||||||||||||||||||||
| <FormInput | ||||||||||||||||||||||||||||||||||||||||||
| id="password" | ||||||||||||||||||||||||||||||||||||||||||
| type="password" | ||||||||||||||||||||||||||||||||||||||||||
| name="password" | ||||||||||||||||||||||||||||||||||||||||||
| placeholder="비밀번호 입력" | ||||||||||||||||||||||||||||||||||||||||||
| required | ||||||||||||||||||||||||||||||||||||||||||
| autoComplete="current-password" | ||||||||||||||||||||||||||||||||||||||||||
| className={INPUT_CLASSNAME} | ||||||||||||||||||||||||||||||||||||||||||
| style={INPUT_STYLE} | ||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||
| {!state.success && ( | ||||||||||||||||||||||||||||||||||||||||||
| {!state.success && state.message && ( | ||||||||||||||||||||||||||||||||||||||||||
| <span className="typo-12-400 text-color-flame-700 absolute bottom-[-25px] left-0"> | ||||||||||||||||||||||||||||||||||||||||||
| * 이메일 혹은 비밀번호가 틀립니다 | ||||||||||||||||||||||||||||||||||||||||||
dasosann marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -77,13 +71,13 @@ export const LoginForm = () => { | |||||||||||||||||||||||||||||||||||||||||
| <BubbleDiv w={162} h={26} typo="typo-12-600" top={3}> | ||||||||||||||||||||||||||||||||||||||||||
| 아직 계정이 없으신가요?! | ||||||||||||||||||||||||||||||||||||||||||
| </BubbleDiv> | ||||||||||||||||||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||||||||||||||||||
| <Link | ||||||||||||||||||||||||||||||||||||||||||
| href="/register" | ||||||||||||||||||||||||||||||||||||||||||
| className="typo-14-500 flex items-center gap-1 border-b-2 border-gray-500 text-gray-500" | ||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||
| <User /> | ||||||||||||||||||||||||||||||||||||||||||
| 이메일로 회원가입 | ||||||||||||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||||||||||||
| </Link> | ||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||
| </section> | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import React from "react"; | ||
| import { cn } from "@/lib/utils"; | ||
|
|
||
| // React.InputHTMLAttributes를 확장하여 모든 표준 input 속성을 타입 안전하게 지원 | ||
| interface FormInputProps extends Omit< | ||
| React.InputHTMLAttributes<HTMLInputElement>, | ||
| "id" | "type" | "name" | "placeholder" | ||
| > { | ||
| id: string; // input 요소의 고유 식별자 (label의 htmlFor와 연결) | ||
| type: string; // input 타입 (예: text, email, password 등) | ||
| name: string; // form 데이터 전송 시 key 역할 | ||
| placeholder: string; // 입력란에 표시되는 안내 텍스트 | ||
| } | ||
|
|
||
| const INPUT_STYLE = { | ||
| background: | ||
| "linear-gradient(180deg, rgba(248, 248, 248, 0.03) 0%, rgba(248, 248, 248, 0.24) 100%)", | ||
| }; | ||
| const INPUT_CLASSNAME = | ||
| "all:unset box-border w-full border-b border-gray-300 px-2 py-[14.5px] leading-[19px] typo-16-500 placeholder:text-[#B3B3B3] text-color-gray-900 outline-none"; | ||
|
|
||
| // 안전한 속성 화이트리스트 (XSS 방지) | ||
| const SAFE_INPUT_ATTRIBUTES = [ | ||
| "autoComplete", | ||
| "required", | ||
| "value", | ||
| "defaultValue", | ||
| "disabled", | ||
| "readOnly", | ||
| "maxLength", | ||
| "minLength", | ||
| "max", | ||
| "min", | ||
| "pattern", | ||
| "step", | ||
| "inputMode", | ||
| "aria-label", | ||
| "aria-describedby", | ||
| "aria-invalid", | ||
| "aria-required", | ||
| "onChange", | ||
| "onBlur", | ||
| "onFocus", | ||
| "onInput", | ||
| "onKeyDown", | ||
| "onKeyUp", | ||
| "ref", // React 19에서 ref는 일반 prop | ||
| ] as const; | ||
|
|
||
| type SafeInputAttribute = (typeof SAFE_INPUT_ATTRIBUTES)[number]; | ||
|
|
||
| // React 19: ref는 일반 prop으로 전달되므로 forwardRef 불필요 | ||
| const FormInput = ({ | ||
| id, | ||
| type, | ||
| name, | ||
| placeholder, | ||
| className = "", | ||
| style = {}, | ||
| ...rest | ||
| }: FormInputProps) => { | ||
| // 화이트리스트에 있는 안전한 속성만 추출 | ||
| const safeProps = Object.fromEntries( | ||
| Object.entries(rest).filter(([key]) => | ||
| SAFE_INPUT_ATTRIBUTES.includes(key as SafeInputAttribute), | ||
| ), | ||
| ); | ||
|
|
||
| return ( | ||
| <input | ||
| id={id} | ||
| type={type} | ||
| name={name} | ||
| placeholder={placeholder} | ||
| className={cn(INPUT_CLASSNAME, className)} | ||
| style={{ ...INPUT_STYLE, ...style }} | ||
| {...safeProps} | ||
| /> | ||
|
Comment on lines
60
to
78
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The References
|
||
| ); | ||
| }; | ||
|
|
||
| export default FormInput; | ||
Uh oh!
There was an error while loading. Please reload this page.