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)]", // ✅ 에러 테두리 )} /> - {/* ✅ 비밀번호 보기/숨기기 토글 버튼 */} + {/* 비밀번호 보기/숨기기 토글 버튼 */} - 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)} + /> + + +
+ + +
+ ) : null} + + {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) { {/* 버튼: 회원가입 완료 / 이전 (세로 정렬) */}
+ 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 전용 */