From 9f2b2736a84936ca0264705d9d8acc5931775a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=8C=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=AE=E1=86=AB?= Date: Fri, 20 Dec 2024 17:13:06 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=90=9B=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=9D=B4=EB=A9=94=EC=9D=BC=EC=9D=84=20=EC=B0=BE=EC=A7=80=20?= =?UTF-8?q?=EB=AA=BB=ED=95=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=EC=9D=84=20Firebase=20?= =?UTF-8?q?Authentication=EC=97=90=EC=84=9C=20=EC=B0=BE=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B3=A0=20users=20=EC=BB=AC=EB=A0=89=EC=85=98?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=B0=BE=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/auth/check-email/route.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app/api/auth/check-email/route.js b/src/app/api/auth/check-email/route.js index 37bf825a..20db2ead 100644 --- a/src/app/api/auth/check-email/route.js +++ b/src/app/api/auth/check-email/route.js @@ -1,5 +1,5 @@ // app/api/auth/check-email/route.js -import { auth } from "@/lib/firebaseAdmin"; +import { db } from "@/lib/firebaseAdmin"; import { NextResponse } from "next/server"; export async function POST(req) { @@ -14,13 +14,14 @@ export async function POST(req) { } try { - const userRecord = await auth.getUserByEmail(email); + const usersSnapshot = await db + .collection("users") + .where("email", "==", email) + .limit(1) + .get(); - return NextResponse.json({ exists: userRecord.emailVerified }); + return NextResponse.json({ exists: !usersSnapshot.empty }); } catch (error) { - if (error.code === "auth/user-not-found") { - return NextResponse.json({ exists: false }); - } console.error("Error in check-email:", error); return NextResponse.json( { error: "이메일 확인 중 오류가 발생했습니다." }, From 2cf0a26fcbf9dab1d68cb69f87f78662be51539d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=8C=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=AE=E1=86=AB?= Date: Fri, 20 Dec 2024 17:13:27 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/validation.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/utils/validation.js b/src/utils/validation.js index bf161926..b3bf87de 100644 --- a/src/utils/validation.js +++ b/src/utils/validation.js @@ -78,3 +78,18 @@ export function validateSecondForm(e) { return true; } + +export function validatePassword(password) { + const minLength = 6; + const maxLength = 4096; + const hasUpperCase = /[A-Z]/.test(password); + const hasLowerCase = /[a-z]/.test(password); + const hasNumber = /[0-9]/.test(password); + const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password); + const isValidLength = + password.length >= minLength && password.length <= maxLength; + + return ( + hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar && isValidLength + ); +} From 785bdff79efe4dcb7a37534ac36f719f427bd37a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=8C=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=AE=E1=86=AB?= Date: Fri, 20 Dec 2024 17:15:24 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=B0=BE=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=95=84=EC=9A=94=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=80=20useState=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/login/SocialLogin.jsx | 40 +++++++--- src/components/login/SocialLogin.module.css | 81 ++++++++++++++++++++- 2 files changed, 107 insertions(+), 14 deletions(-) diff --git a/src/components/login/SocialLogin.jsx b/src/components/login/SocialLogin.jsx index 4d64270f..4b26ca55 100644 --- a/src/components/login/SocialLogin.jsx +++ b/src/components/login/SocialLogin.jsx @@ -14,12 +14,14 @@ import { checkUserExists } from "@/utils/auth/checkUser"; import { Button, Input, LoginForm } from "../Controls"; import styles from "./SocialLogin.module.css"; import Loading from "../Loading"; +import FindPassword from "./FindPassword"; export default function SocialLogin() { const router = useRouter(); const [error, setError] = useState(""); const [showEmailForm, setShowEmailForm] = useState(false); - const [showSignupForm, setShowSignupForm] = useState(false); + const [showFindPassword, setShowFindPassword] = useState(false); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [emailValid, setEmailValid] = useState(false); @@ -32,7 +34,7 @@ export default function SocialLogin() { if (!reduxUser) return; const exists = await checkUserExists(dispatch); - if (exists === false && !showSignupForm) { + if (exists === false) { router.push("/signup"); } else if (!reduxUser.emailVerified) { setError("이메일 인증이 필요합니다. 이메일을 확인하세요."); @@ -113,11 +115,11 @@ export default function SocialLogin() { // 결과에 따라 리다이렉트 // 이메일 회원가입 중인 경우는 리다이렉션하지 않음 - if (exists === false && !showSignupForm) { + if (exists === false) { router.push("/signup"); - } else if (!reduxUser.emailVerified && !showSignupForm) { + } else if (!reduxUser.emailVerified) { setError("이메일 인증이 필요합니다. 이메일을 확인하세요."); - } else if (exists && reduxUser.emailVerified && !showSignupForm) { + } else if (exists && reduxUser.emailVerified) { router.push("/"); } } catch (error) { @@ -133,7 +135,7 @@ export default function SocialLogin() { return (
- {showEmailForm && !showSignupForm ? ( + {showEmailForm && !showFindPassword ? ( <>

이메일로 로그인하기

다시 꿈꾸러 오셔서 기뻐요!

@@ -195,12 +197,30 @@ export default function SocialLogin() { /> -
- 회원이 아니신가요? - 가입하기 -
+
    +
  • + 회원이 아니신가요? + 가입하기 +
  • +
  • + 비밀번호를 잊으셨나요? + +
  • +
+ ) : showFindPassword ? ( + ) : ( <>

로그인

diff --git a/src/components/login/SocialLogin.module.css b/src/components/login/SocialLogin.module.css index 3dcebb8a..253d83ae 100644 --- a/src/components/login/SocialLogin.module.css +++ b/src/components/login/SocialLogin.module.css @@ -20,9 +20,7 @@ main:has(.login-title) { main:has(.login-title) img { max-width: 48rem; } -.login-form { - gap: 0; -} + .login-container { display: flex; flex-direction: column; @@ -30,6 +28,11 @@ main:has(.login-title) img { align-items: center; gap: 1.6rem; } + +.login-container .login-form { + row-gap: 0; +} + .id-label { margin-bottom: 4rem; } @@ -59,6 +62,16 @@ main:has(.login-title) img { gap: 0.8rem; margin-bottom: 0.8rem; } + +.account-btns { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + row-gap: 1.6rem; + text-align: center; +} + .join-button button, .join-button button:hover, .join-button button:active, @@ -70,6 +83,18 @@ main:has(.login-title) img { color: var(--deep-pink-color); margin: 0 0 3.7rem 0.4rem; } +.find-button button, +.find-button button:hover, +.find-button button:active, +.find-button a, +.find-button a:hover, +.find-button a:active { + margin: 0 0 3.7rem 0.4rem; + padding: 0; + background: none; + color: var(--deep-pink-color); + font-size: var(--body-font-size); +} /* '/' 첫페이지 구글,이메일 로그인 버튼 */ .google-login-btn, @@ -123,6 +148,53 @@ main:has(.login-title) img { cursor: not-allowed; } +/* 비밀번호 찾기 창 */ +.login-container .find-form label { + margin-bottom: 0; + display: grid; + grid-template-rows: auto 1fr 2.4rem; +} + +.input-wrap { + width: 100%; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + column-gap: 0.6rem; + border-radius: 1rem; + background-color: var(--input-color); +} + +.password-wrap { + width: 100%; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + column-gap: 0.6rem; + border-radius: 1rem; +} + +.input-wrap button { + width: fit-content; + height: fit-content; + margin-right: 0.6rem; +} + +.invalid-text { + font-weight: 400; + font-size: var(--body-font-size); + color: var(--invalid-text-color); +} + +.bottom-btns { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1.6rem; + padding: 0 1.5rem; +} + @media (max-width: 720px) { main:has(.login-title) { height: fit-content; @@ -174,7 +246,8 @@ main:has(.login-title) img { } .error-message, .google-login, - .join-button { + .join-button, + .find-button { font-size: var(--small-text-font-size); } } From c6cc88341659ea59aaa92010e2d1b83045b8e14d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=8C=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=AE=E1=86=AB?= Date: Fri, 20 Dec 2024 17:15:43 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=B4=88=EA=B8=B0=ED=99=94=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/auth/reset-password/route.js | 114 +++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/app/api/auth/reset-password/route.js diff --git a/src/app/api/auth/reset-password/route.js b/src/app/api/auth/reset-password/route.js new file mode 100644 index 00000000..e3ef13a3 --- /dev/null +++ b/src/app/api/auth/reset-password/route.js @@ -0,0 +1,114 @@ +import { db } from "@/lib/firebaseAdmin"; +import { getAuth } from "firebase-admin/auth"; +import { Timestamp } from "firebase-admin/firestore"; + +function validatePassword(password) { + const minLength = 6; + const maxLength = 4096; + const hasUpperCase = /[A-Z]/.test(password); + const hasLowerCase = /[a-z]/.test(password); + const hasNumber = /[0-9]/.test(password); + const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password); + const isValidLength = + password.length >= minLength && password.length <= maxLength; + + return ( + hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar && isValidLength + ); +} + +export async function PUT(req) { + try { + const { email, code, newPassword } = await req.json(); + + // 비밀번호 유효성 검사 + if (!validatePassword(newPassword)) { + return Response.json( + { + error: "비밀번호는 최소 8자 이상이며, 문자와 숫자를 포함해야 합니다.", + }, + { status: 400 } + ); + } + + // 가장 최근의 인증 코드 조회 + const codesSnapshot = await db + .collection("verification_codes") + .where("email", "==", email) + .where("verified", "==", false) + .orderBy("createdAt", "desc") + .limit(1) + .get(); + + if (codesSnapshot.empty) { + return Response.json( + { error: "유효하지 않은 인증 코드입니다." }, + { status: 400 } + ); + } + + const verificationDoc = codesSnapshot.docs[0]; + const verificationData = verificationDoc.data(); + + // 만료 확인 + if (verificationData.expiresAt.toMillis() < Date.now()) { + return Response.json( + { error: "만료된 인증 코드입니다." }, + { status: 400 } + ); + } + + // 시도 횟수 확인 + if (verificationData.attempts >= 5) { + return Response.json( + { error: "최대 시도 횟수를 초과했습니다." }, + { status: 400 } + ); + } + + // 코드 확인 + if (verificationData.code !== code) { + await verificationDoc.ref.update({ + attempts: FieldValue.increment(1), + }); + return Response.json( + { error: "잘못된 인증 코드입니다." }, + { status: 400 } + ); + } + + // Firebase Auth에서 사용자 찾기 + const auth = getAuth(); + const userRecord = await auth.getUserByEmail(email); + + // 비밀번호 변경 + await auth.updateUser(userRecord.uid, { + password: newPassword, + }); + + // 인증 코드를 verified로 표시 + await verificationDoc.ref.update({ + verified: true, + verifiedAt: Timestamp.now(), + }); + + return Response.json({ + success: true, + message: "비밀번호가 성공적으로 변경되었습니다.", + }); + } catch (error) { + console.error("비밀번호 재설정 오류:", error); + + if (error.code === "auth/invalid-password") { + return Response.json( + { error: "유효하지 않은 비밀번호 형식입니다." }, + { status: 400 } + ); + } + + return Response.json( + { error: "비밀번호 재설정 중 오류가 발생했습니다." }, + { status: 500 } + ); + } +} From 50c177658a7c7eefd1d33de0be07ec19715835db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=8C=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=AE=E1=86=AB?= Date: Fri, 20 Dec 2024 17:15:53 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=B0=BE=EA=B8=B0=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/login/FindPassword.jsx | 239 ++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/components/login/FindPassword.jsx diff --git a/src/components/login/FindPassword.jsx b/src/components/login/FindPassword.jsx new file mode 100644 index 00000000..06b4f29c --- /dev/null +++ b/src/components/login/FindPassword.jsx @@ -0,0 +1,239 @@ +import { useState, useEffect } from "react"; +import { Button, Input, LoginForm } from "../Controls"; +import { validatePassword } from "@/utils/validation"; +import Link from "next/link"; +import Loading from "../Loading"; + +export default function FindPassword({ styles, setShowFindPassword }) { + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [isPasswordValid, setIsPasswordValid] = useState(false); + const [isPasswordMatch, setIsPasswordMatch] = useState(false); + const [error, setError] = useState(""); + const [emailError, setEmailError] = useState(""); + const [isEmailClicked, setIsEmailClicked] = useState(false); + const [isEmailSent, setIsEmailSent] = useState(false); + + useEffect(() => { + if (password === "") { + setIsPasswordValid(false); + } else { + setIsPasswordValid(validatePassword(password)); + } + }, [password]); + + useEffect(() => { + if (password === passwordConfirm) { + setIsPasswordMatch(true); + } else { + setIsPasswordMatch(false); + } + }, [password, passwordConfirm]); + + const handleVerifyEmail = async (e) => { + e.preventDefault(); + setIsEmailClicked(true); + + try { + const checkEmail = await fetch("/api/auth/check-email", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: email }), + }); + + const result = await checkEmail.json(); + + if (!result.exists || result.error) { + setError("이메일 확인에 실패했습니다. 다시 시도해주세요."); + setEmailError("이메일을 확인해주세요."); + return; + } else { + const sendEmail = await fetch("/api/auth/send-email-verification", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: email }), + }); + + if (!sendEmail.ok) { + setError("인증 코드 발송에 실패했습니다. 다시 시도해주세요."); + setEmailError("인증 코드 발송에 실패했습니다."); + return; + } else { + setIsEmailSent(true); + setError(""); + } + setEmailError(""); + } + } catch (err) { + setError("인증 코드 발송에 실패했습니다. 다시 시도해주세요."); + setEmailError("인증 코드 발송에 실패했습니다."); + } finally { + setIsEmailClicked(false); + } + }; + + const handleFindPassword = async (e) => { + e.preventDefault(); + + try { + const response = await fetch("/api/auth/reset-password", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, code, newPassword: password }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error); + } + + alert("비밀번호가 변경되었습니다. 다시 로그인해주세요."); + setShowFindPassword(false); + } catch (error) { + setError(error.message); + } + }; + + return ( + <> +

비밀번호 찾기

+

가입하신 이메일을 입력해주세요.

+ + + + + + + {/*

+ {error} +

*/} +
    +
  • + +
  • +
  • + +
  • +
+
+ + ); +} From c001d15f5d0ad5072a26c9a1e0248387bed8070c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=8C=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=AE=E1=86=AB?= Date: Fri, 20 Dec 2024 17:30:43 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=92=84=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B4=80=EB=A0=A8=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95:=20'=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8'?= =?UTF-8?q?=EB=A5=BC=20'=EC=83=88=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8'?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EA=B3=A0,=20'=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=B0=BE=EA=B8=B0'=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=A5=BC=20'=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=EB=A5=BC=20=EC=9E=8A=EC=9C=BC?= =?UTF-8?q?=EC=85=A8=EB=82=98=EC=9A=94=3F'=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/login/FindPassword.jsx | 2 +- src/components/login/SocialLogin.jsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/login/FindPassword.jsx b/src/components/login/FindPassword.jsx index 06b4f29c..fed8c43b 100644 --- a/src/components/login/FindPassword.jsx +++ b/src/components/login/FindPassword.jsx @@ -149,7 +149,7 @@ export default function FindPassword({ styles, setShowFindPassword }) { />