diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 45249a8..a8a7848 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -68,47 +68,81 @@ jobs: runs-on: ubuntu-latest needs: lint timeout-minutes: 15 + + # ✅ 테스트 단계에서도 env 필요한 경우가 많아서 같이 주입 + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + NEXT_PUBLIC_API_TIMEOUT: "10000" + steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 with: run_install: false + - name: Get pnpm store directory id: pnpm-cache run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v4 with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: ${{ runner.os }}-pnpm- + - run: pnpm install --frozen-lockfile + - run: pnpm run --if-present test -- --ci build: runs-on: ubuntu-latest needs: test timeout-minutes: 15 + + # ✅ 여기서 supabaseUrl required 터지던 원인 해결: build에 env 주입 + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + NEXT_PUBLIC_API_TIMEOUT: "10000" + steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} + - uses: pnpm/action-setup@v4 with: run_install: false + - name: Get pnpm store directory id: pnpm-cache run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v4 with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: ${{ runner.os }}-pnpm- + - run: pnpm install --frozen-lockfile + # ✅ (선택) env 누락이면 여기서 바로 죽게 해서 원인 빨리 찾기 + - name: Check env exists + run: | + test -n "$NEXT_PUBLIC_SUPABASE_URL" || (echo "NEXT_PUBLIC_SUPABASE_URL missing" && exit 1) + test -n "$NEXT_PUBLIC_SUPABASE_ANON_KEY" || (echo "NEXT_PUBLIC_SUPABASE_ANON_KEY missing" && exit 1) + test -n "$SUPABASE_SERVICE_ROLE_KEY" || (echo "SUPABASE_SERVICE_ROLE_KEY missing" && exit 1) + # (선택) Next.js 빌드 캐시 # - uses: actions/cache@v4 # with: diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..f1ef59f --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/.idea/custom-daily-planner.iml b/.idea/custom-daily-planner.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/custom-daily-planner.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..f5bd2df --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a40c53a --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 0000000..b0c1c68 --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/app/api/check-email/route.ts b/src/app/api/check-email/route.ts new file mode 100644 index 0000000..01ef641 --- /dev/null +++ b/src/app/api/check-email/route.ts @@ -0,0 +1,72 @@ +import { supabaseAdmin } from "@/lib/supabase/admin"; +import { isValidEmail } from "@/lib/validators"; +import type { CheckEmailBody, CheckEmailFailRes, CheckEmailOkRes } from "@/types/auth"; +import { NextResponse } from "next/server"; + +export async function POST(req: Request): Promise { + let body: CheckEmailBody; + + try { + body = (await req.json()) as CheckEmailBody; + } catch { + const res: CheckEmailFailRes = { + ok: false, + available: false, + message: "요청 본문(JSON)이 올바르지 않습니다.", + }; + return NextResponse.json(res, { status: 400 }); + } + + const email = (body.email ?? "").trim().toLowerCase(); + + if (!email) { + const res: CheckEmailFailRes = { + ok: false, + available: false, + message: "이메일이 비어 있습니다.", + }; + return NextResponse.json(res, { status: 400 }); + } + + if (!isValidEmail(email)) { + const res: CheckEmailFailRes = { + ok: false, + available: false, + message: "이메일 형식이 올바르지 않습니다.", + }; + return NextResponse.json(res, { status: 400 }); + } + + const { data, error } = await supabaseAdmin + .from("profiles") + .select("id") + .eq("email", email) + .limit(1); + + if (error) { + const res: CheckEmailFailRes = { + ok: false, + available: false, + message: "서버 확인 중 오류가 발생했습니다.", + }; + return NextResponse.json(res, { status: 500 }); + } + + const exists = (data?.length ?? 0) > 0; + + if (exists) { + const res: CheckEmailOkRes = { + ok: true, + available: false, + message: "이미 가입된 이메일입니다.", + }; + return NextResponse.json(res); + } + + const res: CheckEmailOkRes = { + ok: true, + available: true, + message: "사용 가능한 이메일입니다.", + }; + return NextResponse.json(res); +} diff --git a/src/app/api/send-otp/route.ts b/src/app/api/send-otp/route.ts new file mode 100644 index 0000000..2099fe1 --- /dev/null +++ b/src/app/api/send-otp/route.ts @@ -0,0 +1,31 @@ +import { createClient } from "@supabase/supabase-js"; +import { NextResponse } from "next/server"; + +type Body = { email?: string }; + +export async function POST(req: Request) { + const body = (await req.json()) as Body; + const email = (body.email ?? "").trim().toLowerCase(); + + if (!email) { + return NextResponse.json({ ok: false, message: "이메일이 비어 있습니다." }, { status: 400 }); + } + + const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const supabase = createClient(url, anon, { auth: { persistSession: false } }); + + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { + shouldCreateUser: true, + }, + }); + + if (error) { + return NextResponse.json({ ok: false, message: error.message }, { status: 400 }); + } + + return NextResponse.json({ ok: true, message: "인증 메일을 발송했습니다." }); +} diff --git a/src/app/api/set-password/route.ts b/src/app/api/set-password/route.ts new file mode 100644 index 0000000..9715eec --- /dev/null +++ b/src/app/api/set-password/route.ts @@ -0,0 +1,99 @@ +import { supabaseAdmin } from "@/lib/supabase/admin"; +import { isValidEmail } from "@/lib/validators"; +import { createClient } from "@supabase/supabase-js"; +import { NextResponse } from "next/server"; + +export type SetPasswordBody = { + email?: string; + password?: string; +}; + +export type SetPasswordOkRes = { + ok: true; + message: string; +}; + +export type SetPasswordFailRes = { + ok: false; + message: string; +}; + +export type SetPasswordResponse = SetPasswordOkRes | SetPasswordFailRes; + +const getBearerToken = (req: Request): string => { + const value = req.headers.get("authorization") ?? ""; + if (!value.startsWith("Bearer ")) return ""; + return value.slice("Bearer ".length).trim(); +}; + +export async function POST(req: Request): Promise { + let body: SetPasswordBody; + + try { + body = (await req.json()) as SetPasswordBody; + } catch { + const res: SetPasswordFailRes = { + ok: false, + message: "요청 본문(`JSON`)이 올바르지 않습니다.", + }; + return NextResponse.json(res, { status: 400 }); + } + + const accessToken = getBearerToken(req); + const email = (body.email ?? "").trim().toLowerCase(); + const password = (body.password ?? "").trim(); + + if (!accessToken) { + const res: SetPasswordFailRes = { ok: false, message: "인증 토큰이 없습니다." }; + return NextResponse.json(res, { status: 401 }); + } + + if (!email) { + const res: SetPasswordFailRes = { ok: false, message: "이메일이 비어 있습니다." }; + return NextResponse.json(res, { status: 400 }); + } + + if (!isValidEmail(email)) { + const res: SetPasswordFailRes = { ok: false, message: "이메일 형식이 올바르지 않습니다." }; + return NextResponse.json(res, { status: 400 }); + } + + if (!password) { + const res: SetPasswordFailRes = { ok: false, message: "비밀번호가 비어 있습니다." }; + return NextResponse.json(res, { status: 400 }); + } + + // ✅ 1) 토큰으로 “누구인지” 확인 + const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabase = createClient(url, anon, { auth: { persistSession: false } }); + + const { data: userData, error: userError } = await supabase.auth.getUser(accessToken); + + if (userError || !userData.user) { + const res: SetPasswordFailRes = { ok: false, message: "인증 정보가 유효하지 않습니다." }; + return NextResponse.json(res, { status: 401 }); + } + + const authedEmail = (userData.user.email ?? "").toLowerCase(); + if (authedEmail !== email) { + const res: SetPasswordFailRes = { + ok: false, + message: "인증된 이메일과 요청 이메일이 일치하지 않습니다.", + }; + return NextResponse.json(res, { status: 403 }); + } + + // ✅ 2) `Admin` 권한으로 비밀번호 설정 + const { error: updateError } = await supabaseAdmin.auth.admin.updateUserById(userData.user.id, { + password, + }); + + if (updateError) { + const res: SetPasswordFailRes = { ok: false, message: "비밀번호 저장 중 오류가 발생했습니다." }; + return NextResponse.json(res, { status: 500 }); + } + + const res: SetPasswordOkRes = { ok: true, message: "비밀번호가 설정되었습니다." }; + return NextResponse.json(res); +} diff --git a/src/app/api/update-name/route.ts b/src/app/api/update-name/route.ts new file mode 100644 index 0000000..0afc810 --- /dev/null +++ b/src/app/api/update-name/route.ts @@ -0,0 +1,102 @@ +import { supabaseAdmin } from "@/lib/supabase/admin"; +import { isValidEmail } from "@/lib/validators"; +import { NextResponse } from "next/server"; + +export type UpdateNameBody = { + email?: string; + name?: string; +}; + +export type UpdateNameOkRes = { + ok: true; + message: string; +}; + +export type UpdateNameFailRes = { + ok: false; + message: string; +}; + +export type UpdateNameResponse = UpdateNameOkRes | UpdateNameFailRes; + +type ProfileIdRow = { + id: string; +}; + +const getErrorMessage = (err: unknown): string => { + if (err instanceof Error) return err.message; + return "알 수 없는 오류가 발생했습니다."; +}; + +export async function POST(req: Request): Promise { + let body: UpdateNameBody; + + try { + body = (await req.json()) as UpdateNameBody; + } catch { + const res: UpdateNameFailRes = { ok: false, message: "요청 본문(JSON)이 올바르지 않습니다." }; + return NextResponse.json(res, { status: 400 }); + } + + const email = (body.email ?? "").trim().toLowerCase(); + const name = (body.name ?? "").trim(); + + if (!email) { + const res: UpdateNameFailRes = { ok: false, message: "이메일이 비어 있습니다." }; + return NextResponse.json(res, { status: 400 }); + } + + if (!isValidEmail(email)) { + const res: UpdateNameFailRes = { ok: false, message: "이메일 형식이 올바르지 않습니다." }; + return NextResponse.json(res, { status: 400 }); + } + + if (!name) { + const res: UpdateNameFailRes = { ok: false, message: "이름이 비어 있습니다." }; + return NextResponse.json(res, { status: 400 }); + } + + // ✅ 1) profiles.email 기준으로 이름 저장 + userId 확보 + const { data, error } = await supabaseAdmin + .from("profiles") + .update({ name }) + .eq("email", email) + .select("id") + .limit(1); + + if (error) { + const res: UpdateNameFailRes = { ok: false, message: "이름 저장 중 오류가 발생했습니다." }; + return NextResponse.json(res, { status: 500 }); + } + + const row = (data?.[0] as ProfileIdRow | undefined) ?? undefined; + if (!row?.id) { + const res: UpdateNameFailRes = { + ok: false, + message: "프로필을 찾을 수 없습니다. 이메일 인증을 다시 진행해주세요.", + }; + return NextResponse.json(res, { status: 404 }); + } + + const userId = row.id; + + // ✅ 2) auth.users 메타데이터도 동기화 (실패해도 profiles 저장 성공이면 OK 처리) + try { + const { error: authError } = await supabaseAdmin.auth.admin.updateUserById(userId, { + user_metadata: { + name, + display_name: name, + }, + }); + + if (authError) { + // 여기서 실패하더라도 가입 플로우를 막을 필요는 없어서 로그만 남기는 게 일반적 + console.error("[update-name] auth metadata update failed:", authError.message); + } + } catch (err: unknown) { + console.error("[update-name] auth metadata update exception:", getErrorMessage(err)); + } + + const res: UpdateNameOkRes = { ok: true, message: "이름이 저장되었습니다." }; + return NextResponse.json(res); +} diff --git a/src/app/api/verify-otp/route.ts b/src/app/api/verify-otp/route.ts new file mode 100644 index 0000000..4f66f39 --- /dev/null +++ b/src/app/api/verify-otp/route.ts @@ -0,0 +1,53 @@ +import { createClient } from "@supabase/supabase-js"; +import { NextResponse } from "next/server"; + +type Body = { email?: string; token?: string }; + +export async function POST(req: Request) { + const body = (await req.json()) as Body; + const email = (body.email ?? "").trim().toLowerCase(); + const token = (body.token ?? "").trim(); + + if (!email || !token) { + return NextResponse.json( + { ok: false, verified: false, message: "이메일 또는 인증번호가 비어 있습니다." }, + { status: 400 }, + ); + } + + const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const supabase = createClient(url, anon, { auth: { persistSession: false } }); + + const { data, error } = await supabase.auth.verifyOtp({ + email, + token, + type: "email", + }); + + if (error) { + return NextResponse.json({ + ok: true, + verified: false, + message: "인증번호가 올바르지 않습니다.", + }); + } + + const accessToken = data.session?.access_token; + + if (!accessToken) { + return NextResponse.json({ + ok: true, + verified: true, + message: "이메일 인증은 완료됐지만 세션 토큰을 가져오지 못했습니다.", + }); + } + + return NextResponse.json({ + ok: true, + verified: true, + message: "이메일 인증이 완료되었습니다.", + accessToken, + }); +} diff --git a/src/components/auth/forgotPassword/ForgotPasswordEmailStep.tsx b/src/components/auth/forgotPassword/ForgotPasswordEmailStep.tsx index 0db64b8..f4bd658 100644 --- a/src/components/auth/forgotPassword/ForgotPasswordEmailStep.tsx +++ b/src/components/auth/forgotPassword/ForgotPasswordEmailStep.tsx @@ -13,32 +13,24 @@ import { Input } from "@/shared/input"; import type { StepFieldMeta } from "@/types/auth"; export function ForgotPasswordEmailStep({ fieldId, fieldName }: StepFieldMeta) { - // ✅ 1) 전역 스토어에서 이메일 값 + setter 가져오기 const { email, setEmail } = useForgotPasswordFormStore(); - // ✅ 2) 다음 스텝으로 이동하는 액션 const { goNext } = useForgotPasswordStepStore(); - // ✅ 3) 이메일 에러 메시지 (UI 전용 로컬 상태) const [emailError, setEmailError] = useState(""); - // ✅ 4) 공통 submit 훅으로 form 기본 동작 막기 + 콜백 실행 const handleSubmit = useAuthFormSubmit(() => { let hasError = false; - // 이메일 형식 검증 if (!isValidEmail(email)) { setEmailError("올바른 이메일을 입력해주세요."); hasError = true; } - // 하나라도 실패하면 이 스텝에 머무르기 if (hasError) return; - // 디버깅용 로그 (나중에 실제 API 요청으로 대체) console.log("📨 Forgot Password Email Step:", { email }); - // 검증 통과 시 다음 스텝으로 이동 goNext(); }); @@ -63,11 +55,8 @@ export function ForgotPasswordEmailStep({ fieldId, fieldName }: StepFieldMeta) { status="default" autoComplete="email" required - // 전역 스토어 값과 연결 value={email} - // 입력 시 전역 스토어 업데이트 onChange={(e) => setEmail(e.target.value)} - // 포커스 되면 에러 메시지 초기화 onFocus={() => setEmailError("")} className={cn(emailError && "border-[1.5px] border-[var(--color-danger-600)]")} /> diff --git a/src/components/auth/forgotPassword/ForgotPasswordForm.tsx b/src/components/auth/forgotPassword/ForgotPasswordForm.tsx index e8d45bd..b2c21f1 100644 --- a/src/components/auth/forgotPassword/ForgotPasswordForm.tsx +++ b/src/components/auth/forgotPassword/ForgotPasswordForm.tsx @@ -13,22 +13,18 @@ import { ForgotPasswordResetStep } from "./ForgotPasswordResetStep"; import { ForgotPasswordVerifyStep } from "./ForgotPasswordVerifyStep"; export function ForgotPasswordForm() { - // ✅ 1) 현재 스텝 이름 + 전환 방향 가져오기 const { step, direction } = useForgotPasswordStepStore(); const isEmailStep = step === "email"; const isVerifyStep = step === "verify"; const isResetStep = step === "reset"; - // ✅ 2) 현재 스텝이 몇 번째인지 계산 (1부터 시작) const currentStepIndex = FORGOT_PASSWORD_STEP_ORDER.indexOf(step); const currentStepNumber = currentStepIndex + 1; - // ✅ 3) 스텝별 필드 메타 (id / name) const { fieldId, fieldName } = FORGOT_PASSWORD_STEP_FIELD_META[step]; return ( - // 스텝이 바뀌면 슬라이드 전환 { let hasError = false; - // 1) 비밀번호 규칙 검증 (길이 + 특수문자) if (!isValidPassword(newPassword)) { setPasswordError("8자리 이상, 특수문자를 포함해야 합니다."); hasError = true; } - // 2) 비밀번호 확인 일치 여부 검증 if (newPassword !== passwordConfirm) { setPasswordConfirmError("비밀번호가 일치하지 않습니다."); hasError = true; @@ -47,13 +40,10 @@ export function ForgotPasswordResetStep({ fieldId, fieldName }: StepFieldMeta) { if (hasError) return; - // 최종 payload 생성 (이메일 + 코드 + 새 비밀번호) const forgotPayload = { email, code, newPassword }; - // 디버깅용 출력 (나중에 Supabase 비밀번호 재설정 API로 교체) console.log("🔑 Forgot Password Reset Step:", forgotPayload); - // 전체 입력값 초기화 reset(); }); diff --git a/src/components/auth/forgotPassword/ForgotPasswordVerifyStep.tsx b/src/components/auth/forgotPassword/ForgotPasswordVerifyStep.tsx index fadd6a3..f9bd537 100644 --- a/src/components/auth/forgotPassword/ForgotPasswordVerifyStep.tsx +++ b/src/components/auth/forgotPassword/ForgotPasswordVerifyStep.tsx @@ -15,23 +15,17 @@ import { Input } from "@/shared/input"; import type { StepFieldMeta } from "@/types/auth"; export function ForgotPasswordVerifyStep({ fieldId, fieldName }: StepFieldMeta) { - // ✅ 1) 전역 스토어에서 code setter 가져오기 const { setCode } = useForgotPasswordFormStore(); - // ✅ 2) 스텝 이동 액션 const { goNext, goPrev } = useForgotPasswordStepStore(); - // ✅ 3) OTP 훅: 4자리 코드 입력 UX 관리 const { values, inputRefs, handleChange, handleKeyDown, codeValue } = useOtpCode(CODE_LENGTH); - // ✅ 4) 코드 에러 메시지 (로컬 상태) const [codeError, setCodeError] = useState(""); - // ✅ 5) submit 핸들러 (공통 훅 사용) const handleSubmit = useAuthFormSubmit(() => { let hasError = false; - // 숫자 4자리 검증 if (!isValidCode(codeValue)) { setCodeError("4자리 숫자 인증번호를 정확히 입력해주세요."); hasError = true; @@ -39,13 +33,10 @@ export function ForgotPasswordVerifyStep({ fieldId, fieldName }: StepFieldMeta) if (hasError) return; - // 전역 스토어에 최종 코드 저장 setCode(codeValue); - // 디버깅용 로그 console.log("✅ Forgot Password Verify Step:", { code: codeValue }); - // 다음 스텝(비밀번호 재설정)으로 이동 goNext(); }); diff --git a/src/components/auth/login/LoginForm.tsx b/src/components/auth/login/LoginForm.tsx index 8fbd95d..7d44b8e 100644 --- a/src/components/auth/login/LoginForm.tsx +++ b/src/components/auth/login/LoginForm.tsx @@ -16,42 +16,32 @@ import Link from "next/link"; import { useState } from "react"; export function LoginForm() { - // ✅ 1) 전역 스토어: 이메일/비밀번호 값 + 액션 const { email, password, setEmail, setPassword, reset } = useLoginFormStore(); - // ✅ 2) 로컬 에러 상태: UI 전용 const [emailError, setEmailError] = useState(""); const [passwordError, setPasswordError] = useState(""); - // ✅ 3) 비밀번호 토글 훅 const { inputType, iconName, ariaLabel, toggleVisibility } = usePasswordVisibility(false); - // ✅ 4) 공통 submit 훅으로 preventDefault 처리 const handleSubmit = useAuthFormSubmit(() => { let hasError = false; - // 이메일 검증 if (!isValidEmail(email)) { setEmailError("올바른 이메일을 입력해주세요."); hasError = true; } - // 비밀번호 검증 (8자리 이상 + 특수문자) if (!isValidPassword(password)) { setPasswordError("8자리 이상, 특수문자를 포함해야 합니다."); hasError = true; } if (hasError) { - return; // ❌ 에러가 하나라도 있으면 제출 중단 + return; } - // ✅ 5) 검증 통과 시: 로그인 데이터 콘솔 출력 - // (나중에 이 자리에서 Supabase Auth 요청으로 교체) - console.log("🟢 Login submit:", { email, password }); - // ✅ 6) 성공 후 인풋 값 초기화 reset(); }); @@ -88,12 +78,10 @@ export function LoginForm() { placeholder="example@gmail.com" autoComplete="email" required - value={email} // ✅ 전역 스토어의 email과 연결 - onChange={(event) => setEmail(event.target.value)} // ✅ setEmail으로 업데이트 - onFocus={() => setEmailError("")} // ✅ 포커스 시 에러 해제 - className={cn( - emailError && "border-[1.5px] border-[var(--color-danger-600)]", // ✅ 에러 시 테두리 강조 - )} + value={email} + onChange={(event) => setEmail(event.target.value)} + onFocus={() => setEmailError("")} + className={cn(emailError && "border-[1.5px] border-[var(--color-danger-600)]")} /> @@ -104,7 +92,7 @@ export function LoginForm() { 비밀번호 - {/* ✅ 비밀번호 에러 문구 */} + {/* 비밀번호 에러 문구 */} {passwordError && ( {passwordError} )} @@ -114,24 +102,24 @@ export function LoginForm() { setPassword(event.target.value)} - onFocus={() => setPasswordError("")} // ✅ 포커스 시 에러 해제 + onFocus={() => setPasswordError("")} className={cn( "w-full pr-10", passwordError && "border-[1.5px] border-[var(--color-danger-600)]", // ✅ 에러 테두리 )} /> - {/* ✅ 비밀번호 보기/숨기기 토글 버튼 */} + {/* 비밀번호 보기/숨기기 토글 버튼 */} diff --git a/src/components/auth/signup/SignupEmailStep.tsx b/src/components/auth/signup/SignupEmailStep.tsx index 1e58ff7..d238ff4 100644 --- a/src/components/auth/signup/SignupEmailStep.tsx +++ b/src/components/auth/signup/SignupEmailStep.tsx @@ -9,16 +9,163 @@ import { useSignupStepStore } from "@/stores/signupStepStore"; import { Button } from "@/shared/button"; import { Input } from "@/shared/input"; -import { StepFieldMeta } from "@/types/auth"; -import { useState } from "react"; +import type { + CheckEmailResponse, + SendOtpResponse, + StepFieldMeta, + VerifyOtpResponse, +} from "@/types/auth"; +import { useCallback, useEffect, useState } from "react"; export function SignupEmailStep({ fieldId, fieldName }: StepFieldMeta) { - const { email, setEmail } = useSignupFormStore(); - + const { email, setEmail, setAccessToken } = useSignupFormStore(); const { goNext } = useSignupStepStore(); const [emailError, setEmailError] = useState(""); + const [serverMsg, setServerMsg] = useState(""); + const [isAvailable, setIsAvailable] = useState(null); + + const [isChecking, setIsChecking] = useState(false); + + const [otp, setOtp] = useState(""); + const [otpMsg, setOtpMsg] = useState(""); + const [isVerified, setIsVerified] = useState(null); + const [isSending, setIsSending] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + + const rightMsg = emailError || serverMsg; + + const rightMsgClass = cn( + "t-12-m", + emailError ? "text-[var(--color-danger-600)]" : "", + !emailError && isAvailable === true ? "text-[var(--color-blue-500)]" : "", + !emailError && isAvailable === false ? "text-[var(--color-danger-600)]" : "", + !emailError && isAvailable === null && serverMsg ? "text-[var(--color-gray-600)]" : "", + ); + + const handleEmailChange = (value: string) => { + setEmail(value); + setEmailError(""); + setServerMsg(""); + setIsAvailable(null); + + setOtp(""); + setOtpMsg(""); + setIsVerified(null); + }; + + const checkEmailDuplication = async () => { + if (!isValidEmail(email)) { + setEmailError("올바른 이메일을 입력해주세요."); + setServerMsg(""); + setIsAvailable(null); + return; + } + + try { + setIsChecking(true); + setEmailError(""); + + const res = await fetch("/api/check-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + const data = (await res.json()) as CheckEmailResponse; + + if (!res.ok) { + setServerMsg(data?.message || "서버 확인 중 오류가 발생했습니다."); + setIsAvailable(false); + return; + } + + setServerMsg(data.message); + setIsAvailable(data.available); + } catch { + setServerMsg("서버 확인 중 오류가 발생했습니다."); + setIsAvailable(false); + } finally { + setIsChecking(false); + } + }; + + const sendOtp = useCallback(async () => { + try { + setIsSending(true); + setOtpMsg(""); + + const res = await fetch("/api/send-otp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + const data = (await res.json()) as SendOtpResponse; + + if (!res.ok) { + setOtpMsg(data?.message || "인증 메일 발송에 실패했습니다."); + return; + } + + setOtpMsg(data.message); + } catch { + setOtpMsg("인증 메일 발송에 실패했습니다."); + } finally { + setIsSending(false); + } + }, [email]); + + const verifyOtp = async () => { + if (otp.trim().length !== 6) { + setOtpMsg("인증번호 6자리를 입력해주세요."); + setIsVerified(false); + return; + } + + try { + setIsVerifying(true); + + const res = await fetch("/api/verify-otp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, token: otp }), + }); + + const data = (await res.json()) as VerifyOtpResponse; + + if (!res.ok) { + setOtpMsg(data?.message || "인증번호 확인 중 오류가 발생했습니다."); + setIsVerified(false); + return; + } + + setOtpMsg(data.message); + setIsVerified(data.verified); + if (data.verified === true && data.accessToken) { + setAccessToken(data.accessToken); + } + } catch { + setOtpMsg("인증번호 확인 중 오류가 발생했습니다."); + setIsVerified(false); + } finally { + setIsVerifying(false); + } + }; + + useEffect(() => { + if (isAvailable === true) { + if (isVerified === true) return; + + setOtp(""); + setOtpMsg(""); + setIsVerified(null); + + sendOtp(); + } + }, [isAvailable, isVerified, sendOtp]); + const handleSubmit = useAuthFormSubmit(() => { let hasError = false; @@ -27,36 +174,147 @@ export function SignupEmailStep({ fieldId, fieldName }: StepFieldMeta) { hasError = true; } - if (hasError) return; + if (isAvailable !== true) { + setServerMsg(serverMsg || "이메일 중복 확인을 먼저 완료해주세요."); + hasError = true; + } + + if (isVerified !== true) { + setOtpMsg(otpMsg || "이메일 인증을 완료해주세요."); + hasError = true; + } - console.log("📨 Signup Email Step:", { email }); + if (hasError) return; goNext(); }); + const canCheck = isValidEmail(email) && !isChecking; + + const showServerOk = isAvailable === true; + const showServerFail = isAvailable === false; + + const emailBorderClass = emailError + ? "border-[1.5px] border-[var(--color-danger-600)]" + : showServerOk + ? "border-[1.5px] border-[var(--color-blue-500)]" + : showServerFail + ? "border-[1.5px] border-[var(--color-danger-600)]" + : ""; + + const showOtpUi = isAvailable === true; + + const otpBorderClass = + isVerified === false + ? "border-[1.5px] border-[var(--color-danger-600)]" + : isVerified === true + ? "border-[1.5px] border-[var(--color-blue-500)]" + : ""; + + const otpMsgClass = + isVerified === true + ? "text-[var(--color-blue-600)]" + : isVerified === false + ? "text-[var(--color-danger-600)]" + : "text-[var(--color-gray-600)]"; + return ( 이메일 + {rightMsg ? {rightMsg} : null} + + + + handleEmailChange(e.target.value)} + onFocus={() => { + setEmailError(""); + setServerMsg(""); + setIsAvailable(null); - {emailError && {emailError}} + setOtp(""); + setOtpMsg(""); + setIsVerified(null); + }} + className={cn("w-full pr-[92px]", emailBorderClass)} + /> + + + {isChecking ? "확인 중" : "중복 확인"} + - setEmail(e.target.value)} - onFocus={() => setEmailError("")} - className={cn(emailError && "border-[1.5px] border-[var(--color-danger-600)]")} - /> + {showOtpUi ? ( + + + + 인증번호 + + + {otpMsg ? {otpMsg} : null} + + + + { + const onlyNum = e.target.value.replace(/\D/g, "").slice(0, 6); + setOtp(onlyNum); + setOtpMsg(""); + setIsVerified(null); + }} + className={cn("w-full pr-[92px]", otpBorderClass)} + /> + + + {isVerifying ? "확인 중" : "인증 확인"} + + + + + {isSending ? "재발송 중" : "재발송"} + + + ) : null} 다음 diff --git a/src/components/auth/signup/SignupNameStep.tsx b/src/components/auth/signup/SignupNameStep.tsx index 163382d..8adf254 100644 --- a/src/components/auth/signup/SignupNameStep.tsx +++ b/src/components/auth/signup/SignupNameStep.tsx @@ -1,5 +1,7 @@ "use client"; +import { useState } from "react"; + import { useAuthFormSubmit } from "@/hooks/useAuthFormSubmit"; import { cn } from "@/lib/utils"; import { isValidName } from "@/lib/validators"; @@ -9,17 +11,20 @@ import { useSignupStepStore } from "@/stores/signupStepStore"; import { Button } from "@/shared/button"; import { Input } from "@/shared/input"; -import { StepFieldMeta } from "@/types/auth"; -import { useState } from "react"; +import type { StepFieldMeta } from "@/types/auth"; -export function SignupNameStep({ fieldId, fieldName }: StepFieldMeta) { - const { name, setName } = useSignupFormStore(); +type UpdateNameResponse = { ok: true; message: string } | { ok: false; message: string }; +export function SignupNameStep({ fieldId, fieldName }: StepFieldMeta) { + const { email, name, setName } = useSignupFormStore(); const { goNext, goPrev } = useSignupStepStore(); const [nameError, setNameError] = useState(""); - const handleSubmit = useAuthFormSubmit(() => { + const [serverMsg, setServerMsg] = useState(""); + const [isSaving, setIsSaving] = useState(false); + + const handleSubmit = useAuthFormSubmit(async () => { let hasError = false; if (!isValidName(name)) { @@ -29,10 +34,38 @@ export function SignupNameStep({ fieldId, fieldName }: StepFieldMeta) { if (hasError) return; - console.log("👤 Signup Name Step:", { name }); - goNext(); + if (isSaving) return; + + try { + setIsSaving(true); + setServerMsg(""); + + const res = await fetch("/api/update-name", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + name: name.trim(), + }), + }); + + const data = (await res.json()) as UpdateNameResponse; + + if (!res.ok || !data.ok) { + setServerMsg(data?.message || "이름 저장에 실패했습니다."); + return; + } + + goNext(); + } catch { + setServerMsg("이름 저장에 실패했습니다."); + } finally { + setIsSaving(false); + } }); + const rightMsg = nameError || serverMsg; + return ( @@ -40,7 +73,16 @@ export function SignupNameStep({ fieldId, fieldName }: StepFieldMeta) { 이름 - {nameError && {nameError}} + {rightMsg && ( + + {rightMsg} + + )} setName(e.target.value)} - onFocus={() => setNameError("")} + onChange={(e) => { + setName(e.target.value); + setServerMsg(""); + }} + onFocus={() => { + setNameError(""); + setServerMsg(""); + }} className={cn(nameError && "border-[1.5px] border-[var(--color-danger-600)]")} /> diff --git a/src/components/auth/signup/SignupPasswordStep.tsx b/src/components/auth/signup/SignupPasswordStep.tsx index e477827..b1ea5e6 100644 --- a/src/components/auth/signup/SignupPasswordStep.tsx +++ b/src/components/auth/signup/SignupPasswordStep.tsx @@ -1,5 +1,7 @@ "use client"; +import { useState } from "react"; + import { useAuthFormSubmit } from "@/hooks/useAuthFormSubmit"; import { usePasswordVisibility } from "@/hooks/usePasswordVisibility"; import { cn } from "@/lib/utils"; @@ -11,22 +13,63 @@ import { Button } from "@/shared/button"; import { Icon } from "@/shared/Icon"; import { Input } from "@/shared/input"; -import { StepFieldMeta } from "@/types/auth"; -import { useState } from "react"; +import type { StepFieldMeta } from "@/types/auth"; + +type SetPasswordResponse = { ok: true; message: string } | { ok: false; message: string }; export function SignupPasswordStep({ fieldId, fieldName }: StepFieldMeta) { - const { password, setPassword } = useSignupFormStore(); + const { email, accessToken, password, setPassword, setAccessToken } = useSignupFormStore(); const { goNext, goPrev } = useSignupStepStore(); const [passwordError, setPasswordError] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); const [passwordConfirmError, setPasswordConfirmError] = useState(""); + // ✅ 서버 메시지 + 로딩(버튼 `disabled`는 걸지 않고 로직으로만 방지) + const [serverMsg, setServerMsg] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const { inputType, iconName, ariaLabel, toggleVisibility } = usePasswordVisibility(false); - const handleSubmit = useAuthFormSubmit(() => { + const setPasswordOnServer = async (): Promise => { + if (isSaving) return false; + + try { + setIsSaving(true); + setServerMsg(""); + + const res = await fetch("/api/set-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken ?? ""}`, + }, + body: JSON.stringify({ email, password }), + }); + + const data = (await res.json()) as SetPasswordResponse; + + if (!res.ok || !data.ok) { + setServerMsg(data?.message || "비밀번호 저장에 실패했습니다."); + return false; + } + + // ✅ 토큰은 비밀번호 설정에만 쓰고 제거해도 됨(선택) + setAccessToken(""); + + return true; + } catch { + setServerMsg("비밀번호 저장에 실패했습니다."); + return false; + } finally { + setIsSaving(false); + } + }; + + const handleSubmit = useAuthFormSubmit(async () => { let hasError = false; + // ✅ 기존 로컬 검증 로직 유지 if (!isValidPassword(password)) { setPasswordError("8자리 이상, 특수문자를 포함해야 합니다."); hasError = true; @@ -39,7 +82,9 @@ export function SignupPasswordStep({ fieldId, fieldName }: StepFieldMeta) { if (hasError) return; - console.log("🔐 Signup Password Step:", { password }); + // ✅ 서버 저장 성공 시에만 다음 단계 + const ok = await setPasswordOnServer(); + if (!ok) return; goNext(); }); @@ -68,7 +113,10 @@ export function SignupPasswordStep({ fieldId, fieldName }: StepFieldMeta) { required placeholder="비밀번호를 입력하세요" value={password} - onChange={(event) => setPassword(event.target.value)} + onChange={(event) => { + setPassword(event.target.value); + setServerMsg(""); + }} onFocus={() => setPasswordError("")} className={cn( "w-full pr-10", @@ -109,7 +157,10 @@ export function SignupPasswordStep({ fieldId, fieldName }: StepFieldMeta) { required placeholder="비밀번호를 다시 한 번 입력하세요" value={passwordConfirm} - onChange={(event) => setPasswordConfirm(event.target.value)} + onChange={(event) => { + setPasswordConfirm(event.target.value); + setServerMsg(""); + }} onFocus={() => setPasswordConfirmError("")} className={cn( "w-full pr-10", @@ -126,6 +177,8 @@ export function SignupPasswordStep({ fieldId, fieldName }: StepFieldMeta) { + + {serverMsg && {serverMsg}} {/* 3. 버튼 (세로 정렬) */} diff --git a/src/components/auth/signup/SignupTermsStep.tsx b/src/components/auth/signup/SignupTermsStep.tsx index d537e0e..4d74a30 100644 --- a/src/components/auth/signup/SignupTermsStep.tsx +++ b/src/components/auth/signup/SignupTermsStep.tsx @@ -1,20 +1,26 @@ "use client"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { supabaseAuth } from "@/api/client"; import { useAuthFormSubmit } from "@/hooks/useAuthFormSubmit"; import { Button } from "@/shared/button"; import { useSignupFormStore } from "@/stores/signupFormStore"; import { useSignupStepStore } from "@/stores/signupStepStore"; -import { StepFieldMeta } from "@/types/auth"; -import { useState } from "react"; +import type { StepFieldMeta } from "@/types/auth"; export function SignupTermsStep({ fieldId, fieldName }: StepFieldMeta) { - const { email, name, password, agreeToTerms, setAgreeToTerms, reset } = useSignupFormStore(); + const router = useRouter(); + + const { email, password, agreeToTerms, setAgreeToTerms, reset } = useSignupFormStore(); const { goPrev } = useSignupStepStore(); const [serviceChecked, setServiceChecked] = useState(false); const [privacyChecked, setPrivacyChecked] = useState(false); const [termsError, setTermsError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); const syncTerms = (nextService: boolean, nextPrivacy: boolean) => { setServiceChecked(nextService); @@ -28,22 +34,35 @@ export function SignupTermsStep({ fieldId, fieldName }: StepFieldMeta) { } }; - const handleSubmit = useAuthFormSubmit(() => { + const handleSubmit = useAuthFormSubmit(async () => { if (!agreeToTerms) { setTermsError("필수 약관에 모두 동의해야 합니다."); return; } - const signupPayload = { - email, - name, - password, - agreeToTerms, - }; + if (isSubmitting) return; - console.log("🎉 Signup completed:", signupPayload); + try { + setIsSubmitting(true); + setTermsError(""); - reset(); + const { error } = await supabaseAuth.auth.signInWithPassword({ + email: email.trim().toLowerCase(), + password, + }); + + if (error) { + setTermsError("회원가입 완료 처리 중 오류가 발생했습니다."); + return; + } + + reset(); + router.replace("/"); + } catch { + setTermsError("회원가입 완료 처리 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } }); return ( @@ -101,8 +120,9 @@ export function SignupTermsStep({ fieldId, fieldName }: StepFieldMeta) { {/* 버튼: 회원가입 완료 / 이전 (세로 정렬) */} - 회원가입 완료 + {isSubmitting ? "처리 중" : "회원가입 완료"} + 이전 diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts new file mode 100644 index 0000000..e0b290b --- /dev/null +++ b/src/lib/supabase/admin.ts @@ -0,0 +1,8 @@ +import { createClient } from "@supabase/supabase-js"; + +const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + +export const supabaseAdmin = createClient(url, serviceRoleKey, { + auth: { persistSession: false }, +}); diff --git a/src/stores/signupFormStore.ts b/src/stores/signupFormStore.ts index bdbb659..704e2e1 100644 --- a/src/stores/signupFormStore.ts +++ b/src/stores/signupFormStore.ts @@ -8,6 +8,7 @@ const initialSignupValues: SignupFormValues = { name: "", password: "", agreeToTerms: false, + accessToken: "", }; export const useSignupFormStore = create((set) => ({ @@ -33,6 +34,11 @@ export const useSignupFormStore = create((set) => ({ agreeToTerms: value, })), + setAccessToken: (value: string) => + set(() => ({ + accessToken: value, + })), + reset: () => set(() => ({ ...initialSignupValues, diff --git a/src/types/auth.ts b/src/types/auth.ts index 473fab8..e04d3f4 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -1,5 +1,9 @@ import type { FormEvent, ReactNode } from "react"; +/* ========================================================= + * 공통 (Common) + * ========================================================= */ + /** 공통 children 용 */ export interface AuthCommonProps { children: ReactNode; @@ -48,6 +52,10 @@ export type AuthFormSubmitHandler = (event: FormEvent) => void; /** Auth 폼 콜백 타입 (폼 로직에서 쓰는 콜백, event는 받지 않음) */ export type AuthFormSubmitCallback = () => void; +/* ========================================================= + * 회원가입 (Signup) + * ========================================================= */ + /** 회원가입 다단계 스텝 키 */ export type SignupStepKey = "email" | "name" | "password" | "terms"; @@ -61,19 +69,65 @@ export interface SignupStepState { reset: () => void; } -/** 회원가입 다단계 스텝 키 */ -export type ForgotPasswordStepKey = "email" | "verify" | "reset"; +/** (회원가입용) Signup 폼 값 */ +export interface SignupFormValues { + email: string; + name: string; + password: string; + agreeToTerms: boolean; + accessToken: string; +} -/** 비밀번호 찾기 스텝/방향 Zustand용 상태 + 액션 */ -export interface ForgotPasswordStepState { - step: ForgotPasswordStepKey; - direction: StepDirection; - goTo: (next: ForgotPasswordStepKey) => void; - goNext: () => void; - goPrev: () => void; +/** (회원가입용) Signup 폼 Zustand 상태 + 액션 */ +export interface SignupFormState extends SignupFormValues { + setEmail: (value: string) => void; + setName: (value: string) => void; + setPassword: (value: string) => void; + setAgreeToTerms: (value: boolean) => void; + setAccessToken: (value: string) => void; reset: () => void; } +// 이메일 중복 확인 API 요청 바디 +export type CheckEmailBody = { + email?: string; +}; + +// 이메일 중복 확인 API 성공 응답 +export type CheckEmailOkRes = { + ok: true; + available: boolean; + message: string; +}; + +// 이메일 중복 확인 API 실패 응답 +export type CheckEmailFailRes = { + ok: false; + available: false; + message: string; +}; + +// 이메일 중복 확인 API 최종 응답 타입 (성공 | 실패) +export type CheckEmailResponse = CheckEmailOkRes | CheckEmailFailRes; + +/** OTP 발송 API 응답 */ +export interface SendOtpResponse { + ok: boolean; + message: string; +} + +/** OTP 검증 API 응답 */ +export interface VerifyOtpResponse { + ok: boolean; + verified: boolean; + message: string; + accessToken?: string; +} + +/* ========================================================= + * 로그인 (Login) + * ========================================================= */ + /** 로그인 폼 값 */ export interface LoginFormValues { email: string; @@ -87,31 +141,31 @@ export interface LoginFormState extends LoginFormValues { reset: () => void; } -/** (회원가입용) Signup 폼 값 */ -export interface SignupFormValues { - email: string; - name: string; - password: string; - agreeToTerms: boolean; -} +/* ========================================================= + * 비밀번호 찾기 (Forgot Password) + * ========================================================= */ -/** (회원가입용) Signup 폼 Zustand 상태 + 액션 */ -export interface SignupFormState extends SignupFormValues { - setEmail: (value: string) => void; - setName: (value: string) => void; - setPassword: (value: string) => void; - setAgreeToTerms: (value: boolean) => void; +/** 비밀번호 찾기 다단계 스텝 키 */ +export type ForgotPasswordStepKey = "email" | "verify" | "reset"; + +/** 비밀번호 찾기 스텝/방향 Zustand용 상태 + 액션 */ +export interface ForgotPasswordStepState { + step: ForgotPasswordStepKey; + direction: StepDirection; + goTo: (next: ForgotPasswordStepKey) => void; + goNext: () => void; + goPrev: () => void; reset: () => void; } -/** ✅ 비밀번호 재설정 폼 값 */ +/** 비밀번호 재설정 폼 값 */ export interface ForgotPasswordFormValues { email: string; code: string; newPassword: string; } -/** ✅ 비밀번호 재설정 폼 Zustand 상태 + 액션 */ +/** 비밀번호 재설정 폼 Zustand 상태 + 액션 */ export interface ForgotPasswordFormState extends ForgotPasswordFormValues { setEmail: (value: string) => void; setCode: (value: string) => void; diff --git a/src/types/button.ts b/src/types/button.ts index cbf4974..ac3fb4b 100644 --- a/src/types/button.ts +++ b/src/types/button.ts @@ -39,7 +39,6 @@ export type AuthProps = BaseButtonProps & { bg?: BasicSignupBg; // 금지 size?: never; - disabled?: never; }; /** signup 전용 */
{serverMsg}